Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a434b208e | ||
|
|
508f11b558 | ||
|
|
9e1449f0db | ||
|
|
0aad5394e9 | ||
|
|
6ec239d2e0 | ||
|
|
27b4fd7698 | ||
|
|
fb3e138ea2 | ||
|
|
a7c5d1a2aa | ||
|
|
3313738990 | ||
|
|
ce86f56d37 | ||
|
|
3858b97104 | ||
|
|
5be66dbaa7 |
@@ -4,3 +4,9 @@
|
||||
# REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/
|
||||
REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/
|
||||
REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/
|
||||
|
||||
# RevenueCat public SDK keys (native IAP). Leave empty until store apps are wired.
|
||||
REACT_APP_REVENUECAT_APPLE_API_KEY=
|
||||
REACT_APP_REVENUECAT_GOOGLE_API_KEY=
|
||||
# Optional: pin a specific offering identifier (defaults to current offering).
|
||||
# REACT_APP_REVENUECAT_OFFERING_ID=
|
||||
|
||||
@@ -23,6 +23,18 @@
|
||||
*.keystore
|
||||
*.jks
|
||||
|
||||
# Capacitor / iOS CocoaPods + build artifacts (keep ios/ source; ignore Pods / DerivedData)
|
||||
/ios/App/Pods/
|
||||
/ios/App/build/
|
||||
/ios/App/output/
|
||||
/ios/App/App/public/
|
||||
/ios/DerivedData/
|
||||
/ios/**/xcuserdata/
|
||||
*.mobileprovision
|
||||
*.p12
|
||||
*.cer
|
||||
ExportOptions.plist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
|
||||
@@ -31,6 +31,7 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
|
||||
| Message Rated | `MESSAGE_RATED` | MessageActions | `{ rating: 'up' \| 'down' \| 'cleared' }` |
|
||||
| Message Rating Reason | `MESSAGE_RATING_REASON` | MessageActions | `{ reason, hasComment }` |
|
||||
| Message Exported | `MESSAGE_EXPORTED` | MessageActions, AsyncDashboard2 | `{ format, scope }` |
|
||||
| Activity Stage Completed | `ACTIVITY_STAGE_COMPLETED` | MessageContext | `{ stage, durationMs }` (no label/detail text) |
|
||||
|
||||
## Identify
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Android (Capacitor)
|
||||
|
||||
Ship the CRA web build as an Android app via [Capacitor](https://capacitorjs.com/).
|
||||
One codebase: `llm-fe/` → web + Android WebView.
|
||||
One codebase: `llm-fe/` → web + Android + iOS WebView.
|
||||
|
||||
**Build & Play deploy steps:** [`android/README.md`](android/README.md).
|
||||
**iOS sibling:** [`IOS.md`](IOS.md) / [`ios/README.md`](ios/README.md).
|
||||
|
||||
Prerequisites for blockers already merged: JWT-only auth (#22), WebSocket lifecycle (#23), HashRouter (#24).
|
||||
|
||||
@@ -39,6 +40,20 @@ npm run android:sync
|
||||
|
||||
Default `.env.mobile` matches production (`chatbackend.aimloperations.com`). Point it at beta to flip the shell without touching web deploys. Optional gitignored override: `.env.mobile.local`.
|
||||
|
||||
### RevenueCat IAP (#100)
|
||||
|
||||
Native billing uses `@revenuecat/purchases-capacitor` (Capacitor 7 → package **11.x**). Web keeps Stripe Checkout / Customer Portal.
|
||||
|
||||
Set public SDK keys in `.env.mobile` (empty placeholders OK until store apps ship):
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `REACT_APP_REVENUECAT_APPLE_API_KEY` | iOS public SDK key |
|
||||
| `REACT_APP_REVENUECAT_GOOGLE_API_KEY` | Android public SDK key |
|
||||
| `REACT_APP_REVENUECAT_OFFERING_ID` | Optional offering pin (else current) |
|
||||
|
||||
After install / key changes: `npm run build:mobile` (runs `cap sync`). More detail: [`MONETIZATION.md`](MONETIZATION.md).
|
||||
|
||||
## Versioning
|
||||
|
||||
In `android/app/build.gradle`:
|
||||
@@ -63,6 +78,8 @@ Regenerate Android densities after changing source art:
|
||||
npm run assets:generate
|
||||
```
|
||||
|
||||
Same command also regenerates iOS `Assets.xcassets` (see [`IOS.md`](IOS.md)).
|
||||
|
||||
## Signing (upload keystore)
|
||||
|
||||
**Never commit keystores.** Store outside the repo (e.g. host secrets / password manager).
|
||||
@@ -115,12 +132,20 @@ Out of band ops (not automated here):
|
||||
|
||||
1. Google Play developer account
|
||||
2. Privacy policy URL (product site / legal)
|
||||
3. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView)
|
||||
4. Screenshots for phone (+ tablet if targeting)
|
||||
5. Upload `.aab` to **internal testing** track before production
|
||||
3. **Account deletion URL** (Data safety / Account deletion) → public page:
|
||||
- Canonical: `https://hesychia.ai/account-deletion/`
|
||||
- Legacy host until DNS cutover: `https://chat.aimloperations.com/account-deletion/`
|
||||
- Capacitor HashRouter: `/#/account-deletion/`
|
||||
4. Data safety form (JWT auth, chat content, analytics if Tianji runs in WebView)
|
||||
5. Screenshots for phone (+ tablet if targeting)
|
||||
6. Upload `.aab` to **internal testing** track before production
|
||||
|
||||
In-app delete remains Account → **Delete my account** (`DELETE /api/user/`). The public URL
|
||||
above is for Play reviewers and users who never open the app (#103).
|
||||
|
||||
## Related issues
|
||||
|
||||
- [#20](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/20) — this Android wrap
|
||||
- [#21](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/21) — iOS (reuses this scaffolding)
|
||||
- [#22](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/22) / [#23](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/23) / [#24](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/24) — auth / WS / routing
|
||||
- [#103](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/103) — Play public account-deletion URL
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# iOS (Capacitor)
|
||||
|
||||
Ship the CRA web build as an iOS app via [Capacitor](https://capacitorjs.com/)
|
||||
WKWebView. One codebase: `llm-fe/` → web + Android + iOS.
|
||||
|
||||
**Build & Xcode steps:** [`ios/README.md`](ios/README.md).
|
||||
|
||||
Prerequisites already merged: JWT-only auth (#22), WebSocket lifecycle (#23),
|
||||
HashRouter (#24), Android Capacitor scaffolding (#20).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **macOS** with **Xcode** 15+ (CI runners are Linux — iOS builds are **manual**
|
||||
on a Mac until a hosted macOS runner is added)
|
||||
- **Node.js 20** (matches CI; Capacitor **7.x**)
|
||||
- **CocoaPods** (`sudo gem install cocoapods` or Homebrew `pod`)
|
||||
- Apple Developer Program membership for device / TestFlight / App Store signing
|
||||
(simulator debug runs without a paid membership using automatic signing)
|
||||
|
||||
## One-time / day-to-day
|
||||
|
||||
```bash
|
||||
cd llm-fe
|
||||
npm ci
|
||||
npm run build:mobile # .env.mobile → CRA build → cap sync (android + ios)
|
||||
npm run ios:open # opens ios/App/App.xcworkspace in Xcode
|
||||
```
|
||||
|
||||
Or sync only after an existing `build/`:
|
||||
|
||||
```bash
|
||||
npm run ios:sync
|
||||
```
|
||||
|
||||
**Always open the `.xcworkspace`**, not the `.xcodeproj` (CocoaPods).
|
||||
|
||||
## Environment
|
||||
|
||||
Same as Android — see [`ANDROID.md`](ANDROID.md) § Environment.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `.env.mobile` | Endpoints baked into the **mobile** bundle (`build:mobile`) |
|
||||
| `.env.mobile.local` | Optional gitignored override |
|
||||
|
||||
Default `.env.mobile` points at production (`chatbackend.aimloperations.com`).
|
||||
|
||||
WebView origin is `capacitor://localhost`. Backend CORS/CSRF already trusts it
|
||||
(`chat_backend` `CAPACITOR_WEBVIEW_ORIGINS`). Auth is header JWT — no cookies.
|
||||
|
||||
## Versioning
|
||||
|
||||
In Xcode target **App** → General, or `ios/App/App.xcodeproj/project.pbxproj`:
|
||||
|
||||
| Field | Xcode / build setting | Align with |
|
||||
|-------|----------------------|------------|
|
||||
| Version | `MARKETING_VERSION` (`CFBundleShortVersionString`) | `package.json` `version` + Android `versionName` (currently `0.1.0`) |
|
||||
| Build | `CURRENT_PROJECT_VERSION` (`CFBundleVersion`) | Android `versionCode` (integer; **must increase** every TestFlight / App Store upload) |
|
||||
|
||||
## Icons / splash
|
||||
|
||||
Source art in `llm-fe/assets/` (same as Android):
|
||||
|
||||
- `icon.png` / `splash.png` / `splash-dark.png`
|
||||
|
||||
Regenerate iOS + Android densities:
|
||||
|
||||
```bash
|
||||
npm run assets:generate
|
||||
```
|
||||
|
||||
## Signing
|
||||
|
||||
**Never commit certificates, profiles, or `.p12` files.**
|
||||
|
||||
1. Xcode → Signing & Capabilities → Team (Automatic for debug / internal).
|
||||
2. Bundle ID: **`ai.hesychia.chat`** (matches Android `applicationId` / Capacitor `appId`).
|
||||
3. For distribution: App Store Connect app, distribution cert, provisioning profile
|
||||
— keep credentials outside the repo (password manager / CI secrets later).
|
||||
|
||||
## Manual QA checklist
|
||||
|
||||
On **simulator and physical iPhone**:
|
||||
|
||||
- [ ] Login / logout / password reset
|
||||
- [ ] Chat streaming + markdown / code blocks (selection + copy)
|
||||
- [ ] Charts (`recharts`)
|
||||
- [ ] Dark / light theme
|
||||
- [ ] Keyboard does not cover compose input; notch / Dynamic Island / home indicator OK
|
||||
- [ ] No rubber-band overscroll on chat shell
|
||||
- [ ] Background → resume keeps or reconnects WebSocket (`appStateChange` + visibility)
|
||||
- [ ] Cellular ↔ Wi‑Fi switch reconnects socket
|
||||
- [ ] Airplane mode → restore reconnects
|
||||
|
||||
## TestFlight / App Store (follow-on)
|
||||
|
||||
Out of band (not automated here; separate deploy tickets):
|
||||
|
||||
1. Apple Developer Program + App ID `ai.hesychia.chat`
|
||||
2. Privacy policy URL + App Privacy questionnaire
|
||||
3. In-app account deletion if sign-up is offered (guideline)
|
||||
4. Enough native shell value that review does not reject as 4.2 “repackaged website”
|
||||
5. Archive → Upload → TestFlight internal → App Store
|
||||
|
||||
Push notifications, offline cache, Sign in with Apple — out of scope for this wrap.
|
||||
|
||||
## Related issues
|
||||
|
||||
- [#21](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/21) — this iOS wrap
|
||||
- [#20](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/20) — Android scaffolding
|
||||
- [#22](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/22) / [#23](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/23) / [#24](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/24) — auth / WS / routing
|
||||
@@ -0,0 +1,40 @@
|
||||
# Monetization (web Stripe + native RevenueCat)
|
||||
|
||||
## Channels
|
||||
|
||||
| Runtime | Checkout / manage | Ledger |
|
||||
|---------|-------------------|--------|
|
||||
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/monetization/...` | Stripe webhooks |
|
||||
| Native Capacitor | RevenueCat IAP (`@revenuecat/purchases-capacitor`) | RevenueCat webhooks → same invoice/payment tables |
|
||||
|
||||
FE displays whatever `/monetization/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`.
|
||||
|
||||
## App user id
|
||||
|
||||
RevenueCat `appUserID` = Django user pk as string.
|
||||
|
||||
1. Prefer `id` from `/user/get/` (serializer returns all model fields).
|
||||
2. Else JWT `user_id` claim (`SIMPLE_JWT.USER_ID_CLAIM`).
|
||||
|
||||
Backend webhook resolver accepts numeric pk (or email fallback).
|
||||
|
||||
## Mobile env keys
|
||||
|
||||
In `.env.mobile`:
|
||||
|
||||
- `REACT_APP_REVENUECAT_APPLE_API_KEY`
|
||||
- `REACT_APP_REVENUECAT_GOOGLE_API_KEY`
|
||||
- `REACT_APP_REVENUECAT_OFFERING_ID` (optional)
|
||||
|
||||
Package identifiers in the RC dashboard should include plan slugs (e.g. `founders`) so `purchasePlan(planSlug)` can match packages/products. Optional backend `REVENUECAT_PRODUCT_PLAN_MAP` maps product id → plan slug.
|
||||
|
||||
## Auth hooks
|
||||
|
||||
- `Purchases.configure` once on native (first purchase / logIn).
|
||||
- `logIn(appUserID)` after SignIn / SignUp / AuthCallback / AccountContext load.
|
||||
- `logOut` on Header2 sign-out and Delete account.
|
||||
|
||||
## Related
|
||||
|
||||
- Issue #100 (FE) + companion backend RevenueCat webhook PR
|
||||
- [`ANDROID.md`](ANDROID.md) / [`IOS.md`](IOS.md) for store builds
|
||||
@@ -107,9 +107,15 @@ Without `keystore.properties`, release builds are unsigned; debug builds do not
|
||||
|
||||
1. [Play Console](https://play.google.com/console) → app **Hesychia** (`ai.hesychia.chat`).
|
||||
2. Complete store listing: privacy policy URL, data-safety form, screenshots.
|
||||
3. **Testing → Internal testing** → create release → upload `app-release.aab`.
|
||||
4. Add testers, roll out internal track, verify on devices.
|
||||
5. Promote to closed / open / production only after internal QA passes.
|
||||
3. Set **Account deletion URL** to the public instructions page (no sign-in required):
|
||||
- `https://hesychia.ai/account-deletion/` (preferred product host)
|
||||
- or `https://chat.aimloperations.com/account-deletion/` until hesychia.ai cutover
|
||||
4. **Testing → Internal testing** → create release → upload `app-release.aab`.
|
||||
5. Add testers, roll out internal track, verify on devices.
|
||||
6. Promote to closed / open / production only after internal QA passes.
|
||||
|
||||
See also [`../ANDROID.md`](../ANDROID.md) Play checklist and issue
|
||||
[#103](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/103).
|
||||
|
||||
Each new upload needs a higher `versionCode`.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 643 KiB After Width: | Height: | Size: 644 KiB |
|
Before Width: | Height: | Size: 894 KiB After Width: | Height: | Size: 896 KiB |
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 643 KiB After Width: | Height: | Size: 644 KiB |
|
Before Width: | Height: | Size: 894 KiB After Width: | Height: | Size: 896 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 622 KiB After Width: | Height: | Size: 623 KiB |
|
Before Width: | Height: | Size: 874 KiB After Width: | Height: | Size: 875 KiB |
|
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 622 KiB After Width: | Height: | Size: 623 KiB |
|
Before Width: | Height: | Size: 874 KiB After Width: | Height: | Size: 875 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 277 B After Width: | Height: | Size: 276 B |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
@@ -6,11 +6,20 @@ const config: CapacitorConfig = {
|
||||
webDir: 'build',
|
||||
server: {
|
||||
// Bundled assets; HashRouter (#24) handles deep links without a rewrite server.
|
||||
// Android: https://localhost — iOS: capacitor://localhost (CORS trusted in chat_backend).
|
||||
androidScheme: 'https',
|
||||
iosScheme: 'capacitor',
|
||||
},
|
||||
android: {
|
||||
allowMixedContent: false,
|
||||
},
|
||||
ios: {
|
||||
// Prefer content-driven insets; CSS env(safe-area-inset-*) handles notch / home indicator.
|
||||
contentInset: 'automatic',
|
||||
// Keep WebView scroll on; rubber-band bounce disabled in AppDelegate after load.
|
||||
scrollEnabled: true,
|
||||
preferredContentMode: 'mobile',
|
||||
},
|
||||
plugins: {
|
||||
SplashScreen: {
|
||||
launchAutoHide: true,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
App/build
|
||||
App/Pods
|
||||
App/output
|
||||
App/App/public
|
||||
DerivedData
|
||||
xcuserdata
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-ios-plugins
|
||||
|
||||
# Generated Config files
|
||||
App/App/capacitor.config.json
|
||||
App/App/config.xml
|
||||
|
||||
# Signing / export artifacts — never commit
|
||||
*.mobileprovision
|
||||
*.p12
|
||||
*.cer
|
||||
ExportOptions.plist
|
||||
*.ipa
|
||||
@@ -0,0 +1,408 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 48;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC2FB1FED79650016851F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3061FED79650016851F /* App */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7F8756D8B27F46E3366F6CEA /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
504EC3031FED79650016851F /* App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||
buildPhases = (
|
||||
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = App;
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 920;
|
||||
LastUpgradeCheck = 920;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
compatibilityVersion = "Xcode 8.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
packageReferences = (
|
||||
);
|
||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
504EC3021FED79650016851F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
504EC3001FED79650016851F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC30C1FED79650016851F /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC3111FED79650016851F /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
504EC3141FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3151FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
504EC3171FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ai.hesychia.chat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3181FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ai.hesychia.chat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3141FED79650016851F /* Debug */,
|
||||
504EC3151FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3171FED79650016851F /* Debug */,
|
||||
504EC3181FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:App.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,64 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Disable WKWebView rubber-band overscroll so the MUI chat layout feels native.
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(disableWebViewBounce),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
@objc private func disableWebViewBounce() {
|
||||
guard let root = window?.rootViewController else { return }
|
||||
applyNoBounce(to: root)
|
||||
}
|
||||
|
||||
private func applyNoBounce(to controller: UIViewController) {
|
||||
if let bridge = controller as? CAPBridgeViewController,
|
||||
let scrollView = bridge.webView?.scrollView {
|
||||
scrollView.bounces = false
|
||||
scrollView.alwaysBounceVertical = false
|
||||
scrollView.alwaysBounceHorizontal = false
|
||||
}
|
||||
for child in controller.children {
|
||||
applyNoBounce(to: child)
|
||||
}
|
||||
if let presented = controller.presentedViewController {
|
||||
applyNoBounce(to: presented)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
disableWebViewBounce()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
}
|
||||
|
After Width: | Height: | Size: 874 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"size": "1024x1024",
|
||||
"filename": "AppIcon-512@2x.png",
|
||||
"platform": "ios"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "Default@1x~universal~anyany.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "Default@2x~universal~anyany.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "Default@3x~universal~anyany.png",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "universal",
|
||||
"scale": "1x",
|
||||
"filename": "Default@1x~universal~anyany-dark.png"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "universal",
|
||||
"scale": "2x",
|
||||
"filename": "Default@2x~universal~anyany-dark.png"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "universal",
|
||||
"scale": "3x",
|
||||
"filename": "Default@3x~universal~anyany-dark.png"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</imageView>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Splash" width="1366" height="1366"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Hesychia</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,27 @@
|
||||
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
|
||||
|
||||
platform :ios, '14.0'
|
||||
use_frameworks!
|
||||
|
||||
# workaround to avoid Xcode caching of Pods that requires
|
||||
# Product -> Clean Build Folder after new Cordova plugins installed
|
||||
# Requires CocoaPods 1.6 or newer
|
||||
install! 'cocoapods', :disable_input_output_paths => true
|
||||
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
|
||||
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
||||
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
|
||||
end
|
||||
|
||||
target 'App' do
|
||||
capacitor_pods
|
||||
# Add your Pods here
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
assertDeploymentTarget(installer)
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
PODS:
|
||||
- Capacitor (7.6.8):
|
||||
- CapacitorCordova
|
||||
- CapacitorApp (7.1.2):
|
||||
- Capacitor
|
||||
- CapacitorCordova (7.6.8)
|
||||
- CapacitorKeyboard (7.0.6):
|
||||
- Capacitor
|
||||
- CapacitorPreferences (7.0.4):
|
||||
- Capacitor
|
||||
- CapacitorStatusBar (7.0.6):
|
||||
- Capacitor
|
||||
|
||||
DEPENDENCIES:
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorKeyboard (from `../../node_modules/@capacitor/keyboard`)"
|
||||
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
||||
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Capacitor:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorApp:
|
||||
:path: "../../node_modules/@capacitor/app"
|
||||
CapacitorCordova:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorKeyboard:
|
||||
:path: "../../node_modules/@capacitor/keyboard"
|
||||
CapacitorPreferences:
|
||||
:path: "../../node_modules/@capacitor/preferences"
|
||||
CapacitorStatusBar:
|
||||
:path: "../../node_modules/@capacitor/status-bar"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Capacitor: ff6bf01336ac353098378828aff465095a89e459
|
||||
CapacitorApp: f01a913211780e0718dae9750442c3e23f96e106
|
||||
CapacitorCordova: e61ee8c40101b8cd011d0224261606a290c082cf
|
||||
CapacitorKeyboard: a2e0869edd229490ce36aed2549c0d6b95e27ee8
|
||||
CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
|
||||
CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6
|
||||
|
||||
PODFILE CHECKSUM: c2e7da979a3cb960e70090cd1956727cb3ca3413
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -0,0 +1,89 @@
|
||||
# Hesychia — iOS
|
||||
|
||||
Native Capacitor project for `ai.hesychia.chat`. Web UI comes from the CRA
|
||||
`build/` output in the parent `llm-fe/` package (synced with `npx cap sync`).
|
||||
|
||||
Broader Capacitor notes (env files, icons, QA, TestFlight): [`../IOS.md`](../IOS.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS + **Xcode** 15+
|
||||
- Node.js **20**
|
||||
- **CocoaPods** (`pod --version`)
|
||||
- Open **`App.xcworkspace`** (not `.xcodeproj`)
|
||||
|
||||
## Build the web bundle and sync
|
||||
|
||||
From the **npm root** (`llm-fe/`, one level up):
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
npm ci
|
||||
npm run build:mobile # .env.mobile → react-scripts build → cap sync
|
||||
```
|
||||
|
||||
Open in Xcode:
|
||||
|
||||
```bash
|
||||
npm run ios:open
|
||||
# or: npx cap open ios
|
||||
```
|
||||
|
||||
Re-sync after changing web code (with an existing `build/`):
|
||||
|
||||
```bash
|
||||
npm run ios:sync
|
||||
```
|
||||
|
||||
Backend URLs are baked in at web-build time via `../.env.mobile` (prod by default).
|
||||
|
||||
After cloning, if Pods are missing:
|
||||
|
||||
```bash
|
||||
cd App
|
||||
pod install
|
||||
```
|
||||
|
||||
## Run on a simulator / device
|
||||
|
||||
1. Open `App/App.xcworkspace` in Xcode.
|
||||
2. Pick a simulator or a paired iPhone (Signing & Capabilities → your Team).
|
||||
3. Product → Run (⌘R).
|
||||
|
||||
CLI (optional):
|
||||
|
||||
```bash
|
||||
xcodebuild -workspace App/App.xcworkspace \
|
||||
-scheme App \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
build
|
||||
```
|
||||
|
||||
## Version before a store release
|
||||
|
||||
Edit target **App** → General, or `App.xcodeproj` build settings:
|
||||
|
||||
| Field | Build setting | Rule |
|
||||
|-------|---------------|------|
|
||||
| Version | `MARKETING_VERSION` | Keep in sync with `../package.json` `version` + Android `versionName` |
|
||||
| Build | `CURRENT_PROJECT_VERSION` | Integer; **must increase** for every TestFlight / App Store upload |
|
||||
|
||||
Currently: version **0.1.0**, build **1**.
|
||||
|
||||
## Signing
|
||||
|
||||
**Do not commit** `.p12`, `.mobileprovision`, or export option files with secrets.
|
||||
|
||||
Debug / simulator: Automatic signing with a personal team is enough.
|
||||
|
||||
Distribution: App Store Connect + distribution certificate + profile — credentials
|
||||
live outside this repo. CI/TestFlight automation is a follow-on ticket.
|
||||
|
||||
## Useful paths
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `App/App/AppDelegate.swift` | Disables WKWebView rubber-band bounce |
|
||||
| `App/App/Info.plist` | Display name **Hesychia**, version keys |
|
||||
| `App/App/Assets.xcassets` | App icon + splash (regenerate via `npm run assets:generate`) |
|
||||
| `App/Podfile` / `Podfile.lock` | CocoaPods (commit lockfile; Pods/ is gitignored) |
|
||||
@@ -12,6 +12,7 @@
|
||||
"@capacitor/app": "^7.1.2",
|
||||
"@capacitor/cli": "^7.6.8",
|
||||
"@capacitor/core": "^7.6.8",
|
||||
"@capacitor/ios": "^7.6.8",
|
||||
"@capacitor/keyboard": "^7.0.6",
|
||||
"@capacitor/preferences": "^7.0.4",
|
||||
"@capacitor/status-bar": "^7.0.6",
|
||||
@@ -20,6 +21,7 @@
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^5.16.11",
|
||||
"@mui/material": "^5.16.11",
|
||||
"@revenuecat/purchases-capacitor": "^11.3.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
@@ -32,7 +34,6 @@
|
||||
"bootstrap": "^5.3.3",
|
||||
"brace-expansion": "file:vendor/brace-expansion-compat",
|
||||
"chroma-js": "^3.1.2",
|
||||
"docx": "^9.7.1",
|
||||
"formik": "^2.4.6",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -2465,6 +2466,15 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/ios": {
|
||||
"version": "7.6.8",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-7.6.8.tgz",
|
||||
"integrity": "sha512-TAM1FdI1Cfl8e/wLBo7x5m61V/J1fMEpyxU2psZzHzsa436k2pwEME6CQoBRYdw6Q6gud0jZSPwOVrNn+MI7Ag==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^7.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/keyboard": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-7.0.6.tgz",
|
||||
@@ -2496,7 +2506,7 @@
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
@@ -2509,7 +2519,7 @@
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
@@ -5229,6 +5239,24 @@
|
||||
"react": ">=16.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@revenuecat/purchases-capacitor": {
|
||||
"version": "11.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-capacitor/-/purchases-capacitor-11.3.2.tgz",
|
||||
"integrity": "sha512-3T4/lcAwpbPagrT4DuXvJ+8RewzFmHf3fQJQuhVy+uTQk5HGMHNlXcm2A/UdyHtWqU+/VqWbCClPFYGnpnNXAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@revenuecat/purchases-typescript-internal-esm": "17.25.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@revenuecat/purchases-typescript-internal-esm": {
|
||||
"version": "17.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal-esm/-/purchases-typescript-internal-esm-17.25.0.tgz",
|
||||
"integrity": "sha512-KC4BjFaQclXqFafG1Enh7t8GSwdg8p905by7UrHq0WSJmLhOR6yXSj6WDv1iwsFSAZxPPwgozNxt+U6IOFTdCA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/plugin-babel": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
||||
@@ -5580,7 +5608,6 @@
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
@@ -5973,35 +6000,34 @@
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
@@ -6414,7 +6440,7 @@
|
||||
"version": "18.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
|
||||
"integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -9025,7 +9051,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
@@ -9894,61 +9920,10 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx": {
|
||||
"version": "9.7.1",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
|
||||
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"hash.js": "^1.1.7",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.1.3",
|
||||
"xml": "^1.0.1",
|
||||
"xml-js": "^1.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/@types/node": {
|
||||
"version": "25.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
|
||||
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/nanoid": {
|
||||
"version": "5.1.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-converter": {
|
||||
@@ -12440,16 +12415,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
@@ -12846,12 +12811,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "9.0.21",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
||||
@@ -15769,54 +15728,6 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||
@@ -15903,15 +15814,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
@@ -16109,7 +16011,6 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
@@ -16152,7 +16053,7 @@
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/makeerror": {
|
||||
@@ -19062,7 +18963,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -19273,7 +19173,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -20721,7 +20620,6 @@
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
@@ -20989,12 +20887,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -21818,6 +21710,12 @@
|
||||
"postcss": "^8.2.15"
|
||||
}
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
||||
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/stylis-plugin-rtl": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz",
|
||||
@@ -22402,7 +22300,7 @@
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
@@ -22446,7 +22344,7 @@
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
@@ -22459,14 +22357,14 @@
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-node/node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
@@ -22651,7 +22549,6 @@
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -22899,7 +22796,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
@@ -23903,16 +23800,11 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml-js": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": "^1.2.4"
|
||||
@@ -24053,7 +23945,7 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"@capacitor/app": "^7.1.2",
|
||||
"@capacitor/cli": "^7.6.8",
|
||||
"@capacitor/core": "^7.6.8",
|
||||
"@capacitor/ios": "^7.6.8",
|
||||
"@capacitor/keyboard": "^7.0.6",
|
||||
"@capacitor/preferences": "^7.0.4",
|
||||
"@capacitor/status-bar": "^7.0.6",
|
||||
@@ -15,6 +16,7 @@
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^5.16.11",
|
||||
"@mui/material": "^5.16.11",
|
||||
"@revenuecat/purchases-capacitor": "^11.3.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
@@ -27,7 +29,6 @@
|
||||
"bootstrap": "^5.3.3",
|
||||
"brace-expansion": "file:vendor/brace-expansion-compat",
|
||||
"chroma-js": "^3.1.2",
|
||||
"docx": "^9.7.1",
|
||||
"formik": "^2.4.6",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -55,10 +56,12 @@
|
||||
"build": "NODE_ENV=production react-scripts build",
|
||||
"build:prod": "NODE_ENV=production react-scripts build && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/",
|
||||
"build:beta": "bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && mkdir -p /var/www/beta.chat.aimloperations/html && cp -r ./build/* /var/www/beta.chat.aimloperations/html/",
|
||||
"build:mobile": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync android",
|
||||
"build:mobile": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync",
|
||||
"android:open": "npx cap open android",
|
||||
"android:sync": "npx cap sync android",
|
||||
"assets:generate": "npx capacitor-assets generate --android",
|
||||
"ios:open": "npx cap open ios",
|
||||
"ios:sync": "npx cap sync ios",
|
||||
"assets:generate": "npx capacitor-assets generate --ios --android",
|
||||
"test": "NODE_ENV=development react-scripts test",
|
||||
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false",
|
||||
"eject": "react-scripts eject"
|
||||
|
||||
@@ -11,6 +11,7 @@ import NotFound from './llm-fe/pages/NotFound/NotFound';
|
||||
import { AuthContext } from './llm-fe/contexts/AuthContext';
|
||||
import TermsOfService from './llm-fe/pages/TermsOfService/TermsOfService';
|
||||
import PublicTermsOfServicePage from './llm-fe/pages/TermsOfService/PublicTermsOfServicePage';
|
||||
import AccountDeletionPage from './llm-fe/pages/AccountDeletion/AccountDeletionPage';
|
||||
import SetPassword from './llm-fe/components/SetPassword/SetPassword';
|
||||
import AsyncDashboard2 from './llm-fe/pages/AsyncDashboard2/AsyncDashboard2';
|
||||
import FeedbackPage2 from './llm-fe/pages/FeedbackPage2/FeedbackPage2';
|
||||
@@ -71,6 +72,8 @@ class App extends Component {
|
||||
{/* Public legal pages (also used for post-login ToS acknowledge) */}
|
||||
<Route path={"/terms_of_service/"} Component={TermsOfService} />
|
||||
<Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} />
|
||||
{/* Play Console account-deletion URL (#103) — must stay public */}
|
||||
<Route path={"/account-deletion/"} Component={AccountDeletionPage} />
|
||||
|
||||
<Route element={<ProtectedRoutes />}>
|
||||
<Route path={"/"} index={true} Component={AsyncDashboard2} />
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import ActivityIndicator from './ActivityIndicator';
|
||||
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
|
||||
|
||||
const theme = {
|
||||
main: '#336699',
|
||||
focus: '#224466',
|
||||
darkMode: true,
|
||||
colors: {
|
||||
text: '#ffffff',
|
||||
cardBackground: 'rgba(0,0,0,0.3)',
|
||||
cardBorder: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
};
|
||||
|
||||
const renderIndicator = (
|
||||
props: React.ComponentProps<typeof ActivityIndicator>,
|
||||
) =>
|
||||
render(
|
||||
<ThemeProvider theme={theme as never}>
|
||||
<ActivityIndicator {...props} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
describe('ActivityIndicator (#96)', () => {
|
||||
it('has status role and polite live region regardless of state', () => {
|
||||
renderIndicator({ stage: null });
|
||||
const status = screen.getByRole('status');
|
||||
expect(status).toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
it('falls back to three dots when stage is null', () => {
|
||||
renderIndicator({ stage: null });
|
||||
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the label verbatim for unknown/future stage values', () => {
|
||||
renderIndicator({ stage: 'some_future_stage', label: 'Doing something new' });
|
||||
expect(screen.getByText('Doing something new')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows optional detail text', () => {
|
||||
renderIndicator({
|
||||
stage: 'searching',
|
||||
label: 'Searching the web',
|
||||
detail: 'query: best pizza in town',
|
||||
});
|
||||
expect(screen.getByText('Searching the web')).toBeInTheDocument();
|
||||
expect(screen.getByText('query: best pizza in town')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders completed stages above with check marks', () => {
|
||||
const history: ActivityHistoryEntry[] = [
|
||||
{ stage: 'searching', label: 'Searched the web', startedAt: 0, finishedAt: 100 },
|
||||
{ stage: 'reading', label: 'Read 3 sources', startedAt: 100, finishedAt: 400 },
|
||||
];
|
||||
renderIndicator({
|
||||
stage: 'writing',
|
||||
label: null,
|
||||
history,
|
||||
});
|
||||
expect(screen.getByText('Searched the web')).toBeInTheDocument();
|
||||
expect(screen.getByText('Read 3 sources')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('✓')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('hides everything when interrupted', () => {
|
||||
const { container } = renderIndicator({
|
||||
stage: 'searching',
|
||||
label: 'Searching the web',
|
||||
interrupted: true,
|
||||
});
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('shows a generic label when stage is present but label is cleared (writing stage)', () => {
|
||||
renderIndicator({ stage: 'writing', label: null });
|
||||
expect(screen.getByText('Working…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('prefers-reduced-motion', () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalMatchMedia,
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the spinner animation when the user prefers reduced motion', () => {
|
||||
const matchMediaMock = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes('prefers-reduced-motion'),
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
}));
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: matchMediaMock,
|
||||
});
|
||||
|
||||
renderIndicator({ stage: 'searching', label: 'Searching the web' });
|
||||
const spinner = screen.getByTestId('activity-spinner');
|
||||
expect(spinner).toHaveAttribute('data-reduced-motion', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows elapsed time after ~10s and "Still working…" after ~30s of silence', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
renderIndicator({ stage: 'searching', label: 'Searching the web' });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(11_000);
|
||||
});
|
||||
expect(screen.getByText('11s')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(20_000);
|
||||
});
|
||||
expect(screen.getByText('Still working…')).toBeInTheDocument();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import React, { useEffect, useReducer, useRef, useState } from 'react';
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
import type { ActivityHistoryEntry } from '../../utils/wsFrames';
|
||||
|
||||
/** Minimum time a single stage stays visible before advancing to a queued update (#96). */
|
||||
const MIN_DISPLAY_MS = 400;
|
||||
const ELAPSED_THRESHOLD_MS = 10_000;
|
||||
const SILENCE_THRESHOLD_MS = 30_000;
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)')?.matches ?? false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
|
||||
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
if (!mql) return undefined;
|
||||
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}
|
||||
// Safari < 14 fallback
|
||||
mql.addListener(handler);
|
||||
return () => mql.removeListener(handler);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
|
||||
type QueueItem = { stage: string; label: string | null; detail: string | null };
|
||||
|
||||
type IndicatorState = {
|
||||
current: QueueItem | null;
|
||||
queue: QueueItem[];
|
||||
lastIncomingStage: string | null;
|
||||
};
|
||||
|
||||
type IndicatorAction =
|
||||
| { type: 'INCOMING'; item: QueueItem | null }
|
||||
| { type: 'ADVANCE' }
|
||||
| { type: 'RESET' };
|
||||
|
||||
const initialIndicatorState: IndicatorState = {
|
||||
current: null,
|
||||
queue: [],
|
||||
lastIncomingStage: null,
|
||||
};
|
||||
|
||||
function indicatorReducer(state: IndicatorState, action: IndicatorAction): IndicatorState {
|
||||
switch (action.type) {
|
||||
case 'RESET':
|
||||
return initialIndicatorState;
|
||||
case 'INCOMING': {
|
||||
if (!action.item) return initialIndicatorState;
|
||||
// Same stage as the last update seen — refresh content in place (label/detail tweak),
|
||||
// no need to enforce the min-display queue since it isn't a stage transition.
|
||||
if (action.item.stage === state.lastIncomingStage) {
|
||||
if (state.queue.length > 0) {
|
||||
const queue = [...state.queue];
|
||||
queue[queue.length - 1] = action.item;
|
||||
return { ...state, queue };
|
||||
}
|
||||
return { ...state, current: action.item };
|
||||
}
|
||||
if (!state.current) {
|
||||
return { current: action.item, queue: [], lastIncomingStage: action.item.stage };
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
queue: [...state.queue, action.item],
|
||||
lastIncomingStage: action.item.stage,
|
||||
};
|
||||
}
|
||||
case 'ADVANCE': {
|
||||
if (state.queue.length === 0) return state;
|
||||
const [next, ...rest] = state.queue;
|
||||
return { ...state, current: next, queue: rest };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const spin = keyframes`
|
||||
to { transform: rotate(360deg); }
|
||||
`;
|
||||
|
||||
const bounce = keyframes`
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
`;
|
||||
|
||||
const Root = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
font-size: 0.9rem;
|
||||
|
||||
@media (max-width: 360px) {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const HistoryList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
opacity: 0.6;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const HistoryItem = styled.li`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
min-width: 0;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const CheckMark = styled.span`
|
||||
flex-shrink: 0;
|
||||
color: ${({ theme }) => (theme.darkMode ? '#8ee6a0' : '#2e7d32')};
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const CurrentRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Spinner = styled.span<{ $reducedMotion: boolean }>`
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
opacity: 0.7;
|
||||
${({ $reducedMotion }) =>
|
||||
$reducedMotion
|
||||
? css`
|
||||
animation: none;
|
||||
`
|
||||
: css`
|
||||
animation: ${spin} 0.8s linear infinite;
|
||||
`}
|
||||
`;
|
||||
|
||||
const TextColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const LabelText = styled.span`
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const DetailText = styled.span`
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.65;
|
||||
margin-top: 0.1rem;
|
||||
`;
|
||||
|
||||
const MetaText = styled.span`
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.55;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const DotsRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0;
|
||||
`;
|
||||
|
||||
const Dot = styled.span<{ $delay: string; $reducedMotion: boolean }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: 0 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
${({ $reducedMotion, $delay }) =>
|
||||
$reducedMotion
|
||||
? css`
|
||||
opacity: 0.6;
|
||||
`
|
||||
: css`
|
||||
animation: ${bounce} 1.4s infinite ease-in-out both;
|
||||
animation-delay: ${$delay};
|
||||
`}
|
||||
`;
|
||||
|
||||
export type ActivityIndicatorProps = {
|
||||
/** Current stage key from the latest "status" frame, or null before the first update. */
|
||||
stage: string | null;
|
||||
label?: string | null;
|
||||
detail?: string | null;
|
||||
/** Previously completed stages for this turn, oldest first. */
|
||||
history?: ActivityHistoryEntry[];
|
||||
/** Hides the indicator entirely once a stream is interrupted (#96). */
|
||||
interrupted?: boolean;
|
||||
};
|
||||
|
||||
const ActivityIndicator = ({
|
||||
stage,
|
||||
label = null,
|
||||
detail = null,
|
||||
history = [],
|
||||
interrupted = false,
|
||||
}: ActivityIndicatorProps): JSX.Element | null => {
|
||||
const [state, dispatch] = useReducer(indicatorReducer, initialIndicatorState);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const overallStartRef = useRef<number | null>(null);
|
||||
const lastUpdateRef = useRef<number>(Date.now());
|
||||
const currentSinceRef = useRef<number>(Date.now());
|
||||
const advanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [, forceTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (interrupted || stage == null) {
|
||||
overallStartRef.current = null;
|
||||
dispatch({ type: 'RESET' });
|
||||
return;
|
||||
}
|
||||
if (overallStartRef.current == null) {
|
||||
overallStartRef.current = Date.now();
|
||||
}
|
||||
lastUpdateRef.current = Date.now();
|
||||
dispatch({ type: 'INCOMING', item: { stage, label, detail } });
|
||||
}, [stage, label, detail, interrupted]);
|
||||
|
||||
// Reset the per-stage clock whenever the displayed stage changes.
|
||||
useEffect(() => {
|
||||
currentSinceRef.current = Date.now();
|
||||
}, [state.current?.stage]);
|
||||
|
||||
// Enforce the minimum display time per stage before advancing the queue.
|
||||
useEffect(() => {
|
||||
if (advanceTimerRef.current) {
|
||||
clearTimeout(advanceTimerRef.current);
|
||||
advanceTimerRef.current = null;
|
||||
}
|
||||
if (state.queue.length === 0) return undefined;
|
||||
const elapsed = Date.now() - currentSinceRef.current;
|
||||
const remaining = Math.max(MIN_DISPLAY_MS - elapsed, 0);
|
||||
advanceTimerRef.current = setTimeout(() => {
|
||||
dispatch({ type: 'ADVANCE' });
|
||||
}, remaining);
|
||||
return () => {
|
||||
if (advanceTimerRef.current) clearTimeout(advanceTimerRef.current);
|
||||
};
|
||||
}, [state.queue.length]);
|
||||
|
||||
// Tick once a second while active so elapsed/"still working" copy stays fresh.
|
||||
useEffect(() => {
|
||||
if (!state.current) return undefined;
|
||||
const id = setInterval(() => forceTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [Boolean(state.current)]);
|
||||
|
||||
if (interrupted) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const elapsedMs = overallStartRef.current != null ? now - overallStartRef.current : 0;
|
||||
const silentMs = now - lastUpdateRef.current;
|
||||
const showElapsed = state.current != null && elapsedMs >= ELAPSED_THRESHOLD_MS;
|
||||
const stillWorking = state.current != null && silentMs >= SILENCE_THRESHOLD_MS;
|
||||
const elapsedLabel = showElapsed ? `${Math.floor(elapsedMs / 1000)}s` : null;
|
||||
|
||||
return (
|
||||
<Root role="status" aria-live="polite">
|
||||
{history.length > 0 && (
|
||||
<HistoryList>
|
||||
{history.map((entry, i) => (
|
||||
<HistoryItem key={`${entry.stage}-${entry.startedAt}-${i}`}>
|
||||
<CheckMark aria-hidden="true">✓</CheckMark>
|
||||
<span>{entry.label}</span>
|
||||
</HistoryItem>
|
||||
))}
|
||||
</HistoryList>
|
||||
)}
|
||||
|
||||
{state.current ? (
|
||||
<CurrentRow>
|
||||
<Spinner
|
||||
aria-hidden="true"
|
||||
$reducedMotion={reducedMotion}
|
||||
data-testid="activity-spinner"
|
||||
data-reduced-motion={reducedMotion}
|
||||
/>
|
||||
<TextColumn>
|
||||
<LabelText>
|
||||
{stillWorking ? 'Still working…' : state.current.label || 'Working…'}
|
||||
</LabelText>
|
||||
{state.current.detail && (
|
||||
<DetailText title={state.current.detail}>{state.current.detail}</DetailText>
|
||||
)}
|
||||
</TextColumn>
|
||||
{elapsedLabel && !stillWorking && <MetaText>{elapsedLabel}</MetaText>}
|
||||
</CurrentRow>
|
||||
) : (
|
||||
<DotsRow data-testid="activity-dots-fallback">
|
||||
<Dot $delay="-0.32s" $reducedMotion={reducedMotion} />
|
||||
<Dot $delay="-0.16s" $reducedMotion={reducedMotion} />
|
||||
<Dot $delay="0s" $reducedMotion={reducedMotion} />
|
||||
</DotsRow>
|
||||
)}
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityIndicator;
|
||||
@@ -8,6 +8,9 @@ const mockGet = jest.fn();
|
||||
const mockPost = jest.fn();
|
||||
const assignMock = jest.fn();
|
||||
const mockTrackEvent = jest.fn();
|
||||
const mockPurchasePlan = jest.fn();
|
||||
const mockRestorePurchases = jest.fn();
|
||||
const mockIsNativePlatform = jest.fn(() => false);
|
||||
|
||||
jest.mock('../../../axiosApi', () => ({
|
||||
axiosInstance: {
|
||||
@@ -27,6 +30,17 @@ jest.mock('../../utils/analytics', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../platform/nativePlatform', () => ({
|
||||
isNativePlatform: () => mockIsNativePlatform(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/revenueCat', () => ({
|
||||
purchasePlan: (...args: unknown[]) => mockPurchasePlan(...args),
|
||||
restorePurchases: (...args: unknown[]) => mockRestorePurchases(...args),
|
||||
isPurchaseCancelledError: (error: { userCancelled?: boolean }) =>
|
||||
Boolean(error?.userCancelled),
|
||||
}));
|
||||
|
||||
const theme = {
|
||||
main: '#4a90e2',
|
||||
darkMode: true,
|
||||
@@ -143,16 +157,16 @@ const mockFinanceGets = ({
|
||||
plans?: unknown[];
|
||||
} = {}) => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/finance/invoices/') {
|
||||
if (url === '/monetization/invoices/') {
|
||||
return Promise.resolve({ data: invoices });
|
||||
}
|
||||
if (url === '/finance/payments/') {
|
||||
if (url === '/monetization/payments/') {
|
||||
return Promise.resolve({ data: payments });
|
||||
}
|
||||
if (url === '/finance/subscription/') {
|
||||
if (url === '/monetization/subscription/') {
|
||||
return Promise.resolve({ data: subscription });
|
||||
}
|
||||
if (url === '/finance/plans/') {
|
||||
if (url === '/monetization/plans/') {
|
||||
return Promise.resolve({ data: plans });
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected GET ${url}`));
|
||||
@@ -174,6 +188,9 @@ describe('BillingSection', () => {
|
||||
mockPost.mockReset();
|
||||
assignMock.mockReset();
|
||||
mockTrackEvent.mockReset();
|
||||
mockPurchasePlan.mockReset();
|
||||
mockRestorePurchases.mockReset();
|
||||
mockIsNativePlatform.mockReturnValue(false);
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -229,7 +246,7 @@ describe('BillingSection', () => {
|
||||
await user.click(screen.getByRole('button', { name: /Change plan/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/finance/portal/', {
|
||||
expect(mockPost).toHaveBeenCalledWith('/monetization/portal/', {
|
||||
return_url: 'http://localhost/account/',
|
||||
});
|
||||
});
|
||||
@@ -304,7 +321,7 @@ describe('BillingSection', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/finance/checkout/',
|
||||
'/monetization/checkout/',
|
||||
expect.objectContaining({
|
||||
success_url: expect.stringContaining('/billing/success'),
|
||||
cancel_url: expect.stringContaining('/billing/cancel'),
|
||||
@@ -345,4 +362,49 @@ describe('BillingSection', () => {
|
||||
expect(await screen.findByText('No Stripe customer found')).toBeInTheDocument();
|
||||
expect(assignMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('on native uses RevenueCat purchase and shows Restore', async () => {
|
||||
mockIsNativePlatform.mockReturnValue(true);
|
||||
mockFinanceGets();
|
||||
mockPurchasePlan.mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderBilling();
|
||||
|
||||
expect(await screen.findByTestId('restore-purchases')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /Complete payment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPurchasePlan).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockPost).not.toHaveBeenCalledWith(
|
||||
'/monetization/checkout/',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('shows provider badge for RevenueCat invoices', async () => {
|
||||
mockFinanceGets({
|
||||
invoices: [
|
||||
{
|
||||
...paidInvoice,
|
||||
provider: 'revenuecat',
|
||||
stripe_subscription_id: null,
|
||||
revenuecat_store: 'PLAY_STORE',
|
||||
hosted_invoice_url: '',
|
||||
description: 'Store IAP (PLAY_STORE) — founders — INITIAL_PURCHASE',
|
||||
},
|
||||
],
|
||||
subscription: {
|
||||
...foundersSubscription,
|
||||
source: 'revenuecat',
|
||||
stripe_subscription_id: '',
|
||||
},
|
||||
});
|
||||
|
||||
renderBilling();
|
||||
|
||||
expect(await screen.findByText('Play Store')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Store IAP/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,12 +12,21 @@ import {
|
||||
formatTokenCount,
|
||||
higherSelectablePlans,
|
||||
humanizeStatus,
|
||||
invoiceProviderLabel,
|
||||
isComplimentarySubscription,
|
||||
isStoreSubscription,
|
||||
isStripeSubscription,
|
||||
otherSelectablePlans,
|
||||
pickPrimaryInvoice,
|
||||
SubscriptionMe,
|
||||
SubscriptionPlanInfo,
|
||||
} from '../../utils/finance';
|
||||
import { isNativePlatform } from '../../platform/nativePlatform';
|
||||
import {
|
||||
isPurchaseCancelledError,
|
||||
purchasePlan,
|
||||
restorePurchases,
|
||||
} from '../../utils/revenueCat';
|
||||
|
||||
const GlassCard = styled.div`
|
||||
background: ${({ theme }) => theme.colors.cardBackground};
|
||||
@@ -233,6 +242,14 @@ const InvoiceLink = styled.a`
|
||||
}
|
||||
`;
|
||||
|
||||
const ProviderBadge = styled.span`
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.85;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { detail?: string } };
|
||||
@@ -241,9 +258,19 @@ function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
||||
}
|
||||
|
||||
function storeManageLabel(): string {
|
||||
if (typeof window === 'undefined') return 'App Store / Play Store';
|
||||
const Cap = window.Capacitor as { getPlatform?: () => string } | undefined;
|
||||
const platform = Cap?.getPlatform?.();
|
||||
if (platform === 'ios') return 'App Store';
|
||||
if (platform === 'android') return 'Play Store';
|
||||
return 'App Store / Play Store';
|
||||
}
|
||||
|
||||
type PortalIntent = 'manage' | 'upgrade' | 'change' | 'cancel';
|
||||
|
||||
const BillingSection = (): JSX.Element => {
|
||||
const native = isNativePlatform();
|
||||
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
|
||||
const [payments, setPayments] = useState<FinancePayment[]>([]);
|
||||
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
||||
@@ -251,8 +278,10 @@ const BillingSection = (): JSX.Element => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [listError, setListError] = useState('');
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [actionNotice, setActionNotice] = useState('');
|
||||
const [portalLoading, setPortalLoading] = useState(false);
|
||||
const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState<string | null>(null);
|
||||
const [restoreLoading, setRestoreLoading] = useState(false);
|
||||
const [showPlanPicker, setShowPlanPicker] = useState(false);
|
||||
const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change');
|
||||
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
|
||||
@@ -263,10 +292,10 @@ const BillingSection = (): JSX.Element => {
|
||||
try {
|
||||
const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] =
|
||||
await Promise.all([
|
||||
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
|
||||
axiosInstance.get<FinancePayment[]>('/finance/payments/'),
|
||||
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
|
||||
axiosInstance.get<SubscriptionPlanInfo[]>('/finance/plans/'),
|
||||
axiosInstance.get<FinanceInvoice[]>('/monetization/invoices/'),
|
||||
axiosInstance.get<FinancePayment[]>('/monetization/payments/'),
|
||||
axiosInstance.get<SubscriptionMe>('/monetization/subscription/'),
|
||||
axiosInstance.get<SubscriptionPlanInfo[]>('/monetization/plans/'),
|
||||
]);
|
||||
setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []);
|
||||
setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []);
|
||||
@@ -293,6 +322,16 @@ const BillingSection = (): JSX.Element => {
|
||||
() => isComplimentarySubscription(subscription, hasPortalAccess),
|
||||
[subscription, hasPortalAccess]
|
||||
);
|
||||
const storeSub = useMemo(
|
||||
() => isStoreSubscription(subscription?.source),
|
||||
[subscription?.source]
|
||||
);
|
||||
const stripeSub = useMemo(
|
||||
() => isStripeSubscription(subscription?.source) || hasPortalAccess,
|
||||
[subscription?.source, hasPortalAccess]
|
||||
);
|
||||
const showStripeManage = !complimentary && stripeSub && hasPortalAccess;
|
||||
const showStoreManage = !complimentary && (storeSub || (native && !showStripeManage && Boolean(subscription?.plan) && !subscription?.needs_checkout));
|
||||
const upgradePlans = useMemo(
|
||||
() => higherSelectablePlans(plans, subscription?.plan),
|
||||
[plans, subscription?.plan]
|
||||
@@ -306,6 +345,7 @@ const BillingSection = (): JSX.Element => {
|
||||
subscription?.current_period_end || primaryInvoice?.period_end || null;
|
||||
return formatBillingDate(end);
|
||||
}, [subscription?.current_period_end, primaryInvoice?.period_end]);
|
||||
const storeLabel = useMemo(() => storeManageLabel(), []);
|
||||
|
||||
const historyRows = useMemo(() => {
|
||||
if (invoices.length) {
|
||||
@@ -313,6 +353,7 @@ const BillingSection = (): JSX.Element => {
|
||||
key: `invoice-${invoice.id}`,
|
||||
date: invoice.created,
|
||||
description: invoice.description || 'Invoice',
|
||||
provider: invoiceProviderLabel(invoice),
|
||||
amount: formatMoneyCents(
|
||||
invoice.amount_paid || invoice.amount_due,
|
||||
invoice.currency
|
||||
@@ -325,6 +366,12 @@ const BillingSection = (): JSX.Element => {
|
||||
key: `payment-${payment.id}`,
|
||||
date: payment.paid_at || payment.created,
|
||||
description: 'Payment',
|
||||
provider:
|
||||
(payment.provider || '').toLowerCase() === 'revenuecat'
|
||||
? 'Store'
|
||||
: (payment.provider || '').toLowerCase() === 'stripe'
|
||||
? 'Stripe'
|
||||
: payment.provider || '—',
|
||||
amount: formatMoneyCents(payment.amount, payment.currency),
|
||||
status: humanizeStatus(payment.status),
|
||||
url: '',
|
||||
@@ -333,11 +380,12 @@ const BillingSection = (): JSX.Element => {
|
||||
|
||||
const openPortal = async (intent: PortalIntent) => {
|
||||
setActionError('');
|
||||
setActionNotice('');
|
||||
setPortalLoading(true);
|
||||
try {
|
||||
const returnUrl = `${window.location.origin}/account/`;
|
||||
const response = await axiosInstance.post<{ portal_url: string }>(
|
||||
'/finance/portal/',
|
||||
'/monetization/portal/',
|
||||
{ return_url: returnUrl }
|
||||
);
|
||||
const portalUrl = response.data?.portal_url;
|
||||
@@ -356,8 +404,55 @@ const BillingSection = (): JSX.Element => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => {
|
||||
const handleNativePurchase = async (planSlug?: string, source = 'account_billing') => {
|
||||
setActionError('');
|
||||
setActionNotice('');
|
||||
setCheckoutLoadingSlug(planSlug || '__default__');
|
||||
try {
|
||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, {
|
||||
source,
|
||||
...(planSlug ? { plan_slug: planSlug } : {}),
|
||||
});
|
||||
await purchasePlan(planSlug);
|
||||
setActionNotice(
|
||||
'Purchase submitted. Entitlements update after the store confirms — tap Refresh shortly.'
|
||||
);
|
||||
await loadBilling();
|
||||
} catch (error: unknown) {
|
||||
if (isPurchaseCancelledError(error)) {
|
||||
setActionNotice('Purchase cancelled.');
|
||||
return;
|
||||
}
|
||||
setActionError(
|
||||
apiErrorMessage(error, 'Store purchase failed. Try again or Restore purchases.')
|
||||
);
|
||||
} finally {
|
||||
setCheckoutLoadingSlug(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
setActionError('');
|
||||
setActionNotice('');
|
||||
setRestoreLoading(true);
|
||||
try {
|
||||
await restorePurchases();
|
||||
setActionNotice('Purchases restored. Refreshing billing…');
|
||||
await loadBilling();
|
||||
} catch (error: unknown) {
|
||||
setActionError(apiErrorMessage(error, 'Could not restore purchases. Try again.'));
|
||||
} finally {
|
||||
setRestoreLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => {
|
||||
if (native) {
|
||||
await handleNativePurchase(planSlug, source);
|
||||
return;
|
||||
}
|
||||
setActionError('');
|
||||
setActionNotice('');
|
||||
setCheckoutLoadingSlug(planSlug || '__default__');
|
||||
try {
|
||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||
@@ -366,7 +461,7 @@ const BillingSection = (): JSX.Element => {
|
||||
...(planSlug ? { plan_slug: planSlug } : {}),
|
||||
});
|
||||
const response = await axiosInstance.post<{ checkout_url: string }>(
|
||||
'/finance/checkout/',
|
||||
'/monetization/checkout/',
|
||||
{
|
||||
success_url,
|
||||
cancel_url,
|
||||
@@ -395,6 +490,10 @@ const BillingSection = (): JSX.Element => {
|
||||
setShowPlanPicker(true);
|
||||
return;
|
||||
}
|
||||
if (native && (storeSub || showStoreManage)) {
|
||||
void handleNativePurchase(undefined, 'account_upgrade');
|
||||
return;
|
||||
}
|
||||
void openPortal('upgrade');
|
||||
};
|
||||
|
||||
@@ -405,10 +504,24 @@ const BillingSection = (): JSX.Element => {
|
||||
setShowPlanPicker(true);
|
||||
return;
|
||||
}
|
||||
if (native && (storeSub || showStoreManage)) {
|
||||
setActionNotice(
|
||||
`Change or cancel your plan in ${storeLabel} subscription settings.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
void openPortal('change');
|
||||
};
|
||||
|
||||
const handleConfirmCancel = async () => {
|
||||
if (native && (storeSub || showStoreManage) && !showStripeManage) {
|
||||
trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'store' });
|
||||
setCancelConfirmOpen(false);
|
||||
setActionNotice(
|
||||
`Open ${storeLabel} → Subscriptions to cancel. Access usually continues until the period end.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'portal' });
|
||||
setCancelConfirmOpen(false);
|
||||
await openPortal('cancel');
|
||||
@@ -422,7 +535,6 @@ const BillingSection = (): JSX.Element => {
|
||||
source: 'plan_picker',
|
||||
plan_slug: plan.slug,
|
||||
});
|
||||
// New higher tier via Checkout when selectable; portal otherwise.
|
||||
await handleStartCheckout(plan.slug, 'account_upgrade');
|
||||
return;
|
||||
}
|
||||
@@ -430,7 +542,12 @@ const BillingSection = (): JSX.Element => {
|
||||
source: 'plan_picker',
|
||||
plan_slug: plan.slug,
|
||||
});
|
||||
// Existing subscribers change plans in the Stripe portal (proration / PCI).
|
||||
if (native && (storeSub || showStoreManage) && !showStripeManage) {
|
||||
setShowPlanPicker(false);
|
||||
await handleNativePurchase(plan.slug, 'account_change');
|
||||
return;
|
||||
}
|
||||
// Existing Stripe subscribers change plans in the portal (proration / PCI).
|
||||
setShowPlanPicker(false);
|
||||
await openPortal('change');
|
||||
};
|
||||
@@ -525,7 +642,7 @@ const BillingSection = (): JSX.Element => {
|
||||
|
||||
{!loading && !listError && (
|
||||
<ButtonRow>
|
||||
{hasPortalAccess && !complimentary ? (
|
||||
{showStripeManage ? (
|
||||
<>
|
||||
<StyledButton
|
||||
type="button"
|
||||
@@ -564,14 +681,74 @@ const BillingSection = (): JSX.Element => {
|
||||
Complimentary access — no payment required. Plan changes and
|
||||
cancellation are not available for this account.
|
||||
</BodyText>
|
||||
) : showStoreManage ? (
|
||||
<>
|
||||
<StyledButton
|
||||
type="button"
|
||||
onClick={handleUpgradeClick}
|
||||
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
|
||||
>
|
||||
Upgrade
|
||||
</StyledButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleChangePlanClick}
|
||||
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
|
||||
>
|
||||
Change plan
|
||||
</SecondaryButton>
|
||||
{!subscription?.cancel_at_period_end &&
|
||||
subscription?.status !== 'canceled' ? (
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => setCancelConfirmOpen(true)}
|
||||
disabled={Boolean(checkoutLoadingSlug)}
|
||||
>
|
||||
Cancel
|
||||
</DangerButton>
|
||||
) : null}
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActionNotice(
|
||||
`Manage billing in ${storeLabel} → Subscriptions. Changes sync here after refresh.`
|
||||
)
|
||||
}
|
||||
data-testid="store-manage-hint"
|
||||
>
|
||||
Manage in {storeLabel}
|
||||
</SecondaryButton>
|
||||
{native ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleRestore}
|
||||
disabled={restoreLoading || Boolean(checkoutLoadingSlug)}
|
||||
data-testid="restore-purchases"
|
||||
>
|
||||
{restoreLoading ? 'Restoring…' : 'Restore purchases'}
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<StyledButton
|
||||
type="button"
|
||||
onClick={() => handleStartCheckout()}
|
||||
disabled={Boolean(checkoutLoadingSlug)}
|
||||
>
|
||||
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
|
||||
</StyledButton>
|
||||
<>
|
||||
<StyledButton
|
||||
type="button"
|
||||
onClick={() => handleStartCheckout()}
|
||||
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
|
||||
>
|
||||
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
|
||||
</StyledButton>
|
||||
{native ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleRestore}
|
||||
disabled={restoreLoading || Boolean(checkoutLoadingSlug)}
|
||||
data-testid="restore-purchases"
|
||||
>
|
||||
{restoreLoading ? 'Restoring…' : 'Restore purchases'}
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
|
||||
Refresh
|
||||
@@ -583,8 +760,12 @@ const BillingSection = (): JSX.Element => {
|
||||
<div data-testid="plan-picker">
|
||||
<BodyText style={{ marginTop: '1.25rem', marginBottom: 0 }}>
|
||||
{planPickerMode === 'upgrade'
|
||||
? 'Choose a higher plan. Checkout opens securely in Stripe.'
|
||||
: 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'}
|
||||
? native && !showStripeManage
|
||||
? 'Choose a higher plan. Purchase completes in the app store.'
|
||||
: 'Choose a higher plan. Checkout opens securely in Stripe.'
|
||||
: native && !showStripeManage
|
||||
? 'Select another plan to purchase via the app store.'
|
||||
: 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'}
|
||||
</BodyText>
|
||||
<PlanList>
|
||||
{pickerPlans.map((plan) => (
|
||||
@@ -624,6 +805,9 @@ const BillingSection = (): JSX.Element => {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionNotice ? (
|
||||
<NoticeText data-testid="billing-action-notice">{actionNotice}</NoticeText>
|
||||
) : null}
|
||||
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
|
||||
</GlassCard>
|
||||
|
||||
@@ -641,6 +825,7 @@ const BillingSection = (): JSX.Element => {
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Date</Th>
|
||||
<Th>Provider</Th>
|
||||
<Th>Description</Th>
|
||||
<Th>Amount</Th>
|
||||
<Th>Status</Th>
|
||||
@@ -651,6 +836,9 @@ const BillingSection = (): JSX.Element => {
|
||||
{historyRows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<Td>{formatBillingDate(row.date)}</Td>
|
||||
<Td>
|
||||
<ProviderBadge>{row.provider}</ProviderBadge>
|
||||
</Td>
|
||||
<Td>{row.description}</Td>
|
||||
<Td>{row.amount}</Td>
|
||||
<Td>{row.status}</Td>
|
||||
@@ -685,9 +873,9 @@ const BillingSection = (): JSX.Element => {
|
||||
>
|
||||
<ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle>
|
||||
<BodyText>
|
||||
You will finish canceling in the Stripe customer portal. Access typically
|
||||
continues until the end of the current billing period
|
||||
{periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.
|
||||
{native && (storeSub || showStoreManage) && !showStripeManage
|
||||
? `You will cancel in ${storeLabel} subscription settings. Access typically continues until the end of the current billing period${periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.`
|
||||
: `You will finish canceling in the Stripe customer portal. Access typically continues until the end of the current billing period${periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.`}
|
||||
</BodyText>
|
||||
<ButtonRow>
|
||||
<DangerButton
|
||||
@@ -695,7 +883,11 @@ const BillingSection = (): JSX.Element => {
|
||||
onClick={handleConfirmCancel}
|
||||
disabled={portalLoading}
|
||||
>
|
||||
{portalLoading ? 'Opening…' : 'Continue to cancel'}
|
||||
{native && (storeSub || showStoreManage) && !showStripeManage
|
||||
? 'Got it'
|
||||
: portalLoading
|
||||
? 'Opening…'
|
||||
: 'Continue to cancel'}
|
||||
</DangerButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('ConversationDetailCard citations (#98)', () => {
|
||||
expect(out).toContain('```\n[9]\n```');
|
||||
});
|
||||
|
||||
it('renders Sources list from citations prop', () => {
|
||||
it('does not render Sources list card', () => {
|
||||
renderCard({
|
||||
message: 'Answer with [1]',
|
||||
user_created: false,
|
||||
@@ -49,24 +49,13 @@ describe('ConversationDetailCard citations (#98)', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(screen.getByLabelText('Sources')).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: 'Example Source' });
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/a');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('renders nothing for Sources when citations empty', () => {
|
||||
renderCard({
|
||||
message: 'No sources here',
|
||||
user_created: false,
|
||||
citations: [],
|
||||
});
|
||||
expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Source 1' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights source when inline citation clicked', async () => {
|
||||
it('opens citation URL in a new tab when inline marker clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
renderCard({
|
||||
message: 'See [1]',
|
||||
user_created: false,
|
||||
@@ -75,6 +64,11 @@ describe('ConversationDetailCard citations (#98)', () => {
|
||||
],
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: 'Source 1' }));
|
||||
expect(document.getElementById('citation-source-1')).toBeInTheDocument();
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,4 +116,34 @@ describe('ConversationDetailCard', () => {
|
||||
expect(agentBubble).not.toBeNull();
|
||||
expect(getComputedStyle(agentBubble!).color).toBe('rgb(255, 255, 255)');
|
||||
});
|
||||
|
||||
it('renders activity label instead of dots when stage is set (#96)', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={darkTheme as never}>
|
||||
<ConversationDetailCard
|
||||
message=""
|
||||
user_created={false}
|
||||
activityStage="searching"
|
||||
activityLabel="Searching the web"
|
||||
activityDetail="Taylor Swift wedding"
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByText('Searching the web')).toBeInTheDocument();
|
||||
expect(screen.getByText('Taylor Swift wedding')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('activity-dots-fallback')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to dots when empty message has no activity stage (#96)', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={darkTheme as never}>
|
||||
<ConversationDetailCard message="" user_created={false} />
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId('activity-dots-fallback')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import Markdown from 'markdown-to-jsx';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
import { isRagFeatureNotAllowed, parseChatErrorPayload } from '../../utils/chatErrors';
|
||||
import type { Citation } from '../../utils/wsFrames';
|
||||
import type { ActivityHistoryEntry, Citation } from '../../utils/wsFrames';
|
||||
import type { PromptRating } from '../../utils/promptFeedback';
|
||||
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
|
||||
import SourcesList from '../SourcesList/SourcesList';
|
||||
import MessageActions from '../MessageActions/MessageActions';
|
||||
import ActivityIndicator from '../ActivityIndicator/ActivityIndicator';
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
@@ -95,30 +95,6 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingDot = styled.div`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
margin: 0 4px;
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: -0.32s; }
|
||||
&:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
`;
|
||||
|
||||
const UpgradeNotice = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -188,6 +164,12 @@ type ConversationDetailCardProps = {
|
||||
createdTimestamp?: Date | string | null;
|
||||
initialRating?: PromptRating | null;
|
||||
onRatingChange?: (rating: PromptRating | null) => void;
|
||||
/** Live activity status for the empty-message (streaming placeholder) bubble (#96). */
|
||||
activityStage?: string | null;
|
||||
activityLabel?: string | null;
|
||||
activityDetail?: string | null;
|
||||
activityHistory?: ActivityHistoryEntry[];
|
||||
activityInterrupted?: boolean;
|
||||
};
|
||||
|
||||
const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
||||
@@ -220,9 +202,12 @@ const ConversationDetailCard = ({
|
||||
createdTimestamp = null,
|
||||
initialRating = null,
|
||||
onRatingChange,
|
||||
activityStage = null,
|
||||
activityLabel = null,
|
||||
activityDetail = null,
|
||||
activityHistory = [],
|
||||
activityInterrupted = false,
|
||||
}: ConversationDetailCardProps): JSX.Element => {
|
||||
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
|
||||
|
||||
const CitationMark = useCallback(
|
||||
({ indices }: { indices?: string }) => {
|
||||
const list = (indices || '')
|
||||
@@ -241,12 +226,9 @@ const ConversationDetailCard = ({
|
||||
aria-label={`Source ${index}`}
|
||||
title={citation?.title || `Source ${index}`}
|
||||
onClick={() => {
|
||||
setHighlightIndex(index);
|
||||
if (citation?.url) {
|
||||
// Prefer scroll/highlight; URL still available from Sources list
|
||||
window.open(citation.url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
const el = document.getElementById(`citation-source-${index}`);
|
||||
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
|
||||
}}
|
||||
>
|
||||
[{index}]
|
||||
@@ -269,11 +251,13 @@ const ConversationDetailCard = ({
|
||||
return (
|
||||
<MessageContainer $isUser={false}>
|
||||
<Bubble $isUser={false}>
|
||||
<LoadingContainer>
|
||||
<LoadingDot />
|
||||
<LoadingDot />
|
||||
<LoadingDot />
|
||||
</LoadingContainer>
|
||||
<ActivityIndicator
|
||||
stage={activityStage}
|
||||
label={activityLabel}
|
||||
detail={activityDetail}
|
||||
history={activityHistory}
|
||||
interrupted={activityInterrupted}
|
||||
/>
|
||||
</Bubble>
|
||||
</MessageContainer>
|
||||
);
|
||||
@@ -356,10 +340,6 @@ const ConversationDetailCard = ({
|
||||
</Markdown>
|
||||
</Bubble>
|
||||
|
||||
{!user_created && citations.length > 0 && (
|
||||
<SourcesList citations={citations} highlightIndex={highlightIndex} />
|
||||
)}
|
||||
|
||||
<MessageActions
|
||||
promptId={promptId}
|
||||
rawMarkdown={rawForCopy}
|
||||
|
||||
@@ -44,6 +44,10 @@ jest.mock('../../utils/analytics', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/revenueCat', () => ({
|
||||
logOutRevenueCat: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
const theme = {
|
||||
main: '#4a90e2',
|
||||
darkMode: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
||||
import { AccountContext } from '../../contexts/AccountContext';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
||||
import { logOutRevenueCat } from '../../utils/revenueCat';
|
||||
|
||||
const GlassCard = styled.div`
|
||||
background: ${({ theme }) => theme.colors.cardBackground};
|
||||
@@ -180,6 +181,7 @@ const DeleteAccountSection = (): JSX.Element => {
|
||||
applyAccessToken(null);
|
||||
setAuthentication(false);
|
||||
setAccount(undefined);
|
||||
void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err));
|
||||
navigate('/signin/');
|
||||
} catch (err: unknown) {
|
||||
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED);
|
||||
|
||||
@@ -19,6 +19,10 @@ jest.mock('../../../axiosApi', () => ({
|
||||
applyAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/revenueCat', () => ({
|
||||
logOutRevenueCat: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
const theme = {
|
||||
main: '#4a90e2',
|
||||
focus: '#224466',
|
||||
@@ -82,7 +86,7 @@ describe('Header2 (#81 subscription-aware Documents nav link)', () => {
|
||||
|
||||
renderHeader();
|
||||
|
||||
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/finance/subscription/'));
|
||||
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/monetization/subscription/'));
|
||||
expect(screen.queryByText('Documents')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AccountContext } from '../../contexts/AccountContext';
|
||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
||||
import { useSubscription } from '../../hooks/useSubscription';
|
||||
import { logOutRevenueCat } from '../../utils/revenueCat';
|
||||
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
|
||||
|
||||
const HeaderContainer = styled.header`
|
||||
@@ -230,6 +231,7 @@ const Header2 = ({
|
||||
applyAccessToken(null);
|
||||
setAuthentication(false)
|
||||
setAccount(undefined);
|
||||
void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err));
|
||||
navigate('/signin/')
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
@@ -309,7 +309,6 @@ const MessageActions = ({
|
||||
{(
|
||||
[
|
||||
['pdf', 'PDF'],
|
||||
['docx', 'Word (.docx)'],
|
||||
['csv', 'CSV'],
|
||||
['xlsx', 'Excel (.xlsx)'],
|
||||
['txt', 'Plain text'],
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { Citation } from '../../utils/wsFrames';
|
||||
|
||||
const SourcesRoot = styled.aside`
|
||||
margin-top: 0.65rem;
|
||||
max-width: 80%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||
background: ${({ theme }) =>
|
||||
theme.darkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)'};
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
|
||||
@media (max-width: 768px) {
|
||||
max-width: 92%;
|
||||
}
|
||||
`;
|
||||
|
||||
const SourcesTitle = styled.h4`
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.75;
|
||||
`;
|
||||
|
||||
const SourceList = styled.ol`
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
`;
|
||||
|
||||
const SourceItem = styled.li<{ $highlight: boolean }>`
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
outline: ${({ $highlight, theme }) =>
|
||||
$highlight ? `2px solid ${theme.main}` : 'none'};
|
||||
background: ${({ $highlight, theme }) =>
|
||||
$highlight
|
||||
? theme.darkMode
|
||||
? 'rgba(255,255,255,0.08)'
|
||||
: 'rgba(0,0,0,0.06)'
|
||||
: 'transparent'};
|
||||
transition: background 0.2s ease, outline 0.2s ease;
|
||||
`;
|
||||
|
||||
const SourceLink = styled.a`
|
||||
color: ${({ theme }) => (theme.darkMode ? '#a0c4ff' : theme.main)};
|
||||
text-decoration: underline;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const SourceMeta = styled.span`
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.65;
|
||||
margin-top: 0.1rem;
|
||||
`;
|
||||
|
||||
function domainFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
type SourcesListProps = {
|
||||
citations: Citation[];
|
||||
highlightIndex?: number | null;
|
||||
};
|
||||
|
||||
const SourcesList = ({
|
||||
citations,
|
||||
highlightIndex = null,
|
||||
}: SourcesListProps): JSX.Element | null => {
|
||||
const itemRefs = useRef<Record<number, HTMLLIElement | null>>({});
|
||||
const [active, setActive] = useState<number | null>(highlightIndex);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(highlightIndex ?? null);
|
||||
if (highlightIndex == null) return;
|
||||
const el = itemRefs.current[highlightIndex];
|
||||
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
|
||||
}, [highlightIndex]);
|
||||
|
||||
if (!citations.length) return null;
|
||||
|
||||
const sorted = [...citations].sort((a, b) => a.index - b.index);
|
||||
|
||||
return (
|
||||
<SourcesRoot aria-label="Sources">
|
||||
<SourcesTitle>Sources</SourcesTitle>
|
||||
<SourceList>
|
||||
{sorted.map((citation) => {
|
||||
const domain = domainFromUrl(citation.url);
|
||||
return (
|
||||
<SourceItem
|
||||
key={citation.index}
|
||||
id={`citation-source-${citation.index}`}
|
||||
$highlight={active === citation.index}
|
||||
ref={(node) => {
|
||||
itemRefs.current[citation.index] = node;
|
||||
}}
|
||||
>
|
||||
{citation.url ? (
|
||||
<SourceLink
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{citation.title || `Source ${citation.index}`}
|
||||
</SourceLink>
|
||||
) : (
|
||||
<span>{citation.title || `Source ${citation.index}`}</span>
|
||||
)}
|
||||
{(citation.published_at || domain) && (
|
||||
<SourceMeta>
|
||||
{[citation.published_at, domain].filter(Boolean).join(' · ')}
|
||||
</SourceMeta>
|
||||
)}
|
||||
</SourceItem>
|
||||
);
|
||||
})}
|
||||
</SourceList>
|
||||
</SourcesRoot>
|
||||
);
|
||||
};
|
||||
|
||||
export default SourcesList;
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import InfoOutlined from '@mui/icons-material/InfoOutlined';
|
||||
import {
|
||||
useMaterialUIController,
|
||||
setDarkMode,
|
||||
@@ -48,6 +50,16 @@ const SettingLabel = styled.span`
|
||||
font-size: 1.1rem;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
`;
|
||||
|
||||
const InfoIcon = styled(InfoOutlined)`
|
||||
font-size: 1rem !important;
|
||||
opacity: 0.7;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
`;
|
||||
|
||||
const ToggleSwitch = styled.label`
|
||||
@@ -93,6 +105,11 @@ const Checkbox = styled.input`
|
||||
&:checked + ${Slider}:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
&:disabled + ${Slider} {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const ColorPickerContainer = styled.div`
|
||||
@@ -115,6 +132,23 @@ const ColorButton = styled.button<{ color: string; isSelected: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorText = styled.p`
|
||||
color: #ff6b6b;
|
||||
margin: 0.75rem 0 0 0;
|
||||
font-size: 0.95rem;
|
||||
`;
|
||||
|
||||
const CONTEXT_TOOLTIP =
|
||||
'This will use previous conversations to better customize the experience.';
|
||||
|
||||
function prefsErrorMessage(error: unknown, fallback: string): string {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
||||
}
|
||||
|
||||
type PreferencesValues = {
|
||||
order: boolean;
|
||||
}
|
||||
@@ -128,6 +162,10 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
const themeColors = Object.keys(palettes);
|
||||
|
||||
const [order, setOrder] = useState<boolean>(true);
|
||||
const [useConversationContext, setUseConversationContext] = useState<boolean>(false);
|
||||
const [prefsLoading, setPrefsLoading] = useState<boolean>(true);
|
||||
const [contextSaving, setContextSaving] = useState<boolean>(false);
|
||||
const [prefsError, setPrefsError] = useState<string>('');
|
||||
const { updatePreferences } = useContext(PreferenceContext);
|
||||
|
||||
const handleConversationOrder = async ({ order }: PreferencesValues): Promise<void> => {
|
||||
@@ -141,17 +179,50 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
}
|
||||
}
|
||||
|
||||
async function getConversationOrder() {
|
||||
async function getConversationPreferences() {
|
||||
setPrefsLoading(true);
|
||||
setPrefsError('');
|
||||
try {
|
||||
const { data, }: AxiosResponse<PreferencesType> = await axiosInstance.get(`/conversation_preferences`);
|
||||
const { data }: AxiosResponse<PreferencesType> = await axiosInstance.get(
|
||||
`/conversation_preferences`
|
||||
);
|
||||
setOrder(data.order);
|
||||
setUseConversationContext(Boolean(data.use_conversation_context));
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
setPrefsError(
|
||||
prefsErrorMessage(error, 'Could not load account preferences.')
|
||||
);
|
||||
} finally {
|
||||
setPrefsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleConversationContext = async (enabled: boolean): Promise<void> => {
|
||||
const previous = useConversationContext;
|
||||
setUseConversationContext(enabled);
|
||||
setContextSaving(true);
|
||||
setPrefsError('');
|
||||
try {
|
||||
const { data }: AxiosResponse<PreferencesType> = await axiosInstance.post(
|
||||
'/conversation_preferences',
|
||||
{ use_conversation_context: enabled }
|
||||
);
|
||||
setUseConversationContext(Boolean(data.use_conversation_context));
|
||||
} catch (error) {
|
||||
setUseConversationContext(previous);
|
||||
setPrefsError(
|
||||
prefsErrorMessage(
|
||||
error,
|
||||
'Could not update conversation context setting. Try again.'
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setContextSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getConversationOrder();
|
||||
getConversationPreferences();
|
||||
}, [])
|
||||
|
||||
const handleDarkMode = () => setDarkMode(dispatch, !darkMode);
|
||||
@@ -193,6 +264,7 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
<Checkbox
|
||||
type="checkbox"
|
||||
checked={order}
|
||||
disabled={prefsLoading}
|
||||
onChange={() => {
|
||||
setOrder(!order);
|
||||
handleConversationOrder({ order: !order })
|
||||
@@ -202,6 +274,30 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
</ToggleSwitch>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow>
|
||||
<SettingLabel>
|
||||
Use previous conversations
|
||||
<Tooltip title={CONTEXT_TOOLTIP}>
|
||||
<span>
|
||||
<InfoIcon aria-label={CONTEXT_TOOLTIP} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</SettingLabel>
|
||||
<ToggleSwitch>
|
||||
<Checkbox
|
||||
type="checkbox"
|
||||
checked={useConversationContext}
|
||||
disabled={prefsLoading || contextSaving}
|
||||
onChange={() => {
|
||||
handleConversationContext(!useConversationContext);
|
||||
}}
|
||||
/>
|
||||
<Slider />
|
||||
</ToggleSwitch>
|
||||
</SettingRow>
|
||||
|
||||
{prefsError ? <ErrorText role="alert">{prefsError}</ErrorText> : null}
|
||||
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -174,8 +174,8 @@ const UsageSummaryCard = (): JSX.Element => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [subscriptionResponse, invoiceResponse] = await Promise.all([
|
||||
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
|
||||
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
|
||||
axiosInstance.get<SubscriptionMe>('/monetization/subscription/'),
|
||||
axiosInstance.get<FinanceInvoice[]>('/monetization/invoices/'),
|
||||
]);
|
||||
setSubscription(subscriptionResponse.data || null);
|
||||
setHasPortalAccess(
|
||||
@@ -231,7 +231,7 @@ const UsageSummaryCard = (): JSX.Element => {
|
||||
try {
|
||||
const returnUrl = `${window.location.origin}/account/`;
|
||||
const response = await axiosInstance.post<{ portal_url: string }>(
|
||||
'/finance/portal/',
|
||||
'/monetization/portal/',
|
||||
{ return_url: returnUrl }
|
||||
);
|
||||
if (response.data?.portal_url) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Account, AccountType } from "../data";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { axiosInstance } from "../../axiosApi";
|
||||
import { logInRevenueCat } from "../utils/revenueCat";
|
||||
|
||||
type AccountProviderProps ={
|
||||
children? : ReactNode;
|
||||
@@ -28,6 +29,7 @@ const AccountProvider = ({children}: AccountProviderProps) => {
|
||||
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/')
|
||||
|
||||
const account: Account = new Account({
|
||||
id: get_user_response.data.id,
|
||||
email: get_user_response.data.email,
|
||||
first_name: get_user_response.data.first_name,
|
||||
last_name: get_user_response.data.last_name,
|
||||
@@ -43,6 +45,9 @@ const AccountProvider = ({children}: AccountProviderProps) => {
|
||||
|
||||
});
|
||||
setAccount(account);
|
||||
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
|
||||
console.warn('RevenueCat logIn', err)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { MessageContext, MessageProvider } from './MessageContext';
|
||||
import { WebSocketContext } from './WebSocketContext';
|
||||
import { AccountContext } from './AccountContext';
|
||||
import { ConversationContext } from './ConversationContext';
|
||||
import { trackEvent } from '../utils/analytics';
|
||||
|
||||
jest.mock('../../axiosApi', () => ({
|
||||
axiosInstance: {
|
||||
get: jest.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../utils/analytics', () => ({
|
||||
AnalyticsEvents: {
|
||||
CONVERSATION_CREATED: 'Conversation Created',
|
||||
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
|
||||
},
|
||||
trackEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
type Capture = { callback: ((message: string) => void) | null };
|
||||
|
||||
const buildHarness = () => {
|
||||
const capture: Capture = { callback: null };
|
||||
const subscribe = (_channel: string, cb: (message: string) => void) => {
|
||||
capture.callback = cb;
|
||||
};
|
||||
const unsubscribe = jest.fn();
|
||||
|
||||
const Harness = ({ reconnectGeneration = 0 }: { reconnectGeneration?: number }) => (
|
||||
<AccountContext.Provider
|
||||
value={{ account: { email: 'test@example.com' } as never, setAccount: () => {} }}
|
||||
>
|
||||
<ConversationContext.Provider
|
||||
value={{
|
||||
conversations: [],
|
||||
setConversations: () => {},
|
||||
selectedConversation: 1,
|
||||
setSelectedConversation: () => {},
|
||||
deleteConversation: () => {},
|
||||
}}
|
||||
>
|
||||
<WebSocketContext.Provider
|
||||
value={[subscribe, unsubscribe, null, jest.fn(), true, 'CONNECTED', reconnectGeneration] as never}
|
||||
>
|
||||
<MessageProvider>
|
||||
<Probe />
|
||||
</MessageProvider>
|
||||
</WebSocketContext.Provider>
|
||||
</ConversationContext.Provider>
|
||||
</AccountContext.Provider>
|
||||
);
|
||||
|
||||
return { Harness, capture };
|
||||
};
|
||||
|
||||
const Probe = () => {
|
||||
const ctx = useContext(MessageContext);
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="stage">{ctx.activityStage ?? 'null'}</span>
|
||||
<span data-testid="label">{ctx.activityLabel ?? 'null'}</span>
|
||||
<span data-testid="detail">{ctx.activityDetail ?? 'null'}</span>
|
||||
<span data-testid="history-count">{ctx.activityHistory.length}</span>
|
||||
<span data-testid="stream-interrupted">{ctx.streamInterrupted ? 'yes' : 'no'}</span>
|
||||
<button type="button" onClick={() => ctx.clearActivity()}>
|
||||
clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const send = (capture: Capture, message: string) => {
|
||||
act(() => {
|
||||
capture.callback?.(message);
|
||||
});
|
||||
};
|
||||
|
||||
const sendStatus = (
|
||||
capture: Capture,
|
||||
stage: string,
|
||||
label: string,
|
||||
detail: string | null = null,
|
||||
) => {
|
||||
send(
|
||||
capture,
|
||||
JSON.stringify({ v: 1, type: 'status', data: { stage, label, detail } }),
|
||||
);
|
||||
};
|
||||
|
||||
describe('MessageContext activity status (#96)', () => {
|
||||
beforeEach(() => {
|
||||
(trackEvent as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
it('applies the first status frame as the current stage/label/detail', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
sendStatus(capture, 'searching', 'Searching the web', 'query: cats');
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('Searching the web');
|
||||
expect(screen.getByTestId('detail')).toHaveTextContent('query: cats');
|
||||
expect(screen.getByTestId('history-count')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('rolls a completed stage into history and tracks ACTIVITY_STAGE_COMPLETED (no label/detail text)', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
sendStatus(capture, 'searching', 'Searching the web');
|
||||
sendStatus(capture, 'reading', 'Reading sources');
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('reading');
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('Reading sources');
|
||||
expect(screen.getByTestId('history-count')).toHaveTextContent('1');
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith(
|
||||
'Activity Stage Completed',
|
||||
expect.objectContaining({ stage: 'searching', durationMs: expect.any(Number) }),
|
||||
);
|
||||
const call = (trackEvent as jest.Mock).mock.calls.find(
|
||||
([name]) => name === 'Activity Stage Completed',
|
||||
);
|
||||
expect(Object.keys(call![1])).toEqual(['stage', 'durationMs']);
|
||||
});
|
||||
|
||||
it('clears the label display (but not the stage) once the writing stage starts', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
sendStatus(capture, 'searching', 'Searching the web');
|
||||
sendStatus(capture, 'writing', 'Writing the answer');
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('writing');
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('null');
|
||||
});
|
||||
|
||||
it('clears activity on END_OF_THE_STREAM', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
sendStatus(capture, 'searching', 'Searching the web');
|
||||
sendStatus(capture, 'writing', 'Writing the answer');
|
||||
send(capture, 'END_OF_THE_STREAM_ENDER_GAME_42');
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('null');
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('null');
|
||||
expect(screen.getByTestId('detail')).toHaveTextContent('null');
|
||||
expect(screen.getByTestId('history-count')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('ignores malformed status frames without throwing', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
send(
|
||||
capture,
|
||||
JSON.stringify({ v: 1, type: 'status', data: { stage: 'searching' } }),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('null');
|
||||
});
|
||||
|
||||
it('clears activity via clearActivity()', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
render(<Harness />);
|
||||
await act(async () => {});
|
||||
|
||||
sendStatus(capture, 'searching', 'Searching the web');
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'clear' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('null');
|
||||
});
|
||||
|
||||
it('clears activity on the streamInterrupted reconnect path', async () => {
|
||||
const { Harness, capture } = buildHarness();
|
||||
const { rerender } = render(<Harness reconnectGeneration={0} />);
|
||||
await act(async () => {});
|
||||
|
||||
// Simulate an in-flight generating turn so the reconnect effect treats it as interrupted.
|
||||
send(capture, 'CONVERSATION_ID');
|
||||
sendStatus(capture, 'searching', 'Searching the web');
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('searching');
|
||||
|
||||
rerender(<Harness reconnectGeneration={1} />);
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId('stream-interrupted')).toHaveTextContent('yes');
|
||||
expect(screen.getByTestId('stage')).toHaveTextContent('null');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, ReactNode, useContext, useEffect, useRef, useState } from "react";
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { WebSocketContext } from "./WebSocketContext";
|
||||
import { AccountContext } from "./AccountContext";
|
||||
import { ConversationContext } from "./ConversationContext";
|
||||
@@ -6,7 +6,12 @@ import { ConversationPrompt, ConversationPromptType } from "../data";
|
||||
import { axiosInstance } from "../../axiosApi";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { AnalyticsEvents, trackEvent } from "../utils/analytics";
|
||||
import { parseCitationsData, parseVersionedFrame } from "../utils/wsFrames";
|
||||
import {
|
||||
ActivityHistoryEntry,
|
||||
parseCitationsData,
|
||||
parseStatusData,
|
||||
parseVersionedFrame,
|
||||
} from "../utils/wsFrames";
|
||||
|
||||
type MessageProviderProps ={
|
||||
children? : ReactNode;
|
||||
@@ -21,6 +26,12 @@ type IMessageContext = {
|
||||
/** True when a stream was interrupted by a socket drop; cleared on refetch/retry. */
|
||||
streamInterrupted: boolean;
|
||||
clearStreamInterrupted: () => void;
|
||||
/** Live activity status from versioned "status" WS frames (#96). */
|
||||
activityStage: string | null;
|
||||
activityLabel: string | null;
|
||||
activityDetail: string | null;
|
||||
activityHistory: ActivityHistoryEntry[];
|
||||
clearActivity: () => void;
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
@@ -31,6 +42,11 @@ const initialValues = {
|
||||
isGeneratingMessage: false,
|
||||
streamInterrupted: false,
|
||||
clearStreamInterrupted: () => {},
|
||||
activityStage: null,
|
||||
activityLabel: null,
|
||||
activityDetail: null,
|
||||
activityHistory: [],
|
||||
clearActivity: () => {},
|
||||
}
|
||||
|
||||
const MessageContext = createContext<IMessageContext>(initialValues);
|
||||
@@ -47,6 +63,10 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
|
||||
const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false)
|
||||
const [activityStage, setActivityStage] = useState<string | null>(null)
|
||||
const [activityLabel, setActivityLabel] = useState<string | null>(null)
|
||||
const [activityDetail, setActivityDetail] = useState<string | null>(null)
|
||||
const [activityHistory, setActivityHistory] = useState<ActivityHistoryEntry[]>([])
|
||||
|
||||
const messageRef = useRef('')
|
||||
const messageResponsePart = useRef(0);
|
||||
@@ -55,9 +75,19 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
const isGeneratingRef = useRef(false)
|
||||
const prevReconnectGenerationRef = useRef(0)
|
||||
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** Tracks the in-progress activity stage so we can roll it into history with a duration. */
|
||||
const activeStageRef = useRef<{ stage: string; label: string; startedAt: number } | null>(null)
|
||||
|
||||
const clearStreamInterrupted = () => setStreamInterrupted(false)
|
||||
|
||||
const clearActivity = () => {
|
||||
activeStageRef.current = null
|
||||
setActivityStage(null)
|
||||
setActivityLabel(null)
|
||||
setActivityDetail(null)
|
||||
setActivityHistory([])
|
||||
}
|
||||
|
||||
async function GetConversationDetails(conversationId: number | undefined) {
|
||||
if (!conversationId) {
|
||||
setConversationDetails([])
|
||||
@@ -98,7 +128,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePostStreamRefetch = () => {
|
||||
const schedulePostStreamRefetch = useCallback(() => {
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
|
||||
const conversationId = selectedConversationRef.current
|
||||
if (!conversationId) return
|
||||
@@ -107,7 +137,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
refetchTimerRef.current = setTimeout(() => {
|
||||
void GetConversationDetails(conversationId)
|
||||
}, 800)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
GetConversationDetails(selectedConversation)
|
||||
@@ -142,6 +172,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
setIsGeneratingMessage(false)
|
||||
setStateMessage('')
|
||||
setStreamInterrupted(true)
|
||||
clearActivity()
|
||||
|
||||
const details = [...conversationRef.current]
|
||||
if (details.length > 0) {
|
||||
@@ -187,8 +218,41 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
conversationRef.current = details
|
||||
setConversationDetails(details)
|
||||
schedulePostStreamRefetch()
|
||||
} else if (frame.type === 'status') {
|
||||
const statusData = parseStatusData(frame.data)
|
||||
if (statusData) {
|
||||
const now = Date.now()
|
||||
const previous = activeStageRef.current
|
||||
if (previous && previous.stage !== statusData.stage) {
|
||||
const durationMs = now - previous.startedAt
|
||||
setActivityHistory((history) => [
|
||||
...history,
|
||||
{
|
||||
stage: previous.stage,
|
||||
label: previous.label,
|
||||
startedAt: previous.startedAt,
|
||||
finishedAt: now,
|
||||
},
|
||||
])
|
||||
trackEvent(AnalyticsEvents.ACTIVITY_STAGE_COMPLETED, {
|
||||
stage: previous.stage,
|
||||
durationMs,
|
||||
})
|
||||
}
|
||||
activeStageRef.current = {
|
||||
stage: statusData.stage,
|
||||
label: statusData.label,
|
||||
startedAt: previous && previous.stage === statusData.stage
|
||||
? previous.startedAt
|
||||
: now,
|
||||
}
|
||||
setActivityStage(statusData.stage)
|
||||
// Tokens take over the label once the model starts writing.
|
||||
setActivityLabel(statusData.stage === 'writing' ? null : statusData.label)
|
||||
setActivityDetail(statusData.detail ?? null)
|
||||
}
|
||||
}
|
||||
// status / unknown types: no-op (compatible with #96)
|
||||
// other unknown types: no-op
|
||||
return
|
||||
}
|
||||
|
||||
@@ -208,6 +272,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
isGeneratingRef.current = false
|
||||
setIsGeneratingMessage(false)
|
||||
setStreamInterrupted(false)
|
||||
clearActivity()
|
||||
schedulePostStreamRefetch()
|
||||
}
|
||||
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
|
||||
@@ -251,7 +316,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
/* unsubscribe from channel during cleanup */
|
||||
unsubscribe(channelName)
|
||||
}
|
||||
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation])
|
||||
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation, schedulePostStreamRefetch])
|
||||
|
||||
|
||||
return(
|
||||
@@ -263,6 +328,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
isGeneratingMessage,
|
||||
streamInterrupted,
|
||||
clearStreamInterrupted,
|
||||
activityStage,
|
||||
activityLabel,
|
||||
activityDetail,
|
||||
activityHistory,
|
||||
clearActivity,
|
||||
}}>
|
||||
{children}
|
||||
</MessageContext.Provider>
|
||||
|
||||
@@ -255,6 +255,7 @@ export class AdminAnalytics {
|
||||
}
|
||||
|
||||
export interface AccountType {
|
||||
id?: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
@@ -268,9 +269,11 @@ export interface AccountType {
|
||||
|
||||
export interface PreferencesType {
|
||||
order: boolean;
|
||||
use_conversation_context?: boolean;
|
||||
}
|
||||
|
||||
export class Account {
|
||||
id?: number;
|
||||
email: string = '';
|
||||
first_name: string ='';
|
||||
last_name: string = '';
|
||||
@@ -282,6 +285,7 @@ export class Account {
|
||||
has_signed_tos: boolean = false;
|
||||
constructor(initializer?: any){
|
||||
if (!initializer) return;
|
||||
if (initializer.id != null) this.id = Number(initializer.id);
|
||||
if (initializer.email) this.email = initializer.email;
|
||||
if (initializer.first_name) this.first_name = initializer.first_name;
|
||||
if (initializer.is_company_manager) this.is_company_manager = initializer.is_company_manager;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { planAllowsRag, SubscriptionMe } from '../utils/finance';
|
||||
|
||||
/**
|
||||
* Module-level cache so every component that calls useSubscription() during the
|
||||
* same session shares one /finance/subscription/ request instead of each
|
||||
* same session shares one /monetization/subscription/ request instead of each
|
||||
* mounting its own (e.g. Header2 + DocumentStoragePage on the same page).
|
||||
*/
|
||||
let cachedSubscription: SubscriptionMe | null = null;
|
||||
@@ -18,7 +18,7 @@ async function loadSubscription(): Promise<SubscriptionMe | null> {
|
||||
}
|
||||
if (!inFlightRequest) {
|
||||
inFlightRequest = axiosInstance
|
||||
.get<SubscriptionMe>('/finance/subscription/')
|
||||
.get<SubscriptionMe>('/monetization/subscription/')
|
||||
.then((response: AxiosResponse<SubscriptionMe>) => {
|
||||
cachedSubscription = response.data || null;
|
||||
return cachedSubscription;
|
||||
@@ -44,7 +44,7 @@ export type UseSubscriptionResult = {
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Fetches /finance/subscription/ once per session (shared across callers) and exposes plan-derived flags. */
|
||||
/** Fetches /monetization/subscription/ once per session (shared across callers) and exposes plan-derived flags. */
|
||||
export function useSubscription(): UseSubscriptionResult {
|
||||
const { authenticated } = useContext(AuthContext);
|
||||
const [subscription, setSubscription] = useState<SubscriptionMe | null>(cachedSubscription);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import AccountDeletionPage from './AccountDeletionPage';
|
||||
|
||||
describe('AccountDeletionPage', () => {
|
||||
it('renders Hesychia deletion steps and retention copy without auth', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AccountDeletionPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: /Hesychia — Request account deletion/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/ai\.hesychia\.chat/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('heading', { name: /How to delete your account/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Delete my account/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('heading', { name: /Deactivated immediately/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('heading', { name: /Data retained after soft-delete/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /Retention period/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/no automatic hard-purge schedule/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('heading', { name: /Cannot access your account/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/30 days/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Card, CardContent, Divider, Stack, Typography } from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Public Play / App Store account-deletion instructions (#103).
|
||||
* No auth required — reviewers and signed-out users must be able to read this page.
|
||||
*/
|
||||
const AccountDeletionPage = (): JSX.Element => (
|
||||
<Box sx={{ py: 4, px: 2, minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
<Card sx={{ maxWidth: 900, mx: 'auto', width: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h3" component="h1" gutterBottom>
|
||||
Hesychia — Request account deletion
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
|
||||
Hesychia (ai.hesychia.chat) · hesychia.ai · AI ML Operations, LLC
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Use the steps below to request deletion of your Hesychia account. This page does not
|
||||
require sign-in to read.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
<Divider />
|
||||
|
||||
<CardContent>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
How to delete your account
|
||||
</Typography>
|
||||
<Typography variant="body1" component="ol" sx={{ pl: 2.5, m: 0 }}>
|
||||
<li>
|
||||
Open <strong>Hesychia</strong> in the mobile app or at{' '}
|
||||
<a href="https://hesychia.ai" rel="noopener noreferrer">
|
||||
hesychia.ai
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Sign in with the account you want deleted.</li>
|
||||
<li>
|
||||
Go to <strong>Account</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Choose <strong>Delete my account</strong>, then confirm by typing your account
|
||||
email.
|
||||
</li>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
What happens when you delete
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" component="h3" gutterBottom sx={{ mt: 2 }}>
|
||||
Deactivated immediately
|
||||
</Typography>
|
||||
<Typography variant="body1" component="ul" sx={{ pl: 2.5, m: 0 }}>
|
||||
<li>Your account is marked deleted and set inactive.</li>
|
||||
<li>You lose access to Hesychia and cannot sign in again with that account.</li>
|
||||
<li>Your conversations are hidden from the product.</li>
|
||||
<li>Active sessions are invalidated (refresh tokens are blacklisted).</li>
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" component="h3" gutterBottom sx={{ mt: 2 }}>
|
||||
Data retained after soft-delete
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Hesychia currently uses a <strong>soft-delete</strong> process. After you delete your
|
||||
account, personal data associated with the account (including profile details,
|
||||
conversation content, uploaded documents / RAG vectors, and authentication event
|
||||
records) may remain in our systems for administration, security, and audit purposes.
|
||||
This is not a full erasure of all stored data.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" component="h3" gutterBottom>
|
||||
Retention period
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph sx={{ mb: 0 }}>
|
||||
There is currently <strong>no automatic hard-purge schedule</strong>. Soft-deleted
|
||||
data is retained until a future purge process is implemented or until we process a
|
||||
separate retention / erasure request through the contact path below. We will not claim
|
||||
complete erasure while soft-delete-only remains in effect.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Cannot access your account?
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
If you cannot sign in to complete in-app deletion, contact AI ML Operations, LLC via{' '}
|
||||
<a href="https://www.aimloperations.com" rel="noopener noreferrer">
|
||||
aimloperations.com
|
||||
</a>{' '}
|
||||
and include the email address used for your Hesychia account. We aim to respond to
|
||||
account-deletion requests within <strong>30 days</strong>.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Staff accounts cannot self-delete in the app; use the contact path above.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
|
||||
<Divider />
|
||||
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Already have an account?{' '}
|
||||
<Button component={RouterLink} to="/signin/" size="small">
|
||||
Sign in
|
||||
</Button>{' '}
|
||||
·{' '}
|
||||
<Button component={RouterLink} to="/account/" size="small">
|
||||
Account
|
||||
</Button>{' '}
|
||||
·{' '}
|
||||
<Button component={RouterLink} to="/terms_of_service/" size="small">
|
||||
Terms of Service
|
||||
</Button>
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default AccountDeletionPage;
|
||||
@@ -252,32 +252,6 @@ const IconButton = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSelect = styled.select`
|
||||
background: transparent;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
max-width: 7rem;
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
margin-right: 0.5rem;
|
||||
border-right: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||
|
||||
@media (max-width: 768px) {
|
||||
max-width: 5.5rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.2rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
option {
|
||||
background: ${({ theme }) => theme.colors.background || '#1a1a1a'};
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const ConversationItem = styled.div<{ $active: boolean }>`
|
||||
padding: 0.8rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
@@ -343,11 +317,13 @@ const validationSchema = Yup.object().shape({
|
||||
.required("This is required"),
|
||||
});
|
||||
|
||||
/** Fixed until product re-exposes a model picker (#106). */
|
||||
const DEFAULT_MODEL_NAME = "THINKING";
|
||||
|
||||
type PromptValues = {
|
||||
prompt: string;
|
||||
file: Blob | null;
|
||||
fileType: string | null;
|
||||
modelName: string;
|
||||
};
|
||||
|
||||
const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.Element => {
|
||||
@@ -370,6 +346,11 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
setConversationDetails,
|
||||
stateMessage,
|
||||
streamInterrupted,
|
||||
activityStage,
|
||||
activityLabel,
|
||||
activityDetail,
|
||||
activityHistory,
|
||||
clearActivity,
|
||||
} = useContext(MessageContext);
|
||||
|
||||
const conversationRef = useRef(conversationDetails);
|
||||
@@ -442,7 +423,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
})();
|
||||
|
||||
const handlePromptSubmit = async (
|
||||
{ prompt, file, fileType, modelName }: PromptValues,
|
||||
{ prompt, file, fileType }: PromptValues,
|
||||
{ resetForm }: any,
|
||||
): Promise<void> => {
|
||||
const trimmedPrompt = (prompt ?? "").trim();
|
||||
@@ -450,6 +431,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
clearActivity();
|
||||
const tempConversations: ConversationPrompt[] = [
|
||||
...conversationDetails,
|
||||
new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
|
||||
@@ -458,7 +440,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
|
||||
conversationRef.current = tempConversations;
|
||||
setConversationDetails(tempConversations);
|
||||
sendMessage(trimmedPrompt, selectedConversation, file, fileType, modelName);
|
||||
sendMessage(trimmedPrompt, selectedConversation, file, fileType, DEFAULT_MODEL_NAME);
|
||||
trackEvent(AnalyticsEvents.MESSAGE_SENT, {
|
||||
hasConversation: Boolean(selectedConversation),
|
||||
hasAttachment: Boolean(file),
|
||||
@@ -468,7 +450,6 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
prompt: "",
|
||||
file: null,
|
||||
fileType: null,
|
||||
modelName: modelName, // Keep the selected model
|
||||
}
|
||||
});
|
||||
|
||||
@@ -569,7 +550,6 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
{(
|
||||
[
|
||||
['pdf', 'PDF'],
|
||||
['docx', 'Word (.docx)'],
|
||||
['csv', 'CSV'],
|
||||
['xlsx', 'Excel (.xlsx)'],
|
||||
['txt', 'Plain text'],
|
||||
@@ -606,6 +586,11 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
onRatingChange={(rating) =>
|
||||
updatePromptRating(convo_detail.id, rating)
|
||||
}
|
||||
activityStage={isLiveStream ? activityStage : null}
|
||||
activityLabel={isLiveStream ? activityLabel : null}
|
||||
activityDetail={isLiveStream ? activityDetail : null}
|
||||
activityHistory={isLiveStream ? activityHistory : []}
|
||||
activityInterrupted={isLiveStream && streamInterrupted}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -630,7 +615,6 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
prompt: "",
|
||||
file: null,
|
||||
fileType: null,
|
||||
modelName: "FAST",
|
||||
}}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={handlePromptSubmit}
|
||||
@@ -681,14 +665,6 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Field
|
||||
as={StyledSelect}
|
||||
name="modelName"
|
||||
>
|
||||
<option value="FAST">FAST</option>
|
||||
<option value="THINKING">THINKING</option>
|
||||
</Field>
|
||||
|
||||
<Field name="prompt">
|
||||
{({ field }: any) => (
|
||||
<StyledInput
|
||||
|
||||