Compare commits
527 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99897e1c9e | |||
| 3bb5f1d32b | |||
| 2fc7a9ac41 | |||
| e6ea96d69f | |||
| 740fc1c63c | |||
| 4138e12d5e | |||
| 2ede59d2db | |||
| 4cceaf652d | |||
| 474ec6cc99 | |||
| 6453aa71fc | |||
| 4d405996f9 | |||
| c798e2a53e | |||
| 0022b86299 | |||
| abc37151ad | |||
| 814d1909f6 | |||
| f5bb8afaec | |||
| ba0a144afd | |||
| d044218c6a | |||
| 06872186a8 | |||
| b8773c47d7 | |||
| 9184e6e09f | |||
| 5fa1dd1594 | |||
| 9cb8eea636 | |||
| dbed5bb7af | |||
| 11e33c36c0 | |||
| 9a1a530156 | |||
| d02527b751 | |||
| 9129cb8301 | |||
| 61e46e1479 | |||
| bb2846ea17 | |||
| c8d0c8fbd9 | |||
| c49cf68b78 | |||
| 10f8d3c2c2 | |||
| 53e7122302 | |||
| 5013d3d8c3 | |||
| 5cf1157636 | |||
| 1d5320eb33 | |||
| 1873823b4c | |||
| 8c8c7f3bad | |||
| c1f3cc172d | |||
| 0c1e36d20a | |||
| 16704b415d | |||
| ceb3b2df69 | |||
| 6af71ad5f4 | |||
| ea4295cb89 | |||
| 6b72d20af8 | |||
| 773e3830f5 | |||
| 5e99ac817b | |||
| 61308656ac | |||
| fd2a049d93 | |||
| 35d1c34ed8 | |||
| 136ca99f25 | |||
| 2d3b636bfa | |||
| 0bd6bd8baa | |||
| b6eebe497d | |||
| 7126ee1329 | |||
| c707a6ff97 | |||
| f968149a72 | |||
| a9ea21e3d4 | |||
| aca019ff69 | |||
| 0fadf3b23a | |||
| dfd4fa6be7 | |||
| 97d81f2295 | |||
| 91d50c2d83 | |||
| c85f65a99a | |||
| f525f9c393 | |||
| 2adc0a763b | |||
| d939934b7b | |||
| e12716722a | |||
| ac901ac096 | |||
| e54d7c8155 | |||
| d84f2b790f | |||
| a2dbc169b2 | |||
| 845c270f60 | |||
| ed6ac39015 | |||
| ba4b95972f | |||
| 440e00fb47 | |||
| 0a41cee6bf | |||
| aa96c0089c | |||
| da4116a1d1 | |||
| 54bf5efa1f | |||
| 9c590f4560 | |||
| 589a5f159e | |||
| ae11c91674 | |||
| 72989349e8 | |||
| 1283b56be9 | |||
| 5c2c35130f | |||
| da9f88d181 | |||
| 59929e9c4d | |||
| 55f8d946f9 | |||
| e3127e8555 | |||
| 478f53177e | |||
| 4b9fe24b25 | |||
| 9810f813a8 | |||
| 9090ecfa2b | |||
| 6c58b087ae | |||
| 3555cbcf99 | |||
| 9a08c6e488 | |||
| b1c49c06a3 | |||
| 9b81175e85 | |||
| 29fa317689 | |||
| 7be92b8eec | |||
| 3caad76477 | |||
| 65788705c3 | |||
| 4a4ed9bc2d | |||
| 70efa971eb | |||
| a6bfd2cb68 | |||
| df38cfdbca | |||
| 234d3a21a3 | |||
| 64a4643503 | |||
| c7e0234896 | |||
| c69aee40a2 | |||
| 908c5b248c | |||
| 546b1aff9b | |||
| 9aecefff40 | |||
| a9ff5c43f0 | |||
| d34a155922 | |||
| c2c5b5c3be | |||
| 5e729f74cd | |||
| 9e6ed02ce1 | |||
| 973acd7e9b | |||
| d2cf678491 | |||
| 9c74ddcaa9 | |||
| b63d2ba343 | |||
| 0497aa33c9 | |||
| 0d1fe7bbca | |||
| b5675802f4 | |||
| 14e5a82b1e | |||
| bc2131ed52 | |||
| af21eee389 | |||
| 20d7aa199d | |||
| 16222d0145 | |||
| 64ca61bb32 | |||
| 135666c956 | |||
| 46cdbe08eb | |||
| 62cc2611ea | |||
| 65b6c2afb6 | |||
| d80f8ad70c | |||
| 7ba94d72e4 | |||
| 1597e7540b | |||
| bd6852041e | |||
| e15c2b312c | |||
| 17e7bbd07e | |||
| 910d759155 | |||
| a8d5a1538c | |||
| 3f982e2241 | |||
| 418909f531 | |||
| 44098af247 | |||
| 268b171ba4 | |||
| 2e44d2a677 | |||
| 4277a8fe7d | |||
| 9045ff3c41 | |||
| f56ff2f305 | |||
| 699bc6ca33 | |||
| 16ec99b327 | |||
| 09e211f48a | |||
| 27b60b2a6f | |||
| 07ea1f94d1 | |||
| d1017697a4 | |||
| abe12fdefa | |||
| b02a3b604e | |||
| 305af8ad93 | |||
| c03705d6d6 | |||
| 189411ff77 | |||
| ebfa8fc6d2 | |||
| b639bd7a58 | |||
| 74a2522af1 | |||
| ea53a1b0dd | |||
| 5d99337cd2 | |||
| e2ec2892ab | |||
| af67e7f812 | |||
| 2788127894 | |||
| 9a34fa0102 | |||
| 9f425366c0 | |||
| 7eb70f3a61 | |||
| 0436949797 | |||
| b2634d2fcb | |||
| 8fdb5cf1ad | |||
| 981e4f0726 | |||
| b46703eaed | |||
| 476e99ab81 | |||
| 9483fbc99a | |||
| bf540fb5c1 | |||
| 0cae729335 | |||
| 3b48359aa7 | |||
| c5d5165f84 | |||
| 94f531cdd4 | |||
| 222f641123 | |||
| ecbee21d34 | |||
| 3f28bf571a | |||
| 2e7eee66ee | |||
| 7c4d3012ec | |||
| 01af784953 | |||
| 30b10fd435 | |||
| 19cc0d13c9 | |||
| 8016ecb32d | |||
| 43b2ac91b6 | |||
| 0078ba90cb | |||
| dc168bc978 | |||
| 44c3888ac1 | |||
| 7918ee3662 | |||
| 98644047eb | |||
| 423d53ea58 | |||
| 460926fa99 | |||
| cf2f466772 | |||
| 71fe5aaa3a | |||
| 9813a226ec | |||
| 8eb31223a5 | |||
| 00412385c8 | |||
| 5a79c7cbe0 | |||
| da8a5e1dde | |||
| e3b16a3c5b | |||
| a5849fc747 | |||
| 42430e510d | |||
| 09c364b060 | |||
| d96361c578 | |||
| 1346112f36 | |||
| 44b1019d98 | |||
| b4c5db0c0e | |||
| fcfcb381a8 | |||
| 3f3d99e25a | |||
| c957041cf3 | |||
| a56b4839c8 | |||
| 5768dc9183 | |||
| e871229248 | |||
| 141166cdc8 | |||
| b99590bc5e | |||
| 6684efd146 | |||
| b75d8dc16b | |||
| 4a4e6e4398 | |||
| 9054decb16 | |||
| 3f8d6a6c56 | |||
| 3708730c7d | |||
| 40c3e1d025 | |||
| 6242940985 | |||
| 88fd6a74d8 | |||
| fe800401ad | |||
| 2c0e32a039 | |||
| f9f9a8b0d2 | |||
| 480e0aa97f | |||
| e66ab53562 | |||
| da0bffdac2 | |||
| 7afbfb4307 | |||
| ee5d3415ac | |||
| b2f4cc3583 | |||
| e21ee2e4fc | |||
| 8923aa87e2 | |||
| 527b31247b | |||
| 864057f382 | |||
| 7440b2d620 | |||
| f48ba562ea | |||
| c91bdc1d89 | |||
| c7b3305ef4 | |||
| 09c904917d | |||
| 3e099bb08d | |||
| 0e99250a3b | |||
| 9be5650dcd | |||
| 3efdcd5a63 | |||
| fec7021a7f | |||
| 0940358fba | |||
| 50637a4dc1 | |||
| 89a3562a1e | |||
| e9def50a85 | |||
| 2852590e09 | |||
| e883309791 | |||
| bd68a32708 | |||
| 7675d010c2 | |||
| a2f088f86a | |||
| de9a7b0c39 | |||
| c25d772bca | |||
| 75f1b14551 | |||
| 9d914a430c | |||
| aa8541298e | |||
| 4fdbb4d960 | |||
| cb48434f96 | |||
| f4f8e49627 | |||
| 2602182bb7 | |||
| ca39448605 | |||
| 841d10c39c | |||
| f12e2a72da | |||
| dec3d04ca5 | |||
| ca581e37c2 | |||
| 8353f125ff | |||
| dd00cbff24 | |||
| c98b738290 | |||
| f2a8cd75b9 | |||
| b5c941f9fb | |||
| 9cdbb7c9e8 | |||
| 0c9da915ef | |||
| 0ba6bacaf5 | |||
| 3f02fb83f9 | |||
| cd2afb8300 | |||
| 9120cff708 | |||
| 482dca78ec | |||
| 10fc3bf0a7 | |||
| d3462f42dc | |||
| 357d108c7e | |||
| 755f3b9fb0 | |||
| 7aaf9f1cad | |||
| 9be0c22b03 | |||
| a55233fdb1 | |||
| 50e9aee290 | |||
| 97aacd96aa | |||
| 30adbdc947 | |||
| 6b52926da1 | |||
| e14c727568 | |||
| 7d69f48bf6 | |||
| 1d9cd2cd3f | |||
| 8ab7be43dd | |||
| 0c6479f17e | |||
| 4eaec7fead | |||
| 94ca6d162f | |||
| f351443049 | |||
| 4d4d8a43e0 | |||
| 63143db9db | |||
| 08be5e9985 | |||
| 8405d42902 | |||
| 03dcc37083 | |||
| 6b9aeddb06 | |||
| 23e845ebc1 | |||
| 5a80df05f5 | |||
| cc3a5b3415 | |||
| 9a48d039db | |||
| fdacb2029a | |||
| 1eb126bdf8 | |||
| ca260497cc | |||
| 846c4f794a | |||
| 3a9f41892f | |||
| b6b5a46f4f | |||
| 8b0eb97abb | |||
| 5463206d84 | |||
| ed637bc9df | |||
| f465cb7347 | |||
| 49a5461fbe | |||
| 75f6283d9b | |||
| a144193cb4 | |||
| 2ec57ad027 | |||
| ff412bbb29 | |||
| 12d299a7ec | |||
| 8fe0751a67 | |||
| 1b940b262c | |||
| 6ea1d0da2b | |||
| 4dd487e0b2 | |||
| 82f97aa1e2 | |||
| 1be0b3f101 | |||
| 1afafb7abd | |||
| afce15d2d4 | |||
| 348bbf6522 | |||
| 9aa7366c74 | |||
| f68f257234 | |||
| 360a8c88e3 | |||
| feca8bc357 | |||
| 5080970366 | |||
| be4a741a73 | |||
| 589fb8ebba | |||
| 0156a82629 | |||
| 8497d87238 | |||
| 787e0f6902 | |||
| 6ac7bdf826 | |||
| d1ca846d30 | |||
| 0240e77bf9 | |||
| cfcc4b8858 | |||
| b3b7bdd20c | |||
| cbfd4a1f60 | |||
| 2a2ebd6a46 | |||
| ef7af83e5d | |||
| b5b7424472 | |||
| 3805bf39a5 | |||
| 008f3979e1 | |||
| 01980918bc | |||
| ca63c21080 | |||
| 0d637a55b1 | |||
| bddfe4b838 | |||
| 664a555fbd | |||
| 4d00ba9542 | |||
| 12c7676882 | |||
| ea99fdf288 | |||
| 8411fb997d | |||
| 3cc1e1dcec | |||
| 56650efe74 | |||
| ef64668fac | |||
| ce4550cae5 | |||
| d951aab997 | |||
| 3dac492e31 | |||
| 907370e270 | |||
| 1eeaf4c10e | |||
| c5140bf118 | |||
| f0f54d76c5 | |||
| 819d0a88f1 | |||
| 08e61eea89 | |||
| 273469eda8 | |||
| 97a219aa8c | |||
| 5dafdf85f7 | |||
| 7830269ea1 | |||
| 118b0c11ab | |||
| 4ad0a9cfb4 | |||
| 3e5840b9a2 | |||
| c23af72da7 | |||
| bfee3dfdf1 | |||
| b29f7ec4d5 | |||
| a42e5f085e | |||
| cc655891d5 | |||
| 708c25d938 | |||
| a7cd13228b | |||
| ef100bfac1 | |||
| c82b256128 | |||
| a5c52c72be | |||
| 865a472ef1 | |||
| 85b8e68f52 | |||
| c26aa709d0 | |||
| 618655e921 | |||
| 436324fe8f | |||
| d0a11e266f | |||
| 5bf99176bb | |||
| 9c20102dad | |||
| 8b311bde81 | |||
| b4e42778fa | |||
| 986adeb901 | |||
| 1ce9beeaf5 | |||
| e704399c3d | |||
| d1ae988024 | |||
| 27736c7047 | |||
| 6f68153306 | |||
| 3260350377 | |||
| 03d174e5cc | |||
| 243ce98dd4 | |||
| f14316f024 | |||
| 399a3586b2 | |||
| 3bba781f49 | |||
| 91fe272bea | |||
| 0618a1ca13 | |||
| 3fe1256381 | |||
| 1bce67d21d | |||
| 00fa9cad57 | |||
| 9b9abaa855 | |||
| 06b53dbc82 | |||
| bf6788c141 | |||
| 363e39d72c | |||
| e2ce575b25 | |||
| 36373400f8 | |||
| e12d8eebdd | |||
| 91de4f80d8 | |||
| c2af41c7f2 | |||
| 6d9e750251 | |||
| 12c19ac4c2 | |||
| 7768588dbd | |||
| 0f759de671 | |||
| 53b0281dc8 | |||
| f85d345821 | |||
| ff44d9022c | |||
| cb9d183d7d | |||
| a3874a77f4 | |||
| 4264fb4aba | |||
| f2c479ea3a | |||
| 7fa856224e | |||
| 379c21c458 | |||
| 3b576685b7 | |||
| c95287e5a4 | |||
| 54a49f1ece | |||
| a2f2d9ff89 | |||
| cb26238729 | |||
| 800e0bbe47 | |||
| f4ae344b30 | |||
| 61d3c261fe | |||
| ae32b62552 | |||
| 781aa2579b | |||
| 6999da3e45 | |||
| 2c5528774f | |||
| 78db2568e0 | |||
| 64db8b2ce0 | |||
| 8f6361f6fc | |||
| 85894b98f5 | |||
| 3b052d3eb6 | |||
| ecb61d44a5 | |||
| 5789b34b5a | |||
| 5810c86e07 | |||
| 3312621f1d | |||
| ccf1e0f137 | |||
| 2ba987f532 | |||
| f677c131c0 | |||
| 650a45729e | |||
| ab2f574ff3 | |||
| bf59fd6dc2 | |||
| 6bca0922f1 | |||
| 482c99281c | |||
| 72a25d09aa | |||
| bb2bd15a71 | |||
| baf77c95aa | |||
| de8a39f78a | |||
| 4f6f6beff3 | |||
| d224035d28 | |||
| e914109b4b | |||
| 1e694a6cf8 | |||
| 8e6bd29be0 | |||
| ab1f95f2df | |||
| fe11513a6f | |||
| 52e42fcd6e | |||
| 3aa08ba93e | |||
| 9837c23a96 | |||
| 2b9dd6ed6a | |||
| 8ccc2c4a7a | |||
| b4b94698b4 | |||
| 7fa751492b | |||
| 496dfd48e0 | |||
| bae285dd8f | |||
| d628619eca | |||
| edc4163852 | |||
| 08cc77dbdc | |||
| 4764202a44 | |||
| 3d951cdaea | |||
| aadd2908e2 | |||
| 5cb731e557 | |||
| 5660a1cb1b | |||
| aa618edc43 | |||
| c49afc7add | |||
| 64bac10758 | |||
| e74cd1efbb | |||
| 773592f9dd | |||
| 995088842a | |||
| 4abc45a849 | |||
| 5ce2d3d8b4 | |||
| 4391743695 | |||
| a145f92bcb | |||
| 2c853ff02a | |||
| c8d46b3611 | |||
| a75fef039d | |||
| cf6fcc353c |
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: capacitor-compat
|
||||
description: Browser-API gotchas inside Capacitor's WKWebView (iOS) and Android WebView — which common web APIs silently fail, the downloadTextFile/openUrl helpers that bridge web and native, platform detection, and the installed Capacitor plugins. Load when writing code that interacts with file downloads, external URLs, or platform-specific behavior.
|
||||
---
|
||||
|
||||
# Capacitor Compatibility
|
||||
|
||||
Ditto runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features.
|
||||
|
||||
## What Doesn't Work in WKWebView (iOS)
|
||||
|
||||
- **`<a download>` file downloads** — programmatically creating an anchor with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
|
||||
- **`<a target="_blank">` new tabs** — programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app.
|
||||
- **`window.open()`** — may be blocked or behave unexpectedly without user-gesture context.
|
||||
|
||||
For a deeper list of Apple Lockdown Mode restrictions that also affect WKWebView, load the **`lockdown-mode`** skill.
|
||||
|
||||
## File Downloads and URL Opening
|
||||
|
||||
`src/lib/downloadFile.ts` provides two utilities that handle the web/native split automatically. **Always use these** instead of manually constructing anchors.
|
||||
|
||||
### `downloadTextFile(filename, content)`
|
||||
|
||||
Saves a text file to the user's device. On web it uses the `<a download>` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`.
|
||||
|
||||
```typescript
|
||||
import { downloadTextFile } from '@/lib/downloadFile';
|
||||
|
||||
await downloadTextFile('backup.txt', fileContents);
|
||||
```
|
||||
|
||||
### `openUrl(url)`
|
||||
|
||||
Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor.
|
||||
|
||||
```typescript
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
await openUrl('https://example.com/image.jpg');
|
||||
```
|
||||
|
||||
**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. The utilities above work correctly on all platforms; manual anchors silently fail on iOS.
|
||||
|
||||
## Detecting Native Platforms
|
||||
|
||||
Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior:
|
||||
|
||||
```typescript
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// iOS or Android
|
||||
} else {
|
||||
// Web browser
|
||||
}
|
||||
```
|
||||
|
||||
Reserve platform forks for cases where behavior genuinely differs (share sheets, secure storage, haptics). Most UI code should stay platform-agnostic.
|
||||
|
||||
## Installed Capacitor Plugins
|
||||
|
||||
- `@capacitor/app` — app lifecycle events (deep links, back button)
|
||||
- `@capacitor/core` — core runtime and platform detection
|
||||
- `@capacitor/filesystem` — read/write files on the native filesystem
|
||||
- `@capacitor/haptics` — native haptics
|
||||
- `@capacitor/keyboard` — keyboard control (hide accessory bar, etc.)
|
||||
- `@capacitor/local-notifications` — schedule local push notifications
|
||||
- `@capacitor/share` — native share sheet
|
||||
- `@capacitor/status-bar` — control the native status-bar style
|
||||
- `@capgo/capacitor-autofill-save-password` — iOS keychain autofill for nsec
|
||||
- `capacitor-secure-storage-plugin` — OS-level secure storage (iOS Keychain / Android KeyStore)
|
||||
|
||||
After adding or removing plugins, run `npm run cap:sync` to update the native projects.
|
||||
@@ -0,0 +1,350 @@
|
||||
---
|
||||
name: ci-cd-publishing
|
||||
description: Ditto's release and publishing pipeline — cutting a version tag, Zapstore APK publishing with NIP-46 bunker auth, nsite web deploys via nsyte, and Google Play AAB uploads via fastlane supply. Includes GitLab CI variable setup and credential rotation.
|
||||
---
|
||||
|
||||
# CI/CD Pipeline and Publishing
|
||||
|
||||
Ditto uses GitLab CI (`.gitlab-ci.yml`) to run tests on every commit, deploy the web app to nsite on every default-branch push, and build + publish Android binaries to Zapstore and Google Play on every tag. Load this skill when setting up CI credentials, rotating a signing key, diagnosing a failed publish, or adding a new publishing target.
|
||||
|
||||
## Pipeline Overview
|
||||
|
||||
| Stage | Runs on | Job |
|
||||
|-----------|---------------------------|-----------------------------------------|
|
||||
| `test` | every commit (not tags) | `npm run test` |
|
||||
| `deploy` | default branch only | `deploy-nsite` (Vite build → nsyte) |
|
||||
| `build` | tags only | `build-apk` (signed APK + AAB) + `build-ipa` (signed IPA on the Mac runner) |
|
||||
| `release` | tags only | GitLab Release with APK / AAB / IPA links |
|
||||
| `publish` | tags only | `publish-zapstore` + `publish-google-play` + `publish-app-store` |
|
||||
|
||||
## Creating a Release
|
||||
|
||||
Releases are triggered by pushing a version tag:
|
||||
|
||||
```bash
|
||||
npm run release
|
||||
```
|
||||
|
||||
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` jobs.
|
||||
|
||||
For the full versioning / changelog / native-build workflow, load the **`release`** skill.
|
||||
|
||||
## Zapstore Publishing
|
||||
|
||||
The `publish-zapstore` CI job uploads signed APKs to [Zapstore](https://zapstore.dev/) using the [`zsp`](https://github.com/zapstore/zsp) CLI and NIP-46 bunker signing via Amber.
|
||||
|
||||
**Configuration files:**
|
||||
|
||||
- `zapstore.yaml` — app metadata for Zapstore (name, tags, icon, supported NIPs)
|
||||
- `.gitlab-ci.yml` — the `publish-zapstore` job definition
|
||||
|
||||
**GitLab CI/CD variables** (Settings → CI/CD → Variables):
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `ZAPSTORE_BUNKER_URL` | NIP-46 bunker URL (`bunker://<pubkey>?relay=...`). No `secret` param needed after initial auth. | Yes | No | Yes |
|
||||
| `ZAPSTORE_CLIENT_KEY` | Hex private key used as the NIP-46 client identity for bunker communication | Yes | Yes | Yes |
|
||||
| `ANDROID_KEYSTORE_BASE64` | Base64-encoded Android signing keystore | Yes | Yes | Yes |
|
||||
| `KEYSTORE_PASSWORD` | Android keystore password | Yes | Yes | Yes |
|
||||
| `KEY_PASSWORD` | Android key password | Yes | Yes | Yes |
|
||||
|
||||
### How NIP-46 bunker auth works in CI
|
||||
|
||||
NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and a **client key** (the CI runner's identity). The bunker authorizes specific client pubkeys — once authorized, the client can request signatures without re-approval.
|
||||
|
||||
The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/<bunker-pubkey>.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client.
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
Run the NIP-46 client-initiated auth script:
|
||||
|
||||
```bash
|
||||
node scripts/nip46-auth.mjs
|
||||
```
|
||||
|
||||
This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script outputs the `bunker://` URI and client key hex, and writes the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
|
||||
|
||||
Options:
|
||||
- `--relay <url>` — relay for NIP-46 communication (default: `wss://relay.ditto.pub`)
|
||||
- `--name <name>` — app name shown to the signer (default: `Ditto`)
|
||||
- `--timeout <sec>` — how long to wait for approval (default: 300)
|
||||
|
||||
After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs. If the client key is rotated, run the script again and update the GitLab variables.
|
||||
|
||||
## nsite Publishing
|
||||
|
||||
The `deploy-nsite` CI job deploys the Vite build to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The job uploads `dist/` to Blossom servers and publishes site manifest events to Nostr relays.
|
||||
|
||||
nsyte uses a NIP-46 bunker credential called **nbunksec** — a bech32-encoded string bundling the bunker pubkey, client secret key, and relay info into a single self-contained token. It's passed to nsyte via `--sec`.
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. Install nsyte locally:
|
||||
```bash
|
||||
curl -fsSL https://nsyte.run/get/install.sh | bash
|
||||
```
|
||||
2. Generate the CI credential:
|
||||
```bash
|
||||
nsyte ci
|
||||
```
|
||||
This guides you through connecting a NIP-46 bunker (e.g. Amber) and outputs an `nbunksec1...` string. The credential is shown only once.
|
||||
3. Add the `nbunksec1...` value as `NSITE_NBUNKSEC` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**.
|
||||
|
||||
### Configured relays and servers
|
||||
|
||||
Relays the deploy job publishes to:
|
||||
|
||||
- `wss://relay.ditto.pub`
|
||||
- `wss://relay.nsite.lol`
|
||||
- `wss://relay.dreamith.to`
|
||||
- `wss://relay.primal.net`
|
||||
|
||||
Blossom servers:
|
||||
|
||||
- `https://blossom.primal.net`
|
||||
- `https://blossom.ditto.pub`
|
||||
- `https://blossom.dreamith.to`
|
||||
|
||||
The `--use-fallback-relays` and `--use-fallback-servers` flags include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
|
||||
|
||||
### Credential rotation
|
||||
|
||||
To rotate the nsite credential:
|
||||
|
||||
1. Revoke the old bunker connection in your signer app.
|
||||
2. Run `nsyte ci` again to generate a new `nbunksec1...` string.
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings.
|
||||
|
||||
## Google Play Publishing
|
||||
|
||||
The `publish-google-play` CI job uploads Android AABs to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). It runs after a successful AAB build and uploads directly to the production track.
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | **Base64-encoded** contents of the Google Play API service account key JSON. The CI job decodes with `base64 -d` before passing to `fastlane supply`. | Yes | Yes | No |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. Create or reuse a project in [Google Cloud Console](https://console.cloud.google.com/projectcreate).
|
||||
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project.
|
||||
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it.
|
||||
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`.
|
||||
5. **Base64-encode** the key file:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
base64 -w0 service-account.json
|
||||
|
||||
# macOS
|
||||
base64 -i service-account.json | tr -d '\n'
|
||||
```
|
||||
|
||||
6. Add the base64-encoded value as `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**. **Do not paste the raw JSON** — the CI script expects base64 and will fail to decode a raw value.
|
||||
|
||||
### Key points
|
||||
|
||||
- The job uploads the signed **AAB** (not APK) — Google Play requires App Bundles.
|
||||
- Uploads go directly to the **production** track. Google's review process still applies before the update reaches users.
|
||||
- Metadata, screenshots, and store-listing description are managed in the Play Console (the job uses `--skip_upload_metadata`, `--skip_upload_images`, `--skip_upload_screenshots`).
|
||||
- **Changelogs ("What's new in this version")** are uploaded from `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`, generated at CI time from the release summary paragraph in `CHANGELOG.md`. See "Release notes pipeline" below.
|
||||
- The same signing keystore used for Zapstore is reused here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`).
|
||||
|
||||
## App Store Publishing
|
||||
|
||||
Ditto's iOS pipeline is split across two jobs:
|
||||
|
||||
- **`build-ipa`** (stage `build`, `tags: [macos]`) runs on the self-hosted Mac runner. Decodes the App Store Connect API key, fetches the encrypted distribution cert + provisioning profile via fastlane match, builds the web assets, runs `cap sync ios`, stamps the marketing version into `project.pbxproj`, then `fastlane build_ipa` produces a signed App Store IPA at `artifacts/Ditto.ipa`. The IPA is uploaded to the GitLab Generic Packages registry as `Ditto-${CI_COMMIT_TAG}.ipa` (mirrors how `build-apk` publishes the APK and AAB) and exposed as a CI artifact for downstream jobs.
|
||||
- **`publish-app-store`** (stage `publish`, `tags: [macos]`) also runs on the self-hosted Mac runner. Consumes the IPA artifact via `needs: [build-ipa]` and the release-notes artifact via `needs: [release-notes]`. Decodes the API key, copies the release-notes summary into `ios/fastlane/metadata/en-US/release_notes.txt`, and runs `fastlane submit_release` which calls `deliver` to upload metadata + push the prebuilt IPA + auto-submit for App Store review. **macOS is required** even though the IPA is already signed: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode. A Linux container ran into `No such file or directory @ dir_chdir0` from `JavaTransporterExecutor#execute` because `Helper.itms_path` resolved to a missing Xcode path.
|
||||
|
||||
The Mac runner is therefore used for both iOS jobs. For runner administration (operating the Mac, restarting the agent, viewing logs, rotating signing certs), load the **`mac-runner`** skill.
|
||||
|
||||
**Configuration files:**
|
||||
|
||||
- `ios/fastlane/Fastfile` — exposes four lanes:
|
||||
- `build_ipa` — setup_ci → match (readonly, with API key) → increment_build_number → build_app. Used by CI's `build-ipa`.
|
||||
- `submit_release` — reads `IPA_PATH` env var, calls deliver against the prebuilt IPA. Used by CI's `publish-app-store`.
|
||||
- `release` — combines build_ipa + submit_release; convenience for local one-shot runs.
|
||||
- `submit_only` — debug lane that skips build/upload and only runs deliver against an already-uploaded build (set `BUILD_NUMBER` + `VERSION` env vars). See the `mac-runner` skill.
|
||||
- `ios/fastlane/Appfile` — bundle identifier and team ID
|
||||
- `ios/fastlane/Matchfile` — points at the shared `soapbox-pub/certificates` repo
|
||||
- `ios/fastlane/metadata/en-US/release_notes.txt` — placeholder; CI overwrites it with the release summary paragraph from `CHANGELOG.md` per release
|
||||
- `.gitlab-ci.yml` — `build-ipa` and `publish-app-store` both run on the Mac runner (`tags: [macos]`)
|
||||
|
||||
**Code signing storage**: a private GitLab repo `soapbox-pub/certificates` holds encrypted distribution certs and provisioning profiles, managed by [fastlane match](https://docs.fastlane.tools/actions/match/). Match handles cert/profile lifecycle: one passphrase decrypts everything; the same repo can hold signing material for multiple Soapbox iOS apps under team `GZLTTH5DLM`.
|
||||
|
||||
**App Store Connect auth**: a long-lived [App Store Connect API key](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api) (`.p8` file + key ID + issuer ID) authenticates `match`, `deliver`, and `pilot`. Avoids 2FA prompts that would interrupt CI.
|
||||
|
||||
**Distribution**: `submit_for_review: true` automatically pushes the build into Apple's review queue once uploaded. `automatic_release: false` keeps a human-controlled final gate — once Apple approves, you click "Release" in the App Store Connect web UI to publish to users. To remove the manual gate, flip `automatic_release` to `true` in `ios/fastlane/Fastfile`.
|
||||
|
||||
**Release notes**: copied from the `release-notes` job's artifact `artifacts/release-notes-summary.txt` (the leading plaintext paragraph of the version's `CHANGELOG.md` section) into `ios/fastlane/metadata/en-US/release_notes.txt`, uploaded by `deliver` as the App Store "What's New in This Version" text. See "Release notes pipeline" below.
|
||||
|
||||
**IPA distribution beyond the App Store**: `build-ipa` uploads the signed IPA to the GitLab Generic Packages registry, and the `release` job links it from the GitLab Release page. The IPA is signed with the App Store distribution profile, so it isn't directly sideloadable — installation goes through Apple's review process — but having it as a stable artifact lays the groundwork for AltStore or ad-hoc distribution later (which would require a separate provisioning profile).
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `MATCH_PASSWORD` | Symmetric passphrase used by match to encrypt/decrypt certs and profiles. The single most important secret — losing it makes the cert repo unreadable. | Yes | Yes | Yes |
|
||||
| `MATCH_GIT_BASIC_AUTHORIZATION` | Base64 of `username:deploy-token` for HTTPS clone of the certificates repo. Generated from a `read_repository`-scoped deploy token on `soapbox-pub/certificates`. | Yes | Yes | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_ID` | App Store Connect API key ID (10 chars). | Yes | No | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_ISSUER_ID` | App Store Connect issuer ID (UUID). | Yes | No | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_P8_BASE64` | Base64-encoded contents of the `.p8` private key file. CI decodes with `base64 -d` into `~/.private_keys/AuthKey_<KEY_ID>.p8` and removes it in `after_script`. | Yes | Yes | Yes |
|
||||
| `FASTLANE_KEYCHAIN_PASSWORD` | Password for the ephemeral keychain `setup_ci` creates per build. Random per setup; keep stable across runs. | Yes | Yes | Yes |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. **Provision the Mac runner.** See the **`mac-runner`** skill for hardware/launchd setup, Xcode, Homebrew, fastlane, and `gitlab-runner` registration.
|
||||
|
||||
2. **Create the App Store Connect API key.** Log in to [App Store Connect](https://appstoreconnect.apple.com) → Users and Access → Integrations → App Store Connect API → Generate. Use the **App Manager** role (sufficient for `deliver`'s upload + submit-for-review). Download the `.p8` file (one-time download — Apple won't show it again). Note the **Key ID** (10-char string next to the key) and the **Issuer ID** (UUID at the top of the API page).
|
||||
|
||||
Set the three GitLab CI variables:
|
||||
```bash
|
||||
# Replace <ISSUER_ID>, <KEY_ID>, and the path to your .p8
|
||||
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/projects/$PROJECT_ID/variables" \
|
||||
--data-urlencode "key=APP_STORE_CONNECT_API_KEY_ISSUER_ID" \
|
||||
--data-urlencode "value=<ISSUER_ID>" \
|
||||
--data-urlencode "protected=true" --data-urlencode "raw=true"
|
||||
# repeat for APP_STORE_CONNECT_API_KEY_ID
|
||||
# for the .p8, base64 first:
|
||||
base64 -i AuthKey_<KEY_ID>.p8 | tr -d '\n' # paste this as APP_STORE_CONNECT_API_KEY_P8_BASE64 (masked)
|
||||
```
|
||||
|
||||
3. **Create the certificates repo.** A private GitLab repo at `soapbox-pub/certificates` holds match-encrypted certs/profiles. Create a project deploy token on it (Settings → Repository → Deploy tokens) with `read_repository` scope. Encode `username:token` as base64 → set as `MATCH_GIT_BASIC_AUTHORIZATION` (protected, masked, raw).
|
||||
|
||||
4. **Generate `MATCH_PASSWORD` and `FASTLANE_KEYCHAIN_PASSWORD`.** Both are arbitrary strong random strings — `openssl rand -base64 32 | tr -d '=+/' | head -c 32` works. Store them as protected, masked GitLab variables.
|
||||
|
||||
5. **Bootstrap match certs via a one-shot CI job** (preferred over running match locally — avoids the macOS keychain UI permission dialogs that fastlane bug [#15185](https://github.com/fastlane/fastlane/issues/15185) trips on newer macOS):
|
||||
|
||||
a. Create a temporary write-scoped GitLab variable. The deploy token is `read_repository`; for the initial cert creation match needs to push. Encode `username:write-pat` as base64 and set it as `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` (Protected, Masked, Raw).
|
||||
|
||||
b. Add a temporary `setup-match` job to `.gitlab-ci.yml` that runs on the macos runner with `setup_ci` (which creates an ephemeral keychain — bypasses the GUI permission issue):
|
||||
|
||||
```yaml
|
||||
setup-match:
|
||||
stage: publish
|
||||
tags: [macos]
|
||||
rules:
|
||||
- if: $SETUP_MATCH == "1"
|
||||
when: manual
|
||||
script:
|
||||
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
|
||||
- mkdir -p "$HOME/.private_keys" && chmod 700 "$HOME/.private_keys"
|
||||
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
|
||||
- chmod 600 "$ASC_KEY_PATH"
|
||||
- cd ios
|
||||
- export MATCH_GIT_BASIC_AUTHORIZATION="$MATCH_GIT_BASIC_AUTHORIZATION_WRITE"
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
- |
|
||||
cat > Fastfile.setup <<'RUBY'
|
||||
default_platform(:ios)
|
||||
platform :ios do
|
||||
lane :setup do
|
||||
setup_ci
|
||||
api_key = {
|
||||
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
|
||||
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
|
||||
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
|
||||
duration: 1200,
|
||||
in_house: false,
|
||||
}
|
||||
match(type: "appstore", readonly: false, api_key: api_key, force_for_new_devices: true)
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
- mv fastlane/Fastfile fastlane/Fastfile.bak
|
||||
- mv Fastfile.setup fastlane/Fastfile
|
||||
- fastlane setup
|
||||
- mv fastlane/Fastfile.bak fastlane/Fastfile
|
||||
after_script:
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
```
|
||||
|
||||
c. Trigger the pipeline manually with `SETUP_MATCH=1`:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline" \
|
||||
--data-urlencode "ref=main" \
|
||||
--data-urlencode "variables[][key]=SETUP_MATCH" \
|
||||
--data-urlencode "variables[][value]=1"
|
||||
# Then play the manual setup-match job
|
||||
```
|
||||
|
||||
d. Once the job succeeds (cert + profile pushed to the certificates repo), **delete the `setup-match` job from `.gitlab-ci.yml` and the `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` variable**. They're only needed for bootstrap.
|
||||
|
||||
### Yearly cert renewal
|
||||
|
||||
Apple distribution certs expire annually. Renewal is one command per year, run on any Mac:
|
||||
|
||||
```bash
|
||||
cd ~/Projects/ditto/ios
|
||||
fastlane match nuke distribution # revokes old cert in Apple's portal, removes from match repo
|
||||
fastlane match appstore # creates new cert + profile, encrypts, commits, pushes
|
||||
```
|
||||
|
||||
CI's next tag run picks up the new files automatically (`match(... readonly: true)`).
|
||||
|
||||
### Disaster recovery (Mac dies / new developer joins)
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.com/soapbox-pub/ditto.git
|
||||
cd ditto/ios
|
||||
fastlane match appstore --readonly # decrypts existing certs/profiles using MATCH_PASSWORD
|
||||
```
|
||||
|
||||
No re-issuance of certs needed — the cert repo is the source of truth.
|
||||
|
||||
### App Store Connect API key rotation
|
||||
|
||||
App Store Connect API keys can be revoked anytime. To rotate:
|
||||
|
||||
1. App Store Connect → Users and Access → Integrations → App Store Connect API → Generate new key
|
||||
2. Download the new `.p8`, note the new key ID
|
||||
3. Update `APP_STORE_CONNECT_API_KEY_ID` and `APP_STORE_CONNECT_API_KEY_P8_BASE64` in GitLab variables
|
||||
4. (Issuer ID stays the same — it's per-team, not per-key)
|
||||
5. Revoke the old key in App Store Connect
|
||||
|
||||
### Key points
|
||||
|
||||
- `build-ipa` (Mac) produces a signed **IPA** (App Store distribution format) and uploads it to GitLab's Generic Packages registry. `publish-app-store` (also Mac) submits it to Apple via `deliver`.
|
||||
- Builds go to **App Store Connect**, automatically submit for review, but do **not** auto-release after approval. The final "Release" click is manual in the web UI.
|
||||
- Marketing version comes from the git tag (`v2.1.0` → `MARKETING_VERSION = 2.1.0`); build number comes from `CI_PIPELINE_IID`.
|
||||
- Release notes ("What's New in This Version") come from the release-notes summary paragraph (see "Release notes pipeline" below).
|
||||
- `setup_ci` (in `build-ipa`) creates an ephemeral keychain per build, so the runner never touches the login keychain — works whether or not a GUI session is logged in.
|
||||
- `publish-app-store` does no code signing, but it still needs macOS: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode.
|
||||
|
||||
## Release notes pipeline
|
||||
|
||||
Release notes for all three storefronts (App Store, Google Play, GitLab Release page) and the in-app version-update toast are derived from a single source: `CHANGELOG.md`.
|
||||
|
||||
**The `release-notes` job** (stage `build`, default `node:22` image, runs only on `v*` tags) calls `scripts/extract-release-notes.mjs` twice and publishes two artifacts:
|
||||
|
||||
- `artifacts/release-notes.md` — the full section for this version (summary paragraph + `### Added` / `### Changed` / etc. lists). Used as the GitLab Release description.
|
||||
- `artifacts/release-notes-summary.txt` — only the leading plaintext paragraph (max 500 chars by convention). Used as the App Store / Play Store "What's new" text. Falls back to `Ditto vX.Y.Z` if the section has no summary paragraph.
|
||||
|
||||
**Downstream consumers** all pull from the `release-notes` job via `needs:`:
|
||||
|
||||
| Consumer | Job | Artifact used |
|
||||
|---|---|---|
|
||||
| GitLab Release description | `release` | `release-notes.md` |
|
||||
| App Store "What's New" | `publish-app-store` | `release-notes-summary.txt` → copied to `ios/fastlane/metadata/en-US/release_notes.txt` → uploaded by `deliver` |
|
||||
| Play Store "What's new" | `publish-google-play` | `release-notes-summary.txt` → copied to `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` → uploaded by `supply` |
|
||||
| In-app toast | `src/components/VersionCheck.tsx` (runtime) | Re-parses `public/CHANGELOG.md` via `parseChangelog()` and reads `entry.summary` (with a fallback to the legacy first-bullet behavior) |
|
||||
|
||||
**The summary format** is documented in the `release` skill — a single plaintext paragraph immediately under the `## [X.Y.Z] - YYYY-MM-DD` heading, before any `### Category`. The script enforces nothing on the parser side; CI emits a warning when the summary exceeds 500 chars but does not fail the build.
|
||||
|
||||
**To preview locally** what each storefront will receive:
|
||||
|
||||
```bash
|
||||
node scripts/extract-release-notes.mjs vX.Y.Z # full GitLab Release body
|
||||
node scripts/extract-release-notes.mjs vX.Y.Z --summary # storefront blurb
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: file-uploads
|
||||
description: Upload files (images, media, attachments) from the browser to a Blossom server via the useUploadFile hook, and attach them to Nostr events with NIP-94 imeta tags.
|
||||
---
|
||||
|
||||
# File Uploads on Nostr
|
||||
|
||||
This project includes a `useUploadFile` hook that uploads files to Blossom servers and returns NIP-94-compatible tags. Use it whenever a feature needs to accept a user-provided file (avatars, banners, post attachments, etc.).
|
||||
|
||||
## The `useUploadFile` Hook
|
||||
|
||||
```tsx
|
||||
import { useUploadFile } from "@/hooks/useUploadFile";
|
||||
|
||||
function MyComponent() {
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
// Returns an array of NIP-94-compatible tags.
|
||||
// The first tag is the `url` tag; its second element is the file URL.
|
||||
const tags = await uploadFile(file);
|
||||
const url = tags[0][1];
|
||||
// ...use the url
|
||||
} catch (error) {
|
||||
// ...handle errors (show a toast, etc.)
|
||||
}
|
||||
};
|
||||
|
||||
// ...rest of component
|
||||
}
|
||||
```
|
||||
|
||||
The hook is a TanStack Query mutation, so `isPending` can drive loading UI and `mutateAsync` integrates cleanly with `async`/`await` flows.
|
||||
|
||||
## Attaching Files to Events
|
||||
|
||||
### Kind 0 (profile metadata)
|
||||
|
||||
Use the plain URL in the relevant JSON field:
|
||||
|
||||
```ts
|
||||
const tags = await uploadFile(file);
|
||||
const url = tags[0][1];
|
||||
|
||||
createEvent({
|
||||
kind: 0,
|
||||
content: JSON.stringify({ ...existingMetadata, picture: url }),
|
||||
});
|
||||
```
|
||||
|
||||
### Kind 1 (text notes) and other content events
|
||||
|
||||
Append the URL to `content`, and add one `imeta` tag per file. `imeta` carries the NIP-94 metadata (mime type, dimensions, blurhash, etc.) that the uploader returned:
|
||||
|
||||
```ts
|
||||
const tags = await uploadFile(file); // e.g. [["url", "https://..."], ["m", "image/png"], ["dim", "1024x768"], ...]
|
||||
const url = tags[0][1];
|
||||
|
||||
// Flatten the NIP-94 tags into a single imeta tag value.
|
||||
const imeta = tags.map(([name, value]) => `${name} ${value}`);
|
||||
|
||||
createEvent({
|
||||
kind: 1,
|
||||
content: `Check this out ${url}`,
|
||||
tags: [["imeta", ...imeta]],
|
||||
});
|
||||
```
|
||||
|
||||
Repeat the pattern (one `imeta` tag per file) for multiple attachments.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- **Avatar / banner pickers:** wrap an `<input type="file" accept="image/*">` and call `uploadFile` on change; on success, update the relevant profile field and publish a kind 0 event.
|
||||
- **Post composers:** call `uploadFile` for each selected file before publishing the note, then build `imeta` tags alongside `content`.
|
||||
- **Progress UI:** use `isPending` from the mutation to disable the submit button and show a spinner or skeleton.
|
||||
- **Error handling:** wrap `uploadFile` in `try/catch` and surface failures via `useToast` — network and Blossom-server errors are common and should never break the UI.
|
||||
|
||||
## Constraints
|
||||
|
||||
- The hook requires a logged-in user (Blossom auth is signed by the user's signer). Guard uploads behind `useCurrentUser`.
|
||||
- Don't store or display raw `File` objects after upload — always use the returned URL.
|
||||
- Large files may take time; prefer `mutateAsync` over `mutate` so the caller can `await` completion before publishing an event that references the URL.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: git-workflow
|
||||
description: Ditto's git conventions — validating changes before committing, writing commit messages that match project style, and attributing regressions with a Regression-of trailer so the release changelog skill can filter them from the "Fixed" section.
|
||||
---
|
||||
|
||||
# Git Workflow
|
||||
|
||||
Ditto expects every completed task to end with a git commit. This skill covers the pre-commit validation loop, commit-message conventions, and the `Regression-of:` trailer used by the release skill to filter intra-release regressions from the changelog.
|
||||
|
||||
## Pre-commit Validation
|
||||
|
||||
**Your task is not finished until the code type-checks and builds without errors.** In priority order:
|
||||
|
||||
1. **Type Checking** (required) — `tsc --noEmit`
|
||||
2. **Building/Compilation** (required) — `vite build`
|
||||
3. **Linting** (recommended; fix anything critical) — `eslint`
|
||||
4. **Tests** (if available) — `vitest run`
|
||||
5. **Git commit** (required)
|
||||
|
||||
The full `npm run test` script runs all of these in sequence; running it is equivalent to steps 1–4.
|
||||
|
||||
## Using Git
|
||||
|
||||
Use `git status` and `git diff` to review changes, and `git log` to learn the project's commit-message conventions before writing a new one. If you make a mistake, `git checkout` restores files.
|
||||
|
||||
When your changes are complete and validated, create a commit with a message that focuses on **why** the change was made (not just **what**). Summaries should fit on one line; a body is warranted for non-trivial changes.
|
||||
|
||||
**Always commit when you are finished making changes. Non-negotiable — every completed task ends with a commit. Don't leave uncommitted changes.**
|
||||
|
||||
## Contributing Guide
|
||||
|
||||
When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing.
|
||||
|
||||
## Attributing Regressions
|
||||
|
||||
When a commit fixes a bug that was introduced by an identifiable prior commit, add a `Regression-of:` trailer at the bottom of the commit message body referencing the offending commit's short SHA:
|
||||
|
||||
```
|
||||
Fix missing background on expanded emoji picker in feeds
|
||||
|
||||
The compose box overhaul accidentally dropped the bg-background class
|
||||
when refactoring the picker out of QuickReactMenu.
|
||||
|
||||
Regression-of: 3aa08ba9
|
||||
```
|
||||
|
||||
This is a standard Git trailer (compatible with `git interpret-trailers`) that records the cause-and-effect link directly in history. It is consumed by the `release` skill to detect intra-release regressions and exclude them from the changelog's "Fixed" section, and it makes future debugging and post-mortems substantially faster.
|
||||
|
||||
### When to add it
|
||||
|
||||
- The commit fixes a bug (not a new feature, refactor, or doc change).
|
||||
- The introducing commit is identifiable with reasonable effort.
|
||||
|
||||
### When to skip it
|
||||
|
||||
- The bug is pre-existing with no clear single origin.
|
||||
- The behavior was always wrong (no regression).
|
||||
- The introducing commit cannot be determined after a brief search.
|
||||
|
||||
### Finding the introducing commit
|
||||
|
||||
- `git log -S '<removed-or-changed-string>'` — find commits that touched a specific string.
|
||||
- `git log --oneline -- path/to/file` — list all commits touching a file.
|
||||
- `git blame -L <start>,<end> -- path/to/file` — find who last changed specific lines.
|
||||
|
||||
This convention is **strongly recommended but not required.** When the origin is non-obvious, prioritize shipping the fix over hunting indefinitely.
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: mac-runner
|
||||
description: Operate the self-hosted GitLab Runner on the Mac that builds Ditto's iOS IPA. Covers SSH access, restarting the runner, viewing logs, updating Xcode, debugging fastlane locally, and rotating match certificates.
|
||||
---
|
||||
|
||||
# Mac Runner Operations
|
||||
|
||||
Ditto's iOS pipeline runs two CI jobs on a self-hosted GitLab Runner on a MacBook in the rack: `build-ipa` (signs and builds the IPA via Xcode + fastlane match) and `publish-app-store` (uploads the IPA via `fastlane deliver`, which shells out to Apple's iTMSTransporter — that tool only ships inside Xcode, so this job can't run on Linux). This skill covers operating the Mac.
|
||||
|
||||
This skill covers operating the runner: SSH access, restarting after crashes or Xcode updates, watching logs, debugging fastlane locally, and rotating the match certificates. For initial provisioning, App Store Connect API key creation, and GitLab CI variable setup, load the **`ci-cd-publishing`** skill.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Command |
|
||||
|---|---|
|
||||
| SSH in | `ssh alex@alexs-air.lan` |
|
||||
| Runner status | `gitlab-runner status` |
|
||||
| Restart runner | `gitlab-runner restart` (after `eval "$(/opt/homebrew/bin/brew shellenv)"`) |
|
||||
| Stdout log | `tail -f ~/gitlab-runner.out.log` |
|
||||
| Stderr log | `tail -f ~/gitlab-runner.err.log` |
|
||||
| Runner config | `~/.gitlab-runner/config.toml` |
|
||||
| LaunchAgent plist | `~/Library/LaunchAgents/gitlab-runner.plist` |
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Host**: `alexs-air.lan` (Apple Silicon MacBook, macOS 26+, Xcode 26+)
|
||||
- **User**: `alex` (the runner runs in user-mode so it can access keychain and Xcode UI tooling)
|
||||
- **Tooling**: Homebrew (`/opt/homebrew`), `gitlab-runner`, `node@22`, `ruby@3.3`, fastlane installed as a user gem under `~/.gem/ruby/3.3.0/`
|
||||
- **Service**: launchd LaunchAgent at `~/Library/LaunchAgents/gitlab-runner.plist`. `KeepAlive=true` (auto-restart on crash) and `RunAtLoad=true` (starts on login). The agent loads when `alex` logs in via auto-login at boot.
|
||||
- **Tags**: `macos`, `ios`, `xcode` — both `build-ipa` and `publish-app-store` in `.gitlab-ci.yml` target this runner. `publish-app-store` doesn't sign anything, but it still needs Xcode's bundled iTMSTransporter to push the IPA to App Store Connect.
|
||||
- **Shell setup**: `~/.bash_profile` sources brew shellenv and prepends `~/.gem/ruby/3.3.0/bin` and `/opt/homebrew/opt/ruby@3.3/bin` to `PATH` so `bash --login` (the runner's executor) finds fastlane + ruby 3.3.
|
||||
|
||||
### Why Ruby 3.3, not the brewed 4.0
|
||||
|
||||
Brewed `fastlane` (current version) ships running on Ruby 4.0 from `brew install ruby`. Ruby 4.0's OpenSSL bindings hit fastlane bug [#20553](https://github.com/fastlane/fastlane/issues/20553) — `OpenSSL::PKey::EC.new(pem)` raises "invalid curve name" for `prime256v1` keys, which breaks every App Store Connect API key signing operation. Ruby 3.3.x doesn't have this bug. So we install fastlane via `gem install fastlane --user-install` on `ruby@3.3` instead of `brew install fastlane`.
|
||||
|
||||
### Why IPv6 is disabled on Wi-Fi
|
||||
|
||||
`networksetup -setv6off Wi-Fi` is set because Ruby's net/http on this machine attempted IPv6 to `rubygems.org` first and timed out (~30 s per request). Disabling IPv6 on the Wi-Fi interface forces IPv4 immediately. To re-enable: `sudo networksetup -setv6automatic Wi-Fi`.
|
||||
|
||||
## Verifying the runner is healthy
|
||||
|
||||
From any machine:
|
||||
|
||||
```bash
|
||||
curl -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/runners/53111580" \
|
||||
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d['status'], d['online'])"
|
||||
```
|
||||
|
||||
Expected: `online True`. If `offline` or `not_connected`, SSH in and check:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
gitlab-runner status
|
||||
ps aux | grep gitlab-runner
|
||||
tail -50 ~/gitlab-runner.err.log
|
||||
```
|
||||
|
||||
## Restarting the runner
|
||||
|
||||
After a Mac reboot, the runner should start automatically via the LaunchAgent. To restart manually:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
gitlab-runner restart
|
||||
```
|
||||
|
||||
If `gitlab-runner restart` reports "service not installed", reinstall:
|
||||
|
||||
```bash
|
||||
gitlab-runner install
|
||||
gitlab-runner start
|
||||
```
|
||||
|
||||
This rewrites the LaunchAgent plist.
|
||||
|
||||
## Watching a CI job run live
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan 'tail -f ~/gitlab-runner.out.log'
|
||||
```
|
||||
|
||||
The runner streams build output to stdout. The same output appears in the GitLab job UI.
|
||||
|
||||
## Updating Xcode
|
||||
|
||||
After a major Xcode update:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
sudo xcodebuild -license accept # accept the new license non-interactively
|
||||
xcode-select --install # ensure command-line tools are present
|
||||
xcodebuild -version # confirm version
|
||||
```
|
||||
|
||||
Then trigger a no-op tag rebuild (e.g. cut a patch release) to verify the runner still works.
|
||||
|
||||
## Debugging fastlane locally
|
||||
|
||||
If `build-ipa` fails in CI, reproduce on the Mac. The env vars below mirror what CI sets up:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
cd ~/Projects/ditto
|
||||
git pull origin main
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
|
||||
# Match what CI provides
|
||||
export CI_COMMIT_TAG=v2.x.y
|
||||
export CI_PIPELINE_IID=99999
|
||||
export MATCH_PASSWORD='<from GitLab CI variables>'
|
||||
export MATCH_GIT_BASIC_AUTHORIZATION='<base64 of ci-readonly:gldt-...>'
|
||||
export APP_STORE_CONNECT_API_KEY_ID=<key-id>
|
||||
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<issuer-id>
|
||||
export ASC_KEY_PATH=~/.private_keys/AuthKey_<key-id>.p8
|
||||
|
||||
# Build web assets and sync to Capacitor iOS project (CI does this in before_script)
|
||||
npm ci
|
||||
npx vite build -l error
|
||||
cp dist/index.html dist/404.html
|
||||
npx cap sync ios
|
||||
node scripts/patch-cap-config.mjs
|
||||
|
||||
# Stamp marketing version (CI does this in script)
|
||||
VERSION="${CI_COMMIT_TAG#v}"
|
||||
sed -i '' "s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g" ios/App/App.xcodeproj/project.pbxproj
|
||||
|
||||
# Run the build lane
|
||||
cd ios
|
||||
fastlane build_ipa
|
||||
```
|
||||
|
||||
This produces the IPA at `../artifacts/Ditto.ipa` exactly like CI. Add `--verbose` for detailed output.
|
||||
|
||||
To also test the submission step end-to-end (this calls Apple, so be ready to "Remove from Review" in App Store Connect afterward):
|
||||
|
||||
```bash
|
||||
export IPA_PATH="$HOME/Projects/ditto/artifacts/Ditto.ipa"
|
||||
fastlane submit_release
|
||||
```
|
||||
|
||||
Or, to debug *just* the submission against an already-uploaded build without rebuilding, use the `submit_only` lane (see "Debugging App Store submission with the `submit_only` lane" below).
|
||||
|
||||
## Rotating match certificates (yearly)
|
||||
|
||||
Apple distribution certs expire one year after issuance. To renew:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
cd ~/Projects/ditto/ios
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
|
||||
# Set Apple credentials (API key path)
|
||||
export MATCH_PASSWORD='<from GitLab CI variables>'
|
||||
|
||||
# Revoke the expiring cert in Apple's portal and remove from the match repo
|
||||
fastlane match nuke distribution
|
||||
|
||||
# Issue a new cert, generate a new App Store profile, encrypt, commit, push
|
||||
fastlane match appstore \
|
||||
--api_key_path ~/.private_keys/AuthKey_<KEY_ID>.p8 \
|
||||
--api_key_id <KEY_ID> \
|
||||
--api_issuer_id <ISSUER_ID>
|
||||
```
|
||||
|
||||
CI's next tag run picks up the new files via `match(... readonly: true)`. No GitLab variables to update.
|
||||
|
||||
## Debugging App Store submission with the `submit_only` lane
|
||||
|
||||
The `Fastfile` exposes a second lane, `submit_only`, that skips build/archive/upload and just runs `deliver` against an already-uploaded build. Useful when the binary is fine but the metadata/submission step is failing — iterate in ~30 seconds instead of waiting for a full ~6-minute CI build.
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
export PATH="$HOME/.gem/ruby/3.3.0/bin:/opt/homebrew/opt/ruby@3.3/bin:$PATH"
|
||||
cd ~/Projects/ditto/ios
|
||||
|
||||
# Make sure the .p8 is on disk; CI's after_script wipes it after each job
|
||||
scp $LAPTOP:/path/to/AuthKey_<KEY_ID>.p8 ~/.private_keys/
|
||||
|
||||
export ASC_KEY_PATH=$HOME/.private_keys/AuthKey_<KEY_ID>.p8
|
||||
export APP_STORE_CONNECT_API_KEY_ID=<KEY_ID>
|
||||
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<ISSUER_ID>
|
||||
export BUILD_NUMBER=<existing-build-number-on-ASC>
|
||||
export VERSION=<marketing-version, e.g. 2.14.3>
|
||||
|
||||
fastlane submit_only
|
||||
```
|
||||
|
||||
The lane expects the version to exist in App Store Connect with a `VALID` build attached. It uploads metadata (`./fastlane/metadata/en-US/release_notes.txt`) and calls `submit_for_review`. If Apple rejects, fix the Fastfile, re-run — no rebuild needed.
|
||||
|
||||
If Apple has already accepted the submission for that version, you'll need to "Remove from Review" in App Store Connect (only available while state is `WAITING_FOR_REVIEW`, not `IN_REVIEW`) before re-running, or bump the build number.
|
||||
|
||||
## Inspecting App Store Connect state directly
|
||||
|
||||
When fastlane's error messages aren't enough, query Apple's API directly. There's no installed CLI — use the JWT signing recipe Apple documents. A working Ruby snippet lives in this skill's troubleshooting history; the short version:
|
||||
|
||||
```ruby
|
||||
require "json"; require "openssl"; require "net/http"; require "base64"
|
||||
key_pem = File.read(ENV["ASC_KEY_PATH"])
|
||||
ec = OpenSSL::PKey::EC.new(key_pem)
|
||||
header = { alg: "ES256", kid: ENV["APP_STORE_CONNECT_API_KEY_ID"], typ: "JWT" }
|
||||
payload = { iss: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], iat: Time.now.to_i, exp: Time.now.to_i + 1200, aud: "appstoreconnect-v1" }
|
||||
def b64(s); Base64.urlsafe_encode64(s, padding: false); end
|
||||
si = b64(JSON.generate(header)) + "." + b64(JSON.generate(payload))
|
||||
sig_der = ec.sign(OpenSSL::Digest::SHA256.new, si)
|
||||
asn = OpenSSL::ASN1.decode(sig_der)
|
||||
r = asn.value[0].value.to_s(2); s = asn.value[1].value.to_s(2)
|
||||
r = ("\x00".b * (32 - r.bytesize)) + r if r.bytesize < 32
|
||||
s = ("\x00".b * (32 - s.bytesize)) + s if s.bytesize < 32
|
||||
jwt = si + "." + b64(r + s)
|
||||
# Now: GET https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]=pub.ditto.app
|
||||
# with header Authorization: Bearer <jwt>
|
||||
```
|
||||
|
||||
Useful endpoints:
|
||||
- `GET /v1/apps?filter[bundleId]=pub.ditto.app` → app id
|
||||
- `GET /v1/apps/<id>/appStoreVersions` → version list with `appStoreState`
|
||||
- `GET /v1/apps/<id>/builds?sort=-uploadedDate` → recent builds and processing state
|
||||
- `GET /v1/appStoreVersions/<id>/appStoreVersionLocalizations` → release notes (`whatsNew`)
|
||||
|
||||
## What can go wrong
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Runner shows offline in GitLab | Mac rebooted, auto-login disabled, or LaunchAgent unloaded | SSH in, `gitlab-runner status`, `gitlab-runner restart` |
|
||||
| Build fails: "unable to find Xcode" | Xcode auto-updated and changed path, or command-line tools missing | `xcode-select --install`, `sudo xcodebuild -license accept` |
|
||||
| Build fails: "no signing certificate found" | match cert expired, was revoked manually, or `MATCH_PASSWORD` mismatched | Run yearly rotation procedure above |
|
||||
| Build fails: keychain locked / "User interaction is not allowed" | `setup_ci` failed to create the temporary keychain | Verify `FASTLANE_KEYCHAIN_PASSWORD` is set in GitLab CI variables |
|
||||
| Build fails: ASC API key invalid | Key was revoked or rotated | Generate a new key and update `APP_STORE_CONNECT_API_KEY_*` variables |
|
||||
| "Build already exists" from `deliver` | Previous tag's IPA had the same `CFBundleVersion`; fastlane's `increment_build_number` didn't bump because the value already matched `CI_PIPELINE_IID` | Push a new tag (each new tag has a new pipeline ID) |
|
||||
| Apple precheck rejects metadata | Encryption export compliance, IDFA, content rights flags don't match `Fastfile` | Update `submission_information` in `ios/fastlane/Fastfile` |
|
||||
| `OpenSSL::PKey::PKeyError: invalid curve name` | fastlane is running on brewed Ruby 4.0, which has a broken OpenSSL EC parser ([fastlane#20553](https://github.com/fastlane/fastlane/issues/20553)) | Use `ruby@3.3` from brew and install fastlane as a user gem (`gem install fastlane --user-install`); ensure `~/.bash_profile` puts `~/.gem/ruby/3.3.0/bin` on PATH ahead of `/opt/homebrew/bin` |
|
||||
| `gem install` / `bundle install` hangs for >30s per request | Ruby's net/http tries IPv6 to rubygems.org and times out on this network | `sudo networksetup -setv6off Wi-Fi` (per-interface, persistent until reboot) |
|
||||
| `Unresolved conflict between options: 'api_key_path' and 'api_key'` | `app_store_connect_api_key` action sets `APP_STORE_CONNECT_API_KEY_PATH` env var (path to `.p8`), match's same-named env var expects a JSON descriptor | Build the API key hash inline in the Fastfile (don't call `app_store_connect_api_key`); read `.p8` from a non-conflicting var like `ASC_KEY_PATH` |
|
||||
| `[match] Could not find the newly generated certificate installed` when running match interactively on macOS 26+ | [fastlane#15185](https://github.com/fastlane/fastlane/issues/15185) — the new-cert verification step trips on partition list and keychain trust | Run cert generation **in CI** via the bootstrap procedure in the `ci-cd-publishing` skill (uses `setup_ci`'s ephemeral keychain). Don't run `fastlane match appstore` interactively. |
|
||||
| iOS build fails: `No "iOS Development" signing certificate matching team ID` | The Xcode project uses `CODE_SIGN_STYLE=Automatic`; xcodebuild tries to find a Development cert even for Release builds | Override via `xcargs: "CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' PROVISIONING_PROFILE_SPECIFIER='match AppStore <bundle-id>' DEVELOPMENT_TEAM=<team>"` in the Fastfile (already configured) |
|
||||
| `vite.config.ts: Unexpected token 'c', "concurrent"... is not valid JSON` | GitLab Runner sets `CONFIG_FILE=/Users/alex/.gitlab-runner/config.toml` in the job environment, which collides with vite's `process.env.CONFIG_FILE ?? "./ditto.json"` lookup | Already fixed: use `DITTO_CONFIG_FILE` for the override env var |
|
||||
| `whatsNew is missing` from `submit_for_review` | `metadata_path: "./metadata"` resolves relative to fastlane's cwd (`ios/`), not its config dir (`ios/fastlane/`); fastlane silently uploads zero locales | Use `metadata_path: "./fastlane/metadata"` (already configured) |
|
||||
| `appStoreVersions ... is not in valid state` | Apple won't accept submission because the version is past `PREPARE_FOR_SUBMISSION` (already submitted, in review, or shipped) | "Remove from Review" in App Store Connect if `WAITING_FOR_REVIEW`, or cut a new version |
|
||||
| `An attribute value is not acceptable for the current resource state. - contentRightsDeclaration` | Apple rejects PATCH on locked App-level fields when `submission_information` includes `content_rights_*` | Drop `content_rights_*` from `submission_information` in the Fastfile (already configured) |
|
||||
|
||||
## When the Mac dies
|
||||
|
||||
1. Get a replacement Mac. Install Xcode from the App Store.
|
||||
2. Run the **`ci-cd-publishing`** skill's "Initial setup" — but skip the App Store Connect API key step (you already have it). Re-register the runner with the same `macos` tag.
|
||||
3. Restore signing identity: `cd ditto/ios && fastlane match appstore --readonly` decrypts the existing certs/profiles using `MATCH_PASSWORD`.
|
||||
4. No reissuance, no revocation, no GitLab variable updates needed. The certificates repo is the source of truth.
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: nip19-routing
|
||||
description: Implement or populate the root-level NIP-19 router (/:nip19) that handles npub, nprofile, note, nevent, and naddr identifiers. Covers decoding, secure filter construction, and type-specific rendering for profiles, notes, events, and addressable events.
|
||||
---
|
||||
|
||||
# NIP-19 Identifier Routing
|
||||
|
||||
NIP-19 defines the bech32-encoded identifiers used throughout Nostr (`npub1...`, `note1...`, `naddr1...`, etc.). This project routes all of them through a single root-level page at `/:nip19`, implemented by `src/pages/NIP19Page.tsx`.
|
||||
|
||||
Use this skill when the user wants to populate the `NIP19Page` sections with real views, add a new identifier type, or build links that point into the Nostr routing system.
|
||||
|
||||
## Identifier Reference
|
||||
|
||||
| Prefix | Payload | Use when… |
|
||||
|--------------|------------------------------------------------------------------|--------------------------------------------------------------|
|
||||
| `npub1` | 32-byte public key | Simple user reference |
|
||||
| `nprofile1` | Public key + optional relay hints + petname | User reference with relay context |
|
||||
| `note1` | 32-byte event ID (kind:1 text notes only, per NIP-10) | Referencing a short text note/thread |
|
||||
| `nevent1` | Event ID + optional relay hints + author pubkey + kind | Any event kind, or notes where you need relay/author context |
|
||||
| `naddr1` | `kind` + `pubkey` + `identifier` (`d` tag) + optional relay hints | Addressable events (kind 30000-39999): articles, products |
|
||||
| `nsec1` | Private key | **Never display or route** — treat as a 404 |
|
||||
| `nrelay1` | Relay URL | Deprecated |
|
||||
|
||||
### `note1` vs `nevent1`
|
||||
|
||||
- `note1` carries only an event ID, and is canonically tied to kind:1 text notes.
|
||||
- `nevent1` can reference **any** kind and can carry relay hints + author pubkey. Prefer `nevent1` for non-kind-1 events or when you want to ship relay hints with a link.
|
||||
|
||||
### `npub1` vs `nprofile1`
|
||||
|
||||
- `npub1` is just a pubkey.
|
||||
- `nprofile1` adds relay hints and a petname. Prefer it for shareable profile links where discoverability matters.
|
||||
|
||||
## Routing Rules
|
||||
|
||||
1. **All NIP-19 identifiers are handled at the URL root**: `/:nip19` in `AppRouter.tsx`. Never nest them under paths like `/note/:id` or `/profile/:npub`.
|
||||
2. **Invalid, vacant, or unsupported identifiers** (including `nsec1` and `nrelay1`) render the 404 page. The `NIP19Page` boilerplate already handles this.
|
||||
3. **Addressable event URLs must include the author**. `naddr1` already encodes `pubkey` + `kind` + `identifier`, which is exactly what a secure query filter needs. If you ever design an alternative URL, use the shape `/:npub/:dtag`, never `/:dtag` alone — otherwise anyone can publish a conflicting event with the same `d` tag.
|
||||
|
||||
## Decoding and Filtering
|
||||
|
||||
Nostr relay filters only accept hex strings. Always decode the NIP-19 identifier before building a filter.
|
||||
|
||||
```ts
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
const decoded = nip19.decode(value); // throws on invalid input
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub': {
|
||||
const pubkey = decoded.data; // hex string
|
||||
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'nprofile': {
|
||||
const { pubkey /*, relays */ } = decoded.data;
|
||||
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'note': {
|
||||
const id = decoded.data;
|
||||
return nostr.query([{ ids: [id], kinds: [1], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'nevent': {
|
||||
const { id /*, relays, author, kind */ } = decoded.data;
|
||||
return nostr.query([{ ids: [id], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'naddr': {
|
||||
const { kind, pubkey, identifier } = decoded.data;
|
||||
return nostr.query([{
|
||||
kinds: [kind],
|
||||
authors: [pubkey], // critical: prevents d-tag spoofing
|
||||
'#d': [identifier],
|
||||
limit: 1,
|
||||
}]);
|
||||
}
|
||||
|
||||
default:
|
||||
// nsec, nrelay, unknown → 404
|
||||
throw new Error('Unsupported Nostr identifier');
|
||||
}
|
||||
```
|
||||
|
||||
### Common mistakes
|
||||
|
||||
```ts
|
||||
// ❌ Passing bech32 into a filter
|
||||
nostr.query([{ ids: [naddr] }]);
|
||||
|
||||
// ❌ Addressable lookup without the author — anyone can spoof the d-tag
|
||||
nostr.query([{ kinds: [30023], '#d': [slug] }]);
|
||||
|
||||
// ✅ Decode first, then include author
|
||||
const { kind, pubkey, identifier } = nip19.decode(naddr).data;
|
||||
nostr.query([{ kinds: [kind], authors: [pubkey], '#d': [identifier] }]);
|
||||
```
|
||||
|
||||
## Populating `NIP19Page`
|
||||
|
||||
`src/pages/NIP19Page.tsx` already:
|
||||
|
||||
- Decodes `params.nip19` with `nip19.decode`.
|
||||
- Branches on `decoded.type` with a section for each supported identifier.
|
||||
- Redirects invalid / unsupported identifiers to the 404 page.
|
||||
- Provides a responsive container wrapper.
|
||||
|
||||
To turn it into a real router, replace each placeholder section with a concrete component:
|
||||
|
||||
| `decoded.type` | Typical view |
|
||||
|-----------------------|---------------------------------------------------------------|
|
||||
| `npub` / `nprofile` | Profile page: header from kind 0, feed of the user's events |
|
||||
| `note` | Single kind:1 text note with thread + replies |
|
||||
| `nevent` | Generic event renderer; branch on `kind` for specialized UIs |
|
||||
| `naddr` | Addressable-event view (article, product, community, etc.) |
|
||||
|
||||
Inside each branch, pass the decoded payload (not the raw bech32 string) to a child component. That keeps filter construction colocated with the fetching hook and removes any chance of a re-decode mismatch.
|
||||
|
||||
## Linking to NIP-19 Routes
|
||||
|
||||
When building links elsewhere in the app:
|
||||
|
||||
```tsx
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// To a profile
|
||||
<Link to={`/${nip19.npubEncode(pubkey)}`}>Profile</Link>
|
||||
|
||||
// To an addressable event (article, product, …)
|
||||
<Link to={`/${nip19.naddrEncode({ kind, pubkey, identifier, relays })}`}>
|
||||
Open
|
||||
</Link>
|
||||
|
||||
// To a specific event of any kind, with relay hints
|
||||
<Link to={`/${nip19.neventEncode({ id, relays, author, kind })}`}>Open</Link>
|
||||
```
|
||||
|
||||
Always encode with the **most specific** identifier you have context for (`nprofile` > `npub`, `nevent` > `note`, `naddr` for addressable). The extra metadata makes links more robust across relays.
|
||||
|
||||
## Security Recap
|
||||
|
||||
- Decode **before** querying.
|
||||
- For addressable events, always include `authors: [pubkey]` in the filter — the `d` tag alone is not a trust boundary.
|
||||
- Treat `nsec1` and any unknown/invalid identifier as 404. Never render, log, or echo a decoded `nsec`.
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: nip85-stats
|
||||
description: Fetch pre-computed engagement stats (follower count, post count, reply count, reaction count, zap amounts, etc.) for users, events, and addressable events via a NIP-85 Trusted Assertion provider. Provides useNip85UserStats, useNip85EventStats, and useNip85AddrStats hooks backed by a configurable provider pubkey in AppConfig.
|
||||
---
|
||||
|
||||
# NIP-85 Trusted Assertion Stats
|
||||
|
||||
[NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) defines "Trusted Assertions" — events published by a service provider that carry pre-computed stats (follower counts, reaction counts, zap totals, etc.) for users and events. Clients that would otherwise need to load thousands of events to compute these numbers can instead query a single addressable event from a trusted provider.
|
||||
|
||||
This skill adds three hooks — `useNip85UserStats`, `useNip85EventStats`, `useNip85AddrStats` — and a configurable `nip85StatsPubkey` field on `AppConfig` so you can swap providers.
|
||||
|
||||
## Kinds Used
|
||||
|
||||
| Kind | Subject | `d` tag value |
|
||||
| ----- | ---------------------------- | -------------------------- |
|
||||
| 30382 | User | user pubkey (hex) |
|
||||
| 30383 | Event (regular, kind 1 etc.) | event id (hex) |
|
||||
| 30384 | Addressable event | `<kind>:<pubkey>:<d-tag>` |
|
||||
|
||||
The hooks query one replaceable event at a time (`limit: 1`), filtered by `authors: [statsPubkey]` and `#d`. **Filtering by `authors` is required** — without it, anyone could publish a fake assertion with the same `d` tag and the client would accept it.
|
||||
|
||||
## Files Provided by This Skill
|
||||
|
||||
| Skill file | Copy to |
|
||||
|---|---|
|
||||
| `files/hooks/useNip85Stats.ts` | `src/hooks/useNip85Stats.ts` |
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Copy the Hooks File
|
||||
|
||||
Copy `.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts` into `src/hooks/useNip85Stats.ts`. It imports `@nostrify/react`, `@tanstack/react-query`, and `@/hooks/useAppContext`, all already present in the template.
|
||||
|
||||
### 2. Add `nip85StatsPubkey` to `AppConfig`
|
||||
|
||||
In `src/contexts/AppContext.ts`, add the field to the `AppConfig` interface:
|
||||
|
||||
```typescript
|
||||
export interface AppConfig {
|
||||
// ...existing fields...
|
||||
/** Hex pubkey of the NIP-85 Trusted Assertion provider. Empty = disabled. */
|
||||
nip85StatsPubkey: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update the Zod Schema in `AppProvider.tsx`
|
||||
|
||||
In `src/components/AppProvider.tsx`, add the field to `AppConfigSchema`:
|
||||
|
||||
```typescript
|
||||
const AppConfigSchema = z.object({
|
||||
// ...existing fields...
|
||||
nip85StatsPubkey: z.string().refine(
|
||||
(val) => val.length === 0 || /^[0-9a-f]{64}$/i.test(val),
|
||||
{ message: 'Must be empty or a 64-character hex pubkey' },
|
||||
),
|
||||
}) satisfies z.ZodType<AppConfig>;
|
||||
```
|
||||
|
||||
### 4. Set the Default in `App.tsx`
|
||||
|
||||
Pick a provider pubkey and add it to `defaultConfig`. The ditto.pub provider is a reasonable default:
|
||||
|
||||
```typescript
|
||||
const defaultConfig: AppConfig = {
|
||||
// ...existing fields...
|
||||
nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea',
|
||||
};
|
||||
```
|
||||
|
||||
Set to `''` to ship with stats disabled.
|
||||
|
||||
### 5. Update `TestApp.tsx`
|
||||
|
||||
In `src/test/TestApp.tsx`, add the field to the test default config. Use an empty string so tests don't hit a live provider:
|
||||
|
||||
```typescript
|
||||
const defaultConfig: AppConfig = {
|
||||
// ...existing fields...
|
||||
nip85StatsPubkey: '',
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### User stats (kind 30382)
|
||||
|
||||
```tsx
|
||||
import { useNip85UserStats } from '@/hooks/useNip85Stats';
|
||||
|
||||
function FollowerCount({ pubkey }: { pubkey: string }) {
|
||||
const { data: stats } = useNip85UserStats(pubkey);
|
||||
if (!stats) return null; // no provider configured or no assertion yet
|
||||
return <span>{stats.followers.toLocaleString()} followers</span>;
|
||||
}
|
||||
```
|
||||
|
||||
### Event stats (kind 30383)
|
||||
|
||||
```tsx
|
||||
import { useNip85EventStats } from '@/hooks/useNip85Stats';
|
||||
|
||||
function NoteStats({ eventId }: { eventId: string }) {
|
||||
const { data: stats } = useNip85EventStats(eventId);
|
||||
if (!stats) return null;
|
||||
return (
|
||||
<div className="flex gap-3 text-sm text-muted-foreground">
|
||||
<span>{stats.reactionCount} reactions</span>
|
||||
<span>{stats.repostCount} reposts</span>
|
||||
<span>{stats.commentCount} comments</span>
|
||||
<span>{stats.zapAmount} sats</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Addressable event stats (kind 30384)
|
||||
|
||||
The `addr` argument is the full NIP-01 event address `<kind>:<pubkey>:<d-tag>`:
|
||||
|
||||
```tsx
|
||||
import { useNip85AddrStats } from '@/hooks/useNip85Stats';
|
||||
|
||||
function ArticleStats({ kind, pubkey, identifier }: { kind: number; pubkey: string; identifier: string }) {
|
||||
const { data: stats } = useNip85AddrStats(`${kind}:${pubkey}:${identifier}`);
|
||||
if (!stats) return null;
|
||||
return <span>{stats.reactionCount} reactions</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior Notes
|
||||
|
||||
- **Graceful degradation:** The hooks return `null` (not an error) when `nip85StatsPubkey` is empty or the provider has no assertion for the subject. Always render defensively — NIP-85 is an optimization, not a source of truth.
|
||||
- **Short timeouts:** Each query is wrapped in a 2-second `AbortSignal.timeout` so a slow stats relay never blocks the UI.
|
||||
- **Cached by TanStack Query:** `staleTime` is 30s for event/addr stats and 60s for user stats. Results are keyed on `[kind, subject, statsPubkey]`, so swapping providers invalidates the cache automatically.
|
||||
- **Missing tags = 0:** A tag absent from the assertion is reported as `0` rather than `undefined`, matching NIP-85's "no data" semantics.
|
||||
- **Not the source of truth:** For interactive features (did *this* user like *this* post?) you still need to query the underlying reaction/zap/repost events. NIP-85 only provides aggregate counts.
|
||||
|
||||
## Extending the Stats
|
||||
|
||||
The hooks expose a small subset of the tags defined in NIP-85. To surface more (e.g. `zap_amt_sent`, `rank`, `first_created_at`), extend the return types and pull additional tags via `getIntTag`:
|
||||
|
||||
```typescript
|
||||
export interface Nip85UserStats {
|
||||
followers: number;
|
||||
postCount: number;
|
||||
rank: number; // new
|
||||
zapAmtReceived: number; // new
|
||||
}
|
||||
|
||||
// inside useNip85UserStats queryFn
|
||||
return {
|
||||
followers: getIntTag(tags, 'followers'),
|
||||
postCount: getIntTag(tags, 'post_cnt'),
|
||||
rank: getIntTag(tags, 'rank'),
|
||||
zapAmtReceived: getIntTag(tags, 'zap_amt_recd'),
|
||||
};
|
||||
```
|
||||
|
||||
See the full tag table in [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md).
|
||||
|
||||
## Exposing a Provider Picker (Optional)
|
||||
|
||||
If you want the user to change providers at runtime, add an input bound to `config.nip85StatsPubkey` and call `updateConfig` with a validated 64-char hex value:
|
||||
|
||||
```tsx
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
function StatsProviderInput() {
|
||||
const { config, updateConfig } = useAppContext();
|
||||
return (
|
||||
<input
|
||||
value={config.nip85StatsPubkey}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim().toLowerCase();
|
||||
if (v === '' || /^[0-9a-f]{64}$/.test(v)) {
|
||||
updateConfig(() => ({ nip85StatsPubkey: v }));
|
||||
}
|
||||
}}
|
||||
placeholder="64-char hex pubkey (blank to disable)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Related NIPs
|
||||
|
||||
- [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) — Trusted Assertions (this skill)
|
||||
- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — Addressable event addressing (`<kind>:<pubkey>:<d-tag>`)
|
||||
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Zaps (the underlying events `zap_amount` / `zap_cnt` aggregate)
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
/** Engagement counts exposed by NIP-85 kind 30383 (events) and 30384 (addressable events). */
|
||||
export interface Nip85EventStats {
|
||||
commentCount: number;
|
||||
repostCount: number;
|
||||
reactionCount: number;
|
||||
zapCount: number;
|
||||
/** Zap amount in sats. */
|
||||
zapAmount: number;
|
||||
}
|
||||
|
||||
/** A subset of NIP-85 kind 30382 (user) stats — extend as needed. */
|
||||
export interface Nip85UserStats {
|
||||
followers: number;
|
||||
postCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an integer tag value from a NIP-85 assertion event. Returns 0 when missing
|
||||
* or unparseable, which mirrors the semantics of "no data" in NIP-85.
|
||||
*/
|
||||
function getIntTag(tags: string[][], tagName: string): number {
|
||||
const tag = tags.find(([name]) => name === tagName);
|
||||
if (!tag?.[1]) return 0;
|
||||
const n = parseInt(tag[1], 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches NIP-85 event stats (kind 30383) from the configured stats pubkey.
|
||||
* Returns `null` when no stats pubkey is configured or the provider has no
|
||||
* assertion for this event.
|
||||
*/
|
||||
export function useNip85EventStats(eventId: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
|
||||
return useQuery<Nip85EventStats | null>({
|
||||
queryKey: ['nip85-event-stats', eventId, statsPubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!eventId || !statsPubkey) return null;
|
||||
|
||||
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [30383], authors: [statsPubkey], '#d': [eventId], limit: 1 }],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const { tags } = events[0];
|
||||
return {
|
||||
commentCount: getIntTag(tags, 'comment_cnt'),
|
||||
repostCount: getIntTag(tags, 'repost_cnt'),
|
||||
reactionCount: getIntTag(tags, 'reaction_cnt'),
|
||||
zapCount: getIntTag(tags, 'zap_cnt'),
|
||||
zapAmount: getIntTag(tags, 'zap_amount'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!eventId && !!statsPubkey,
|
||||
staleTime: 30 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches NIP-85 user stats (kind 30382) from the configured stats pubkey.
|
||||
* Returns `null` when no stats pubkey is configured or the provider has no
|
||||
* assertion for this pubkey.
|
||||
*/
|
||||
export function useNip85UserStats(pubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
|
||||
return useQuery<Nip85UserStats | null>({
|
||||
queryKey: ['nip85-user-stats', pubkey, statsPubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!pubkey || !statsPubkey) return null;
|
||||
|
||||
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [30382], authors: [statsPubkey], '#d': [pubkey], limit: 1 }],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const { tags } = events[0];
|
||||
return {
|
||||
followers: getIntTag(tags, 'followers'),
|
||||
postCount: getIntTag(tags, 'post_cnt'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!pubkey && !!statsPubkey,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches NIP-85 addressable event stats (kind 30384) from the configured
|
||||
* stats pubkey. The `addr` argument is the full NIP-01 event address string,
|
||||
* e.g. `30023:<pubkey>:<d-tag>`.
|
||||
*/
|
||||
export function useNip85AddrStats(addr: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
|
||||
return useQuery<Nip85EventStats | null>({
|
||||
queryKey: ['nip85-addr-stats', addr, statsPubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!addr || !statsPubkey) return null;
|
||||
|
||||
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [30384], authors: [statsPubkey], '#d': [addr], limit: 1 }],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const { tags } = events[0];
|
||||
return {
|
||||
commentCount: getIntTag(tags, 'comment_cnt'),
|
||||
repostCount: getIntTag(tags, 'repost_cnt'),
|
||||
reactionCount: getIntTag(tags, 'reaction_cnt'),
|
||||
zapCount: getIntTag(tags, 'zap_cnt'),
|
||||
zapAmount: getIntTag(tags, 'zap_amount'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!addr && !!statsPubkey,
|
||||
staleTime: 30 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
---
|
||||
name: nostr-direct-messages
|
||||
description: Implement Nostr direct messaging features, build chat interfaces, or work with encrypted peer-to-peer communication (NIP-04 and NIP-17).
|
||||
---
|
||||
|
||||
# Direct Messaging on Nostr
|
||||
|
||||
This project includes a complete direct messaging system supporting both NIP-04 (legacy) and NIP-17 (modern, more private) encrypted messages with real-time subscriptions, optimistic updates, and a persistent cache-first local storage.
|
||||
|
||||
**The DM system is not enabled by default** - follow the setup instructions below to add messaging functionality to your application.
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Add DMProvider to Your App
|
||||
|
||||
First, add the `DMProvider` to your app's provider tree in `src/App.tsx`:
|
||||
|
||||
```tsx
|
||||
// Add these imports at the top of src/App.tsx
|
||||
import { DMProvider, type DMConfig } from '@/components/DMProvider';
|
||||
import { PROTOCOL_MODE } from '@/lib/dmConstants';
|
||||
|
||||
// Add this configuration before your App component
|
||||
const dmConfig: DMConfig = {
|
||||
// Enable or disable DMs entirely
|
||||
enabled: true, // Set to true to enable messaging functionality
|
||||
|
||||
// Choose one protocol mode:
|
||||
// PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only
|
||||
// PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only
|
||||
// PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17)
|
||||
protocolMode: PROTOCOL_MODE.NIP17_ONLY, // Recommended for new apps
|
||||
};
|
||||
|
||||
// Then wrap your app components with DMProvider:
|
||||
export function App() {
|
||||
return (
|
||||
<UnheadProvider head={head}>
|
||||
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NostrLoginProvider storageKey='nostr:login'>
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<DMProvider config={dmConfig}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Suspense>
|
||||
<AppRouter />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</DMProvider>
|
||||
</NostrProvider>
|
||||
</NostrLoginProvider>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>
|
||||
</UnheadProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Configure DM Settings
|
||||
|
||||
The `DMConfig` object supports the following options:
|
||||
|
||||
- `enabled` (boolean, default: `false`) - Enable/disable entire DM system. When false, no messages are loaded, stored, or processed.
|
||||
- `protocolMode` (ProtocolMode, default: `PROTOCOL_MODE.NIP17_ONLY`) - Which protocols to support:
|
||||
- `PROTOCOL_MODE.NIP04_ONLY` - Legacy encryption only
|
||||
- `PROTOCOL_MODE.NIP17_ONLY` - Modern private messages (recommended)
|
||||
- `PROTOCOL_MODE.NIP04_OR_NIP17` - Support both protocols (for backwards compatibility)
|
||||
|
||||
**Note**: The DM system uses domain-based IndexedDB naming (`nostr-dm-store-${hostname}`) to prevent conflicts between multiple apps on the same domain.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Send Messages
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
|
||||
|
||||
function ComposeMessage({ recipientPubkey }: { recipientPubkey: string }) {
|
||||
const { sendMessage } = useDMContext();
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const handleSend = async () => {
|
||||
await sendMessage({
|
||||
recipientPubkey,
|
||||
content,
|
||||
protocol: MESSAGE_PROTOCOL.NIP17, // Uses NIP-44 encryption + gift wrapping
|
||||
});
|
||||
setContent('');
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Display Conversations
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
||||
function ConversationList({ onSelectConversation }: { onSelectConversation: (pubkey: string) => void }) {
|
||||
const { conversations, isLoading } = useDMContext();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading conversations...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{conversations.map((conversation) => (
|
||||
<ConversationItem
|
||||
key={conversation.pubkey}
|
||||
conversation={conversation}
|
||||
onClick={() => onSelectConversation(conversation.pubkey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationItem({ conversation, onClick }: {
|
||||
conversation: ConversationSummary;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const author = useAuthor(conversation.pubkey);
|
||||
const displayName = author.data?.metadata?.name || genUserName(conversation.pubkey);
|
||||
const avatarUrl = author.data?.metadata?.picture;
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className="w-full p-3 hover:bg-accent rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={avatarUrl} />
|
||||
<AvatarFallback>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium">{displayName}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{conversation.lastMessage?.decryptedContent || 'No messages yet'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Display Messages in a Conversation
|
||||
|
||||
```tsx
|
||||
import { useConversationMessages } from '@/hooks/useConversationMessages';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
function MessageThread({ conversationPubkey }: { conversationPubkey: string }) {
|
||||
const { user } = useCurrentUser();
|
||||
const { messages, hasMoreMessages, loadEarlierMessages } = useConversationMessages(conversationPubkey);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-2">
|
||||
{hasMoreMessages && (
|
||||
<button onClick={loadEarlierMessages} className="text-sm text-muted-foreground">
|
||||
Load earlier messages
|
||||
</button>
|
||||
)}
|
||||
|
||||
{messages.map((message) => {
|
||||
const isFromMe = message.pubkey === user?.pubkey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex",
|
||||
isFromMe ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"max-w-[70%] rounded-lg px-4 py-2",
|
||||
isFromMe ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}>
|
||||
{message.error ? (
|
||||
<span className="text-red-500">🔒 {message.error}</span>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
{message.decryptedContent}
|
||||
</p>
|
||||
)}
|
||||
{message.isSending && (
|
||||
<span className="text-xs opacity-50">Sending...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using the Complete Messaging Interface
|
||||
|
||||
For a fully-featured messaging UI out of the box, use the `DMMessagingInterface` component:
|
||||
|
||||
```tsx
|
||||
import { DMMessagingInterface } from "@/components/dm/DMMessagingInterface";
|
||||
|
||||
function MessagesPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-4 h-screen">
|
||||
<DMMessagingInterface />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `DMMessagingInterface` component provides a complete messaging UI with:
|
||||
- Conversation list with Active/Requests tabs
|
||||
- Message thread view with pagination
|
||||
- Compose area with file upload support
|
||||
- Real-time message updates
|
||||
- Mobile-responsive layout (shows one panel at a time on mobile)
|
||||
|
||||
It requires no props and works automatically when wrapped in `DMProvider`.
|
||||
|
||||
**For custom layouts**, see the "Building Custom Messaging UIs" section below for individual components (`DMConversationList`, `DMChatArea`, `DMStatusInfo`).
|
||||
|
||||
## Sending Files with Messages
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
|
||||
import type { FileAttachment } from '@/contexts/DMContext';
|
||||
|
||||
function ComposeWithFiles({ recipientPubkey }: { recipientPubkey: string }) {
|
||||
const { sendMessage } = useDMContext();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const [content, setContent] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const handleSend = async () => {
|
||||
let attachments: FileAttachment[] | undefined;
|
||||
|
||||
// Upload file if one is selected
|
||||
if (selectedFile) {
|
||||
const tags = await uploadFile(selectedFile);
|
||||
|
||||
attachments = [{
|
||||
url: tags[0][1], // URL from first tag
|
||||
mimeType: selectedFile.type,
|
||||
size: selectedFile.size,
|
||||
name: selectedFile.name,
|
||||
tags: tags
|
||||
}];
|
||||
}
|
||||
|
||||
await sendMessage({
|
||||
recipientPubkey,
|
||||
content,
|
||||
protocol: MESSAGE_PROTOCOL.NIP17,
|
||||
attachments,
|
||||
});
|
||||
|
||||
setContent('');
|
||||
setSelectedFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
|
||||
{selectedFile && <div>Selected: {selectedFile.name}</div>}
|
||||
|
||||
<button type="submit" disabled={isUploading}>
|
||||
{isUploading ? 'Uploading...' : 'Send'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol Comparison
|
||||
|
||||
### NIP-04 (Legacy)
|
||||
- **Encryption**: NIP-04 (simpler, older)
|
||||
- **Metadata**: Sender and recipient visible to relays
|
||||
- **Event Kind**: Kind 4
|
||||
- **Use When**: Compatibility with older clients
|
||||
|
||||
### NIP-17 (Modern & Private)
|
||||
- **Encryption**: NIP-44 (stronger)
|
||||
- **Metadata**: Hidden via gift wrapping (NIP-59)
|
||||
- **Event Kinds**: Kind 14 (text), Kind 15 (files)
|
||||
- **Wrapped In**: Kind 1059 (Gift Wrap) with ephemeral keys
|
||||
- **Use When**: Maximum privacy (recommended)
|
||||
|
||||
**Key Privacy Features of NIP-17:**
|
||||
- Sender identity hidden (uses random ephemeral keys)
|
||||
- Timestamps randomized (±2 days) to hide send time
|
||||
- Dual gift wraps (recipient + sender) for message history
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Conversation Categorization
|
||||
|
||||
The system automatically categorizes conversations:
|
||||
|
||||
```tsx
|
||||
const { conversations } = useDMContext();
|
||||
|
||||
// Filter by category
|
||||
const knownConversations = conversations.filter(c => c.isKnown);
|
||||
const requestConversations = conversations.filter(c => c.isRequest);
|
||||
|
||||
// isKnown = true if user has sent at least one message
|
||||
// isRequest = true if only received messages, never replied
|
||||
```
|
||||
|
||||
### Loading States
|
||||
|
||||
```tsx
|
||||
const { isLoading, loadingPhase, scanProgress } = useDMContext();
|
||||
|
||||
// Check overall loading state
|
||||
if (isLoading) {
|
||||
console.log('Current phase:', loadingPhase);
|
||||
// LOADING_PHASES.CACHE - Loading from local cache
|
||||
// LOADING_PHASES.RELAYS - Querying relays
|
||||
// LOADING_PHASES.SUBSCRIPTIONS - Setting up real-time updates
|
||||
// LOADING_PHASES.READY - Fully loaded
|
||||
}
|
||||
|
||||
// Display scan progress for large message histories
|
||||
if (scanProgress.nip17) {
|
||||
console.log(`NIP-17: ${scanProgress.nip17.current} messages - ${scanProgress.nip17.status}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Clear Cache and Refresh
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
|
||||
function SettingsButton() {
|
||||
const { clearCacheAndRefetch } = useDMContext();
|
||||
|
||||
const handleClearCache = async () => {
|
||||
await clearCacheAndRefetch();
|
||||
// Clears IndexedDB cache and reloads all messages from relays
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleClearCache}>
|
||||
Clear Message Cache
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Data Flow
|
||||
1. **Cache First**: Messages load instantly from encrypted IndexedDB cache
|
||||
2. **Background Sync**: New messages fetched from relays in parallel
|
||||
3. **Real-time Updates**: WebSocket subscriptions for live messages
|
||||
4. **Optimistic UI**: Sent messages appear immediately, confirmed on relay response
|
||||
|
||||
### Storage
|
||||
- **IndexedDB**: All messages stored locally with NIP-44 encryption
|
||||
- **Per-User Storage**: Separate encrypted store for each logged-in user
|
||||
- **Automatic Sync**: Debounced writes (15s) + immediate on new messages
|
||||
|
||||
### Performance
|
||||
- **Parallel Queries**: NIP-04 and NIP-17 messages fetched simultaneously
|
||||
- **Batched Loading**: Messages loaded in batches (1000/batch, 20k limit)
|
||||
- **Pagination**: Conversation messages paginated (25/page)
|
||||
- **Deduplication**: Automatic filtering of duplicate messages by ID
|
||||
|
||||
### Security
|
||||
- **NIP-44 Encryption**: Modern authenticated encryption for all NIP-17 messages
|
||||
- **Local Encryption**: IndexedDB storage encrypted with user's NIP-44 key
|
||||
- **Ephemeral Keys**: Random keys for NIP-17 gift wraps (sender anonymity)
|
||||
- **No Plaintext**: Decrypted content never persisted unencrypted
|
||||
- **Domain Isolation**: IndexedDB databases are namespaced by hostname to prevent data conflicts
|
||||
|
||||
## Building Custom Messaging UIs
|
||||
|
||||
For advanced use cases, you can use the individual DM components to build custom layouts:
|
||||
|
||||
### Available Components
|
||||
|
||||
**`DMConversationList`** - Conversation sidebar with tabs
|
||||
```tsx
|
||||
import { DMConversationList } from '@/components/dm/DMConversationList';
|
||||
|
||||
<DMConversationList
|
||||
selectedPubkey={selectedPubkey}
|
||||
onSelectConversation={(pubkey) => setSelectedPubkey(pubkey)}
|
||||
onStatusClick={() => setShowStatus(true)} // optional
|
||||
className="h-full"
|
||||
/>
|
||||
```
|
||||
|
||||
**`DMChatArea`** - Message thread and compose area
|
||||
```tsx
|
||||
import { DMChatArea } from '@/components/dm/DMChatArea';
|
||||
|
||||
<DMChatArea
|
||||
pubkey={selectedPubkey}
|
||||
onBack={() => setSelectedPubkey(null)} // optional, for mobile back button
|
||||
className="h-full"
|
||||
/>
|
||||
```
|
||||
|
||||
**`DMStatusInfo`** - Debug/status panel
|
||||
```tsx
|
||||
import { DMStatusInfo } from '@/components/dm/DMStatusInfo';
|
||||
|
||||
<DMStatusInfo clearCacheAndRefetch={clearCacheAndRefetch} />
|
||||
```
|
||||
|
||||
### Custom Layout Example
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { DMConversationList } from '@/components/dm/DMConversationList';
|
||||
import { DMChatArea } from '@/components/dm/DMChatArea';
|
||||
|
||||
function CustomMessagingLayout() {
|
||||
const [selectedPubkey, setSelectedPubkey] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Custom sidebar */}
|
||||
<aside className="w-64 border-r">
|
||||
<DMConversationList
|
||||
selectedPubkey={selectedPubkey}
|
||||
onSelectConversation={setSelectedPubkey}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Custom main area */}
|
||||
<main className="flex-1">
|
||||
{selectedPubkey ? (
|
||||
<DMChatArea pubkey={selectedPubkey} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p>Select a conversation to start messaging</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: nostr-encryption
|
||||
description: Encrypt and decrypt content for Nostr direct messages, gift wraps, or any feature that needs NIP-44 (or legacy NIP-04) ciphertext, using the logged-in user's signer.
|
||||
---
|
||||
|
||||
# Nostr Encryption and Decryption
|
||||
|
||||
The logged-in user exposes a `signer` object that matches the NIP-07 signer interface. The signer handles all cryptographic operations internally — including ECDH, conversation-key derivation, and AEAD — so your code never touches a private key.
|
||||
|
||||
**Always use the signer interface for encryption. Never ask the user for their private key, and never derive a shared secret yourself.**
|
||||
|
||||
## NIP-44 (preferred)
|
||||
|
||||
NIP-44 is the modern, authenticated encryption scheme used for DMs (NIP-17), gift wraps (NIP-59), and most new encrypted payloads.
|
||||
|
||||
```ts
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
|
||||
function useEncryptedNote() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
if (!user) throw new Error("Must be logged in");
|
||||
|
||||
// Guard: older signers may not support NIP-44 yet.
|
||||
if (!user.signer.nip44) {
|
||||
throw new Error(
|
||||
"Please upgrade your signer extension to a version that supports NIP-44 encryption",
|
||||
);
|
||||
}
|
||||
|
||||
// Encrypt a message to a recipient (use your own pubkey to encrypt to self).
|
||||
const ciphertext = await user.signer.nip44.encrypt(
|
||||
recipientPubkey,
|
||||
"hello world",
|
||||
);
|
||||
|
||||
// Decrypt a message from a sender (use the *other party's* pubkey).
|
||||
const plaintext = await user.signer.nip44.decrypt(senderPubkey, ciphertext);
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
```
|
||||
|
||||
### Key points
|
||||
|
||||
- `encrypt(peerPubkey, plaintext)` — `peerPubkey` is the **other party's** hex public key. For self-encryption (notes, backups), pass `user.pubkey`.
|
||||
- `decrypt(peerPubkey, ciphertext)` — `peerPubkey` is the author of the ciphertext you're decrypting (for an incoming DM, this is the sender's pubkey).
|
||||
- Both methods are async and may throw if the signer rejects the request or the ciphertext is malformed. Wrap calls in `try/catch`.
|
||||
- The signer handles conversation-key caching; repeated calls for the same peer are cheap.
|
||||
|
||||
## NIP-04 (legacy)
|
||||
|
||||
NIP-04 is only needed when interacting with older clients that haven't adopted NIP-44. The API mirrors NIP-44:
|
||||
|
||||
```ts
|
||||
if (!user.signer.nip04) {
|
||||
throw new Error("Signer does not support NIP-04");
|
||||
}
|
||||
|
||||
const ciphertext = await user.signer.nip04.encrypt(peerPubkey, plaintext);
|
||||
const plaintext = await user.signer.nip04.decrypt(peerPubkey, ciphertext);
|
||||
```
|
||||
|
||||
Prefer NIP-44 for anything new. Only fall back to NIP-04 when a spec or peer explicitly requires it.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Encrypt-to-self (drafts, private notes)
|
||||
|
||||
```ts
|
||||
const ciphertext = await user.signer.nip44.encrypt(user.pubkey, draft);
|
||||
createEvent({ kind: 30078, content: ciphertext, tags: [["d", "my-draft"]] });
|
||||
```
|
||||
|
||||
### Decrypt an incoming DM (NIP-17 / NIP-59)
|
||||
|
||||
For gift-wrapped DMs, you'll typically decrypt the outer wrap, then the inner seal, then read the rumor's content. Each decryption uses the *sender* of that specific layer as the peer pubkey.
|
||||
|
||||
### Guarding the UI
|
||||
|
||||
Always check `user.signer.nip44` (or `nip04`) before calling encryption methods. Remote signers and older browser extensions may not implement every interface, and catching the missing-capability case lets you show a useful message ("Please upgrade your signer") instead of an unhandled promise rejection.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: nostr-kind-design
|
||||
description: Decide whether to reuse an existing NIP or mint a new kind, design tag structures that relays can index, choose what goes in content vs. tags, and document new kinds or extensions in NIP.md. Load when authoring a new schema — not when wiring up rendering for a kind that already exists (use nostr-kind-rendering for that).
|
||||
---
|
||||
|
||||
# Nostr Kinds — Design and Schema
|
||||
|
||||
Load this skill when:
|
||||
|
||||
- Minting a new event kind for a Ditto feature.
|
||||
- Extending an existing NIP with new tags.
|
||||
- Deciding whether an existing NIP covers a use case or whether a custom kind is warranted.
|
||||
- Documenting a custom kind or extension in `NIP.md`.
|
||||
|
||||
**Not this skill** — if an existing NIP/kind covers your use case and you only need to render it in Ditto's UI, use the **`nostr-kind-rendering`** skill instead.
|
||||
|
||||
## Choosing Between Existing NIPs and Custom Kinds
|
||||
|
||||
1. **Thorough NIP review first.** Browse the NIP index, then read candidate NIPs in detail. The goal is to find the closest existing solution.
|
||||
2. **Prefer extending existing NIPs** over creating custom kinds, even at the cost of minor schema compromises. Custom kinds fragment the ecosystem.
|
||||
3. **When an existing NIP is close but not perfect**, use its kind as the base and add domain-specific tags. Document the extension in `NIP.md`.
|
||||
4. **Only mint a new kind** when no existing NIP covers the core functionality, the data structure is fundamentally different, or the use case requires different storage characteristics (regular vs. replaceable vs. addressable).
|
||||
5. **If a tool to generate a new kind number is available, you MUST call it.** Never pick an arbitrary number.
|
||||
6. **Custom kinds MUST include a NIP-31 `alt` tag** with a human-readable description of the event's purpose.
|
||||
|
||||
**Example decision:**
|
||||
|
||||
```
|
||||
Need: Equipment marketplace for farmers
|
||||
Options:
|
||||
1. NIP-15 (Marketplace) — too structured for peer-to-peer sales
|
||||
2. NIP-99 (Classifieds) — good fit, extensible with farming tags
|
||||
3. Custom kind — perfect fit, no interoperability
|
||||
|
||||
Decision: NIP-99 + farming-specific tags.
|
||||
```
|
||||
|
||||
## Kind Ranges
|
||||
|
||||
An event's kind number determines storage semantics:
|
||||
|
||||
- **Regular** (1000 ≤ kind < 10000) — stored permanently by relays. Notes, articles, etc.
|
||||
- **Replaceable** (10000 ≤ kind < 20000) — only the latest event per `pubkey+kind` is kept. Profile metadata, contact lists, mute lists.
|
||||
- **Addressable** (30000 ≤ kind < 40000) — identified by `pubkey+kind+d-tag`; only the latest per combo is kept. Long-form content, products, definitions.
|
||||
|
||||
Kinds below 1000 are "legacy"; storage is per-kind (e.g. kind 1 is regular, kind 3 is replaceable).
|
||||
|
||||
## Tag Design Principles
|
||||
|
||||
- **Kind = schema, tags = semantics.** Don't mint a new kind just to represent a different category of the same data.
|
||||
- **Relays only index single-letter tags.** Use `t` for categories so filters like `'#t': ['electronics']` work at the relay level. Multi-letter tags (`product_type`, etc.) force inefficient client-side filtering.
|
||||
- **Filter at the relay**, not in JavaScript:
|
||||
|
||||
```ts
|
||||
// ❌ Fetch everything, filter locally
|
||||
const events = await nostr.query([{ kinds: [30402] }]);
|
||||
const filtered = events.filter((e) => hasTag(e, 'product_type', 'electronics'));
|
||||
|
||||
// ✅ Filter at the relay
|
||||
const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]);
|
||||
```
|
||||
|
||||
- **For Ditto-specific niches** (community apps, regional variants), tag events with a `t` value and query on it. Don't do this for generic platforms — it would silo content.
|
||||
|
||||
## Content vs. Tags
|
||||
|
||||
- **`content`** — large freeform text or existing industry-standard JSON (GeoJSON, FHIR, Tiled maps). Kind 0 is the one exception where structured JSON goes in content.
|
||||
- **Tags** — queryable metadata, structured data, anything you might filter on.
|
||||
- **Empty content is fine.** `content: ""` is idiomatic for tag-only events.
|
||||
- **If you need to filter by a field, it must be a tag** — relays don't index content.
|
||||
|
||||
```json
|
||||
// ✅ Queryable
|
||||
{ "kind": 30402, "content": "",
|
||||
"tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]] }
|
||||
|
||||
// ❌ Structured data buried in content
|
||||
{ "kind": 30402, "content": "{\"title\":\"Camera\",\"price\":250}", "tags": [["d", "product-123"]] }
|
||||
```
|
||||
|
||||
## `NIP.md`
|
||||
|
||||
`NIP.md` documents Ditto's custom kinds and any extensions to existing NIPs. Whenever you mint a new kind or change a custom schema, **create or update `NIP.md`** with the tag list, content format, and intended usage. If a kind you add is effectively the same shape as an existing NIP, note the NIP reference rather than duplicating the spec.
|
||||
|
||||
Standard NIPs (like NIP-84 Highlights, NIP-23 Articles) do **not** go in `NIP.md` — only Ditto-custom kinds and Ditto-specific extensions.
|
||||
|
||||
## After Designing — What's Next?
|
||||
|
||||
Once you've settled on a kind number and tag shape, you still need to render it in Ditto's UI. Load the **`nostr-kind-rendering`** skill for the full multi-location registration checklist (feed cards, detail pages, embedded previews, kind-label maps, notifications, feed-toggle registration).
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
name: nostr-kind-rendering
|
||||
description: Add UI rendering for an event kind Ditto doesn't yet display — feed cards, detail pages, embedded previews, notifications, routes, feed-toggle registration, and the several kind-label maps (KIND_LABELS, KIND_HEADER_MAP, NOTIFICATION_KIND_NOUNS, CommentContext) that must stay in sync. Load when asked to "support / display / render" a NIP or kind number, when a kind renders blank or as "Kind 12345", or when quote embeds of a kind show "This event kind is not supported".
|
||||
---
|
||||
|
||||
# Nostr Kinds — UI Rendering Checklist
|
||||
|
||||
Ditto's kind dispatch is **spread across many files** by design — feed cards, detail pages, embedded previews, notifications, and several kind-label maps each have their own rendering requirements. The central `KIND_LABELS` registry covers the easy cases, but most context-specific maps (grammar, icons, verbs) cannot be derived mechanically and must be updated manually.
|
||||
|
||||
**Missing any location causes visible bugs**: a kind might render blank in quote posts, show "Kind 12345" as a label, skip its action header, tombstone as "This event kind is not supported" in embeds, or — worst of all — have its content fed through the kind-1 tokenizer and auto-linkify URLs/hashtags that weren't authored by the event creator.
|
||||
|
||||
**When in doubt, grep for an existing kind number like `30617` or `9802`** — you'll find every registration point you need to mirror.
|
||||
|
||||
## Decision: Feed-toggle + dedicated page, or just rendering?
|
||||
|
||||
Before touching code, pick one:
|
||||
|
||||
- **Just render it everywhere Nostr content appears** (no feed toggle, no dedicated page). Use when the kind is niche or only reached via direct links / quote embeds. Minimal surface — steps 1–6 below.
|
||||
- **Add a feed toggle + optional dedicated page.** Use when users should be able to browse events of this kind or opt them in/out of their home feed. Requires the feed registration (step 7) and `AppConfig` triple (step 8).
|
||||
|
||||
When the user asks generally to "support" a kind, ask which direction they want if it's not obvious from context.
|
||||
|
||||
## Checklist
|
||||
|
||||
### 1. Content card component (`src/components/`)
|
||||
|
||||
Create `<MyKindCard event={event} />` that renders the event's tags/content appropriately.
|
||||
|
||||
- **Never run event content through the kind-1 tokenizer** (`<NoteContent>` / `<TruncatedNoteContent>`) unless the kind's content is actually free-form user prose. Quote-type content (highlights, snippets, citations) contains URLs and hashtags from the *source*, not the event author — tokenizing them is misleading.
|
||||
- Render plaintext with `whitespace-pre-wrap break-words` inside a `<p>` instead.
|
||||
- Route any event-sourced URLs (`r` tags, media URLs, source links) through `sanitizeUrl()` from `@/lib/sanitizeUrl` before using them in `href`/`src`.
|
||||
- Support an `expanded` prop if the card looks different on the detail page than in the feed.
|
||||
|
||||
### 2. Feed card dispatch (`src/components/NoteCard.tsx`)
|
||||
|
||||
Three edits in this file:
|
||||
|
||||
1. **Flag block** (around lines 384–435): add `const isMyKind = event.kind === XXXX;`.
|
||||
2. **`isTextNote` negation list** (around lines 440–475): add `&& !isMyKind`. Without this, unknown kinds fall through to `UnknownKindContent` (showing only the `alt` tag).
|
||||
3. **Content dispatch ternary** (around lines 578–692): add `) : isMyKind ? (<MyKindCard event={event} />`.
|
||||
4. **`KIND_HEADER_MAP`** (around lines 1710+): add an entry so the feed shows "Alice shared a *noun*" or similar. Pattern:
|
||||
```ts
|
||||
9802: {
|
||||
icon: Highlighter,
|
||||
action: "shared a",
|
||||
noun: "highlight",
|
||||
nounRoute: "/highlights", // omit if no dedicated page
|
||||
},
|
||||
```
|
||||
5. Import the card component and any new lucide icons.
|
||||
|
||||
### 3. Detail page dispatch (`src/pages/PostDetailPage.tsx`)
|
||||
|
||||
Mirror the three NoteCard edits:
|
||||
|
||||
1. **Flag block** (around lines 1021–1098): `const isMyKind = event.kind === XXXX;`.
|
||||
2. **`isTextNote` negation list**: add `&& !isMyKind`.
|
||||
3. **Content dispatch ternary** (around lines 2147–2251): add `) : isMyKind ? (<MyKindCard event={event} expanded />`.
|
||||
|
||||
The loading-state title uses `shellTitleForKind()`, which falls through to `KIND_LABELS` — no override needed unless the kind belongs to a group ("Music Details") or needs composite grammar.
|
||||
|
||||
### 4. Central kind label (`src/lib/kindLabels.ts`)
|
||||
|
||||
Add a **capitalized noun phrase, no articles** to the `KIND_LABELS` map:
|
||||
|
||||
```ts
|
||||
9802: 'Highlight',
|
||||
```
|
||||
|
||||
This is consumed by the detail-page loading title, nsite permission prompt, signer nudge toasts, and addressable-event preview headers. Ignoring this gives "Kind 9802" everywhere it appears.
|
||||
|
||||
### 5. Context-specific label and icon maps
|
||||
|
||||
Each of these maps exists because the surrounding UI needs a different grammatical form. They are **not derived** from `KIND_LABELS` and must be updated manually.
|
||||
|
||||
- **`src/components/CommentContext.tsx`** — `KIND_LABELS` (uses articles: `'a highlight'`, `'an article'`) and `KIND_ICONS` (lucide component reference). Rendered as "Commenting on {label}". Without an entry you get "an unsupported event".
|
||||
- **`src/pages/NotificationsPage.tsx`** — `NOTIFICATION_KIND_NOUNS` (bare lowercase nouns: `'highlight'`, `'article'`). Rendered as "reacted to your {noun}". Without an entry you get "post" as a fallback.
|
||||
- **`src/components/NoteCard.tsx`** — `KIND_HEADER_MAP` (already covered in step 2).
|
||||
|
||||
### 6. Embedded previews (`src/components/EmbeddedNote.tsx`)
|
||||
|
||||
The quote-embed dispatcher in `EmbeddedNote` (around lines 65–110) routes kinds to dedicated compact cards. **Without a branch here, non-content kinds fall through to `EmbeddedNoteCard`, which either:**
|
||||
|
||||
- Shows only the NIP-31 `alt` tag (if present), or
|
||||
- Tombstones as "This event kind is not supported", or
|
||||
- **Feeds the event's `content` through the kind-1 tokenizer** if the kind is mistakenly treated as a content-kind — auto-linkifying URLs and hashtags that weren't authored by the event creator. This is a security/UX bug.
|
||||
|
||||
For any kind whose `content` isn't freeform user prose, add an explicit dispatch branch even if it just renders a minimal compact card. Pattern:
|
||||
|
||||
```tsx
|
||||
if (event.kind === 9802) {
|
||||
return <EmbeddedHighlightCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
```
|
||||
|
||||
Then define the compact card using `EmbeddedCardShell` for the author row + navigation, and render the kind-specific body inside. See `EmbeddedHighlightCard` and `EmbeddedBadgeAwardCard` for reference.
|
||||
|
||||
`src/components/EmbeddedNaddr.tsx` works similarly for addressable kinds — add a branch there if your kind is addressable.
|
||||
|
||||
### 7. Feed/sidebar registration (`src/lib/extraKinds.ts`)
|
||||
|
||||
Only needed if you decided on "feed-toggle + dedicated page" above. Add an `ExtraKindDef`:
|
||||
|
||||
```ts
|
||||
{
|
||||
kind: 9802,
|
||||
id: 'highlights',
|
||||
showKey: 'showHighlights',
|
||||
feedKey: 'feedIncludeHighlights',
|
||||
label: 'Highlights',
|
||||
description: 'Noteworthy excerpts from articles, posts, and the web (NIP-84)',
|
||||
route: 'highlights', // omit for feed-only registration
|
||||
addressable: false,
|
||||
section: 'social', // feed | media | social | development | whimsy
|
||||
blurb: 'Longer marketing copy shown in the info modal.',
|
||||
},
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
- **Sidebar icon** (`src/lib/sidebarItems.tsx`) — add `{ id: "highlights", label: "Highlights", path: "/highlights", icon: Highlighter }` to `SIDEBAR_ITEMS`, and import the icon at the top. `CONTENT_KIND_ICONS` picks up the icon automatically from the sidebar definition.
|
||||
- **Route** (`src/AppRouter.tsx`) — add `const highlightsDef = getExtraKindDef("highlights")!;` at the top of the file and a `<Route path="/highlights" element={<KindFeedPage kind={highlightsDef.kind} title={highlightsDef.label} icon={sidebarItemIcon("highlights", "size-5")} />} />` above the catch-all `*` route.
|
||||
|
||||
### 8. `AppConfig` triple (required if you added feed/sidebar toggle keys in step 7)
|
||||
|
||||
Three files must stay in sync, or the build fails or the setting silently no-ops:
|
||||
|
||||
1. **`src/contexts/AppContext.ts`** — add the fields to the `FeedSettings` interface with JSDoc comments.
|
||||
2. **`src/lib/schemas.ts`** — add the same fields to `FeedSettingsSchema` as `z.boolean().optional()`. `DittoConfigSchema` is derived from `AppConfigSchema` with `.strict()` mode, so any `ditto.json` field missing from Zod is a build error.
|
||||
3. **`src/App.tsx`** — add the default value in the initial `feedSettings` block.
|
||||
4. **`src/test/TestApp.tsx`** — mirror the default in test config so component tests work.
|
||||
|
||||
Convention: `show*` toggles default to `true` (sidebar entries visible), `feedInclude*` toggles default to `false` for niche content, `true` for core feed content.
|
||||
|
||||
### 9. Notification integration (if applicable)
|
||||
|
||||
Load this step when the kind represents an interaction with the user's content (reactions, reposts, highlights, awards, etc.) — i.e. when an event author "does something with" another user's content via an `e`/`a`/`p` tag.
|
||||
|
||||
**Six files** to update:
|
||||
|
||||
1. **`src/hooks/useEncryptedSettings.ts`** — add `highlights?: boolean` (or equivalent) to the `notificationPreferences` object.
|
||||
2. **`src/lib/notificationKinds.ts`** — add the kind to `ALL_NOTIFICATION_KINDS` and add a `if (p.X !== false) kinds.push(XXXX);` line in `getEnabledNotificationKinds`.
|
||||
3. **`src/lib/notificationTemplates.ts`** — add a `NOTIFICATION_TEMPLATES` entry with a title and body for nostr-push server-side notifications.
|
||||
4. **`src/pages/NotificationSettings.tsx`** — extend `NotificationPrefKey` union, add a row to `NOTIFICATION_TYPES` with icon/label/description/kinds.
|
||||
5. **`src/hooks/useNotifications.ts`** — extend `groupKey` (decide if events of this kind group by referenced event or stand alone), and if it's a "did something to your content" kind, add it to the author-ownership filter so users only get notified for interactions with their own content.
|
||||
6. **`src/pages/NotificationsPage.tsx`** — add a case to `GroupedNotificationView`'s switch; write `MyKindNotification` + `MyKindNotificationGroup` components modeled on `RepostNotification` / `LikeNotification`.
|
||||
|
||||
### 10. Spam guards (`src/lib/feedUtils.ts`)
|
||||
|
||||
If the kind has required tags (NIP-spec-mandated references, minimum content, etc.), add a check in `shouldHideFeedEvent` to hide events that don't meet the minimum bar. This pre-filters events before `NoteCard` mounts them, avoiding layout shifts from components that would return `null`.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
// NIP-84 highlights with no excerpt AND no source reference.
|
||||
if (event.kind === 9802) {
|
||||
const hasContent = event.content.trim().length > 0;
|
||||
const hasSource = event.tags.some(([n]) => n === 'a' || n === 'e' || n === 'r');
|
||||
if (!hasContent && !hasSource) return true;
|
||||
}
|
||||
```
|
||||
|
||||
### 11. `NIP.md` (custom kinds only)
|
||||
|
||||
If the kind is a Ditto-custom kind or a Ditto-specific extension of an existing NIP, document it in `NIP.md` — see the **`nostr-kind-design`** skill for the format. Standard NIPs (like NIP-84, NIP-23) do not go in `NIP.md`.
|
||||
|
||||
## Validation
|
||||
|
||||
After making changes, run `npm run test` — it runs `tsc --noEmit`, `eslint`, `vitest`, and `vite build` in sequence. All must pass. Additions to the `AppConfig` triple in particular frequently break the build if one of the four files is missed.
|
||||
|
||||
## Why so many locations?
|
||||
|
||||
These are genuinely different UI contexts (feed cards, detail pages, embedded previews, comment-context labels, notifications, sidebar routes) with different rendering requirements and grammar needs. The central `KIND_LABELS` in `src/lib/kindLabels.ts` handles the common "what to call this kind" case, but feed headers, comment-context text, and notification verbs each need their own grammar, and notification integration involves a whole independent subsystem.
|
||||
|
||||
## Bugs that signal a missed step
|
||||
|
||||
- **"Kind 12345" shown as a label** → step 4 (`KIND_LABELS`).
|
||||
- **"an unsupported event" in CommentContext** → step 5 (`CommentContext` maps).
|
||||
- **"reacted to your **post**"** when it should say "highlight" → step 5 (`NOTIFICATION_KIND_NOUNS`).
|
||||
- **No action header above a feed card** → step 2.4 (`KIND_HEADER_MAP`).
|
||||
- **Blank / `alt`-only card in quote embeds** → step 6 (`EmbeddedNote` dispatcher).
|
||||
- **URLs/hashtags in quoted text auto-linkified** → step 6 (embedded dispatcher forgot to bypass the kind-1 tokenizer).
|
||||
- **Kind doesn't appear in the home feed even with the toggle on** → step 7 (`ExtraKindDef` missing `feedKey`).
|
||||
- **Build error mentioning a missing `FeedSettings` field** → step 8 (one of the three files out of sync).
|
||||
- **Users not notified when their content is interacted with** → step 9 (notification stack).
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: nostr-publishing
|
||||
description: Publish Nostr events with useNostrPublish. Covers the basic publishing pattern, safely mutating replaceable and addressable events (read-modify-write via fetchFreshEvent + prev), published_at preservation, and d-tag collision prevention for new addressable content.
|
||||
---
|
||||
|
||||
# Publishing Nostr Events
|
||||
|
||||
Use this skill when a feature needs to publish events — notes, reactions, list updates, profile edits, addressable content, etc. Covers the `useNostrPublish` hook, the correct read-modify-write pattern for replaceable/addressable lists, and d-tag collision prevention.
|
||||
|
||||
## The `useNostrPublish` Hook
|
||||
|
||||
`useNostrPublish` publishes an event through the app's connection pool and auto-adds a `client` tag. Always guard calls with `useCurrentUser` — publishing requires a signer.
|
||||
|
||||
```tsx
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
export function PostForm() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutate: createEvent } = useNostrPublish();
|
||||
|
||||
if (!user) return <span>You must be logged in to post.</span>;
|
||||
|
||||
return (
|
||||
<button onClick={() => createEvent({ kind: 1, content: 'hello' })}>
|
||||
Post
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `mutateAsync` over `mutate` when the caller needs to `await` the published event (e.g. to navigate to the new event's page, or to chain another publish).
|
||||
|
||||
## Mutating Replaceable and Addressable Events (CRITICAL)
|
||||
|
||||
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a **read-modify-write** cycle: fetch the current event, modify its tags, publish a new version. **Never read from the TanStack Query cache before mutating** — the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
|
||||
|
||||
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
|
||||
|
||||
```typescript
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
// Inside a mutation function:
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [10003],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
const currentTags = prev?.tags ?? [];
|
||||
// ...modify tags...
|
||||
await publishEvent({
|
||||
kind: 10003,
|
||||
content: prev?.content ?? '',
|
||||
tags: newTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
```
|
||||
|
||||
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
|
||||
|
||||
### The `prev` Property on Event Templates
|
||||
|
||||
`useNostrPublish` accepts an optional `prev` property on the event template — the **previous version** of the event being replaced. The hook uses it to manage the `published_at` tag (NIP-24) automatically:
|
||||
|
||||
- **First publish (no `prev`)** — `published_at` is set equal to `created_at`.
|
||||
- **Update (`prev` provided)** — `published_at` is preserved from the old event.
|
||||
- **Old event lacks `published_at`** — nothing is fabricated.
|
||||
- **Caller already set `published_at` in tags** — left alone.
|
||||
|
||||
**Convention**: name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
|
||||
|
||||
```typescript
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
// ...
|
||||
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
|
||||
```
|
||||
|
||||
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
|
||||
|
||||
## D-Tag Collision Prevention for Addressable Events
|
||||
|
||||
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
|
||||
|
||||
### When to check for collisions
|
||||
|
||||
- **Must check** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.).
|
||||
- **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with an embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
|
||||
|
||||
### Implementation pattern
|
||||
|
||||
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
|
||||
|
||||
```typescript
|
||||
// Before publishing a new addressable event:
|
||||
const slug = slugify(title, { lower: true, strict: true });
|
||||
|
||||
const existing = await nostr.query([
|
||||
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
|
||||
]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
toast({
|
||||
title: 'Slug already in use',
|
||||
description: 'Change the slug or edit the existing item.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Safe to publish
|
||||
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
|
||||
```
|
||||
|
||||
**Skip the check in edit mode** — when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
|
||||
|
||||
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: nostr-queries
|
||||
description: Query Nostr events efficiently with useNostr + TanStack Query. Covers the standard useQuery pattern, combining related kinds into a single request to avoid rate limiting, and validating events with required tags or strict schemas.
|
||||
---
|
||||
|
||||
# Querying Nostr Events
|
||||
|
||||
Use this skill when building a hook that fetches Nostr events. Covers the standard `useNostr` + `useQuery` pattern, efficient query design (combining kinds to avoid relay round-trips), and event validation for kinds with required tags.
|
||||
|
||||
## The Standard Pattern
|
||||
|
||||
Combine `useNostr` with TanStack Query in a custom hook. Pass the abort signal from `c.signal` into `nostr.query` so cancelled queries free relay resources:
|
||||
|
||||
```typescript
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function usePosts() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: async (c) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [1], limit: 20 }],
|
||||
{ signal: c.signal },
|
||||
);
|
||||
return events;
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Transform events into a domain model inside the `queryFn` if needed — callers should rarely see raw `NostrEvent`s. Multiple calls to `nostr.query()` inside one `queryFn` are fine for compound queries that can't be expressed as a single filter.
|
||||
|
||||
## Efficient Query Design
|
||||
|
||||
**Always minimize the number of separate round-trips** to relays. Each query consumes relay capacity and may count against rate limits.
|
||||
|
||||
**✅ Efficient — single query with multiple kinds:**
|
||||
|
||||
```typescript
|
||||
// Query repost variants in one request
|
||||
const events = await nostr.query([{
|
||||
kinds: [1, 6, 16],
|
||||
'#e': [eventId],
|
||||
limit: 150,
|
||||
}]);
|
||||
|
||||
// Separate by kind in JavaScript
|
||||
const notes = events.filter((e) => e.kind === 1);
|
||||
const reposts = events.filter((e) => e.kind === 6);
|
||||
const genericReposts = events.filter((e) => e.kind === 16);
|
||||
```
|
||||
|
||||
**❌ Inefficient — three separate round-trips:**
|
||||
|
||||
```typescript
|
||||
const [notes, reposts, genericReposts] = await Promise.all([
|
||||
nostr.query([{ kinds: [1], '#e': [eventId] }]),
|
||||
nostr.query([{ kinds: [6], '#e': [eventId] }]),
|
||||
nostr.query([{ kinds: [16], '#e': [eventId] }]),
|
||||
]);
|
||||
```
|
||||
|
||||
### Optimization rules
|
||||
|
||||
1. **Combine kinds** into one filter: `kinds: [1, 6, 16]`.
|
||||
2. **Use multiple filter objects** in a single `nostr.query()` call when different tag filters are needed simultaneously.
|
||||
3. **Raise the `limit`** when combining kinds so you still receive enough of each type.
|
||||
4. **Split by kind in JavaScript**, not by making separate requests.
|
||||
5. **Respect relay capacity** — heavy parallel queries can trigger rate limits even when each individually would be fine.
|
||||
|
||||
## Event Validation
|
||||
|
||||
For kinds with required tags or strict schemas (most custom kinds, anything beyond kind 1), filter query results through a validator before returning them. Loose kinds (kind 1 text notes) rarely need validation — all tags are optional and `content` is freeform.
|
||||
|
||||
```typescript
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
// Example validator for NIP-52 calendar events
|
||||
function validateCalendarEvent(event: NostrEvent): boolean {
|
||||
if (![31922, 31923].includes(event.kind)) return false;
|
||||
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
const title = event.tags.find(([n]) => n === 'title')?.[1];
|
||||
const start = event.tags.find(([n]) => n === 'start')?.[1];
|
||||
if (!d || !title || !start) return false;
|
||||
|
||||
// Date-based events require YYYY-MM-DD
|
||||
if (event.kind === 31922 && !/^\d{4}-\d{2}-\d{2}$/.test(start)) return false;
|
||||
|
||||
// Time-based events require a unix timestamp
|
||||
if (event.kind === 31923) {
|
||||
const ts = parseInt(start);
|
||||
if (isNaN(ts) || ts <= 0) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function useCalendarEvents() {
|
||||
const { nostr } = useNostr();
|
||||
return useQuery({
|
||||
queryKey: ['calendar-events'],
|
||||
queryFn: async (c) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [31922, 31923], limit: 20 }],
|
||||
{ signal: c.signal },
|
||||
);
|
||||
return events.filter(validateCalendarEvent);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Validation is a correctness layer, not a security layer. For trust-sensitive queries (admin actions, addressable events, moderator approvals), also constrain `authors` — see the `nostr-security` skill.
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: nostr-relay-pools
|
||||
description: Query or publish to specific Nostr relays or curated relay groups using nostr.relay() and nostr.group(), instead of the default connection pool. Useful for debugging, testing, specialized relays, or geographically-targeted publishing.
|
||||
---
|
||||
|
||||
# Targeted Nostr Relay Connections
|
||||
|
||||
By default, the `nostr` object returned from `useNostr` uses the app's connection pool: it reads from one of the configured relays and publishes to all of them. For most features this is exactly what you want.
|
||||
|
||||
Use this skill when you need **more granular control** — talking to a single relay, a curated group of relays, or debugging a specific relay's behavior.
|
||||
|
||||
## Single Relay: `nostr.relay(url)`
|
||||
|
||||
```ts
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
function useSpecificRelay() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Connect to a specific relay
|
||||
const relay = nostr.relay('wss://relay.damus.io');
|
||||
|
||||
// Query from this relay only
|
||||
const events = await relay.query([{ kinds: [1], limit: 15 }]);
|
||||
|
||||
// Publish to this relay only
|
||||
await relay.event({ kind: 1, content: 'Hello from a specific relay!' });
|
||||
}
|
||||
```
|
||||
|
||||
**Good fits:**
|
||||
|
||||
- Testing a relay's behavior in isolation
|
||||
- Debugging connectivity or rate-limiting issues
|
||||
- Querying content that only lives on a specialized relay (paid relays, private relays, niche communities)
|
||||
- Health checks / admin tooling
|
||||
|
||||
## Relay Group: `nostr.group(urls)`
|
||||
|
||||
```ts
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
function useRelayGroup() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Create a group of specific relays
|
||||
const relayGroup = nostr.group([
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.primal.net',
|
||||
'wss://nos.lol',
|
||||
]);
|
||||
|
||||
// Query from all relays in the group (deduplicated)
|
||||
const events = await relayGroup.query([{ kinds: [1], limit: 15 }]);
|
||||
|
||||
// Publish to all relays in the group
|
||||
await relayGroup.event({ kind: 1, content: 'Hello from a relay group!' });
|
||||
}
|
||||
```
|
||||
|
||||
**Good fits:**
|
||||
|
||||
- Publishing to a curated set of trusted relays for a specific feature
|
||||
- Community-scoped queries (e.g. a set of relays known to host a particular topic)
|
||||
- Geographic/region-targeted delivery
|
||||
- Load-balancing reads across a known-good subset
|
||||
|
||||
## API Consistency
|
||||
|
||||
Both the `relay` object and the `group` object expose the **same interface** as the top-level `nostr` object:
|
||||
|
||||
- `.query(filters, opts?)` — request events matching filters
|
||||
- `.req(filters, opts?)` — open a streaming subscription
|
||||
- `.event(event)` — publish a signed event
|
||||
- All other Nostrify methods
|
||||
|
||||
This means you can drop them into any existing hook or helper that expects a `nostr`-shaped object.
|
||||
|
||||
## Choosing Between Pool, Group, and Single Relay
|
||||
|
||||
| Scenario | Use |
|
||||
|----------------------------------------------------|---------------------|
|
||||
| Default app queries, best reach for publishing | `nostr` (pool) |
|
||||
| Trusted subset, community-specific publishing | `nostr.group([…])` |
|
||||
| Single-relay debugging or specialized relay access | `nostr.relay(url)` |
|
||||
|
||||
## Tips
|
||||
|
||||
- **Don't hard-code user-facing relay lists.** If a feature should publish to "the user's write relays", read from `AppContext.config.relayMetadata` (NIP-65) instead of hard-coding URLs.
|
||||
- **Compose with TanStack Query.** Wrap `relay.query(...)` / `group.query(...)` inside a `useQuery` hook exactly as you would with the default `nostr` object; the caching layer is identical.
|
||||
- **Handle unreachable relays.** Specific relays can be offline, rate-limited, or slow. Always wrap calls in `try/catch` and respect the abort signal from the query function (`c.signal`).
|
||||
- **Avoid leaking subscriptions.** When using `.req(...)` for streaming, always close the subscription on unmount (`controller.abort()` or the returned disposer).
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: nostr-security
|
||||
description: Threat model and defenses for Ditto as a web Nostr client — why XSS is catastrophic when nsec keys live in localStorage, CSP as defense-in-depth, URL and CSS sanitization for untrusted event data, and author filtering for trust-sensitive queries (admin actions, moderators, addressable events, NIP-72 communities). Load when building trust-boundary features, rendering user-controlled URLs or markup, interpolating event data into CSS, or reviewing the app's security posture.
|
||||
---
|
||||
|
||||
# Nostr Security
|
||||
|
||||
## Threat model
|
||||
|
||||
**Nostr private keys (`nsec`) are stored in plaintext in `localStorage`.** Any JavaScript running on the origin can read them with `localStorage.getItem('nostr-login')`. A successful XSS = instant, silent, irreversible key theft — no rotation, no revocation, permanent impersonation across every Nostr client the user ever touches. External signers (NIP-07 extension, NIP-46 bunker) don't change this: an XSS can still ask the active signer to sign arbitrary events, drain funds via zaps, or scrape DMs as they decrypt.
|
||||
|
||||
**Treat every piece of untrusted data as a script-injection vector** — event tags, `content`, metadata, URL params, relay responses.
|
||||
|
||||
## Defense-in-depth
|
||||
|
||||
**Content Security Policy.** `index.html` ships a restrictive CSP: `default-src 'none'`, `script-src 'self'` (no inline scripts, no `eval`), `base-uri 'self'`, `connect-src 'self' https: wss:`. The one intentional gap is `style-src 'unsafe-inline'` — required by Tailwind/shadcn — which means **CSS injection is not blocked by CSP; sanitization is on you**. When modifying CSP, only narrow it. Never add `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcard sources.
|
||||
|
||||
**Never use `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write`** with event data, URL params, or any other untrusted string. React's JSX auto-escapes interpolated strings — the moment you bypass that, CSP alone won't save you. If you must render HTML from event data, pipe it through a strict allowlist sanitizer (DOMPurify, already installed) at the parse layer.
|
||||
|
||||
**Sanitize URLs and CSS values** — see §1 and §2.
|
||||
|
||||
## 1. URL sanitization
|
||||
|
||||
Any URL from event tags, `content`, metadata fields (`picture`, `banner`, `website`, `nip05`, etc.), or relay hints is untrusted. Threats beyond `javascript:` XSS: `data:` resource exhaustion / phishing, `http://` IP leaks, relative paths triggering same-origin requests, malformed strings crashing downstream parsers.
|
||||
|
||||
**Use the shipped helper at `src/lib/sanitizeUrl.ts`:**
|
||||
|
||||
```ts
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
// Single URL — returns the normalised href, or undefined if not valid https
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
if (url) {
|
||||
// safe to use in any context
|
||||
}
|
||||
|
||||
// Array of URLs — filter out invalid entries
|
||||
const links = getAllTags(event.tags, 'r')
|
||||
.map(([, v]) => sanitizeUrl(v))
|
||||
.filter((v): v is string => !!v);
|
||||
```
|
||||
|
||||
`sanitizeUrl` returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, relative paths, etc.) return `undefined`.
|
||||
|
||||
**Sanitize at the parse layer.** When writing a parser function that extracts URLs from event tags (e.g. `parseThemeDefinition`, `parseBadgeDefinition`), apply `sanitizeUrl()` before returning the parsed data. This way every downstream consumer is automatically protected without needing to remember to sanitize at each usage site.
|
||||
|
||||
**When sanitization is NOT required:** URLs matched by a regex that constrains the protocol (e.g. `NoteContent`'s tokenizer matching `https?://...` — the regex *is* the sanitizer), hardcoded/app-generated URLs (relay configs, internal routes), and strings rendered as plain text that never land in an attribute, CSS value, or network request.
|
||||
|
||||
## 2. CSS injection
|
||||
|
||||
Event data interpolated into CSS (a `<style>` element, `style=""`, or an injected stylesheet) is a CSS injection vector. A `"`, `)`, `}`, or `;` in the value can break out of the string context and inject rules — overlay phishing, hide UI, exfiltrate via `background-image: url()` requests.
|
||||
|
||||
Common surfaces: `background-image: url("${url}")`, `font-family: "${family}"`, `@font-face { src: url("${url}") }`.
|
||||
|
||||
**Mitigation:**
|
||||
|
||||
- **URLs in `url()`** — use `sanitizeUrl()`. The `URL` constructor percent-encodes `"`, `)`, `\` and rejects non-`https:`. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
|
||||
- **Non-URL strings** (font-family, animation names) — use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which allowlists Unicode letters/numbers, spaces, hyphens, underscores, apostrophes, and periods:
|
||||
|
||||
```ts
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { sanitizeCssString } from '@/lib/fontLoader';
|
||||
|
||||
// ❌ UNSAFE
|
||||
style.textContent = `body { background-image: url("${rawUrl}"); font-family: "${rawFamily}"; }`;
|
||||
|
||||
// ✅ SAFE — validate URLs, allowlist identifiers
|
||||
const bgUrl = sanitizeUrl(rawUrl);
|
||||
const family = sanitizeCssString(rawFamily ?? '');
|
||||
if (bgUrl && family) {
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); font-family: "${family}"; }`;
|
||||
}
|
||||
```
|
||||
|
||||
If you can't justify the exact characters you're allowing, the policy is wrong.
|
||||
|
||||
## 3. Author filtering for trust-sensitive queries
|
||||
|
||||
Even with perfect XSS defenses, an attacker can publish forged events your UI will trust unless queries constrain `authors`. Relays are dumb pipes — any matching event comes back.
|
||||
|
||||
**Filter by `authors` when:**
|
||||
|
||||
- Querying admin/moderator/owner events — use a hardcoded trusted-pubkey list (e.g. `ADMIN_PUBKEYS` from `src/lib/admins`).
|
||||
- Querying addressable events (kinds 30000–39999) — the `d` tag alone is not a trust boundary; the `(kind, pubkey, d)` triple is.
|
||||
- Querying user-owned replaceable events (profile metadata, relay lists, mute lists) — `authors: [userPubkey]`.
|
||||
|
||||
**Do NOT filter by `authors`** for public UGC (kind 1 notes, reactions, zaps, discovery feeds) — anyone can post there by design.
|
||||
|
||||
```ts
|
||||
// ❌ Anyone can publish kind 30078 with this d-tag and self-appoint
|
||||
nostr.query([{ kinds: [30078], '#d': ['pathos-organizers'], limit: 1 }]);
|
||||
|
||||
// ✅ Only trust the admin list
|
||||
nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['pathos-organizers'], limit: 1 }]);
|
||||
```
|
||||
|
||||
**Routes for addressable/replaceable events must include the author** — otherwise the route handler can't construct a secure filter:
|
||||
|
||||
```tsx
|
||||
// ❌ Any pubkey can squat the slug
|
||||
<Route path="/article/:slug" element={<Article />} />
|
||||
// ✅ Filter can include authors
|
||||
<Route path="/article/:npub/:slug" element={<Article />} />
|
||||
```
|
||||
|
||||
### NIP-72 community moderation
|
||||
|
||||
Kind 4550 approvals are only trustworthy if signed by a moderator from the community definition (kind 34550). Two-step query:
|
||||
|
||||
```ts
|
||||
// 1. Fetch community definition — author-filter by the owner.
|
||||
const [community] = await nostr.query([{
|
||||
kinds: [34550], authors: [communityOwnerPubkey], '#d': [communityId], limit: 1,
|
||||
}]);
|
||||
if (!community) return [];
|
||||
|
||||
// 2. Extract moderator pubkeys from `p` tags with role "moderator".
|
||||
const moderators = community.tags
|
||||
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
|
||||
.map(([, pubkey]) => pubkey);
|
||||
|
||||
// 3. Query approvals — only from moderators.
|
||||
const approvals = await nostr.query([{
|
||||
kinds: [4550],
|
||||
authors: moderators,
|
||||
'#a': [`34550:${communityOwnerPubkey}:${communityId}`],
|
||||
limit: 100,
|
||||
}]);
|
||||
```
|
||||
|
||||
Without step 3's `authors` filter, anyone can publish a kind 4550 "approval".
|
||||
|
||||
## Pre-merge checklist
|
||||
|
||||
- [ ] No `dangerouslySetInnerHTML` / `innerHTML` / `document.write` with untrusted data.
|
||||
- [ ] CSP unchanged or narrowed; no new `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcards.
|
||||
- [ ] Every event-sourced URL passes `sanitizeUrl()` before reaching `href`, `src`, `srcSet`, `poster`, iframe `src`, or CSS.
|
||||
- [ ] Every event-sourced string in CSS passes `sanitizeUrl()` (URLs) or `sanitizeCssString()` (identifiers).
|
||||
- [ ] Every trust-sensitive query includes `authors`.
|
||||
- [ ] Routes for addressable/replaceable events carry the author in the URL.
|
||||
@@ -87,6 +87,8 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
```markdown
|
||||
## [X.Y.Z] - YYYY-MM-DD
|
||||
|
||||
A short single-paragraph summary of this release written in plain prose -- max 500 characters. This appears on the App Store, Google Play, and the in-app "what's new" toast.
|
||||
|
||||
### Added
|
||||
- Description of new features
|
||||
|
||||
@@ -100,7 +102,100 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
- Description of removed features
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
#### The Summary Paragraph
|
||||
|
||||
Every release section MUST start with a single plaintext paragraph (not a bullet, not a heading) that summarises the release for app-store-style audiences:
|
||||
|
||||
- **Single paragraph, plain prose.** No bullets, no headings, no Markdown formatting beyond plain text.
|
||||
- **Max ~500 characters.** Apple App Store and Google Play both cap "What's new" text at 500. The CI `release-notes` job warns when the summary is longer.
|
||||
- **Audience: end users discovering the update.** Describe the most noticeable user-visible changes; omit internal cleanups even if they're in the bullets below.
|
||||
- **Tone matches the bullets.** Present-tense, no Nostr jargon, no NIP/kind numbers (see Rules below).
|
||||
- **Maintenance releases** -- write a one-sentence summary like `A behind-the-scenes maintenance release with no user-facing changes.` Don't leave it blank; the CI fallback `Ditto vX.Y.Z` is a last resort for legacy entries, not new ones.
|
||||
|
||||
The same paragraph is used in three places automatically:
|
||||
- **App Store** -- "What's New in This Version" via fastlane `deliver`
|
||||
- **Google Play** -- "What's new in this version" via fastlane `supply` `metadata/android/<lang>/changelogs/<versionCode>.txt`
|
||||
- **In-app toast** -- the `What's new in vX.Y.Z` toast that fires when users load a new version (see `src/components/VersionCheck.tsx`)
|
||||
- The full section (summary + lists) goes into the GitLab Release description.
|
||||
|
||||
Extraction is handled by `scripts/extract-release-notes.mjs`; you don't have to write store-specific copy.
|
||||
|
||||
#### Changelog Quality Checklist
|
||||
|
||||
Before drafting any entries, run through this checklist. It is NOT optional -- skipping steps here is the most common way a release goes out with misleading notes.
|
||||
|
||||
##### 5.1. Diff the code, not just the commit log
|
||||
|
||||
Commit messages describe intent at the moment of commit; they over- and under-represent the cumulative effect at release time. Before drafting entries, **run a real diff** for each area of substantial change:
|
||||
|
||||
```bash
|
||||
# Full diff between tags
|
||||
git diff v<prev>..HEAD
|
||||
|
||||
# Or narrowed to an area you're unsure about
|
||||
git diff v<prev>..HEAD -- src/components/ComposeBox.tsx
|
||||
```
|
||||
|
||||
Only the diff reveals intra-release churn (commits that cancel each other out, bugs introduced and then fixed, refactors that land and get reverted). Reading commit messages alone is insufficient.
|
||||
|
||||
##### 5.2. Trace every candidate "Fixed" entry to its origin commit
|
||||
|
||||
For each bug fix you're considering listing, find the commit that introduced the bug.
|
||||
|
||||
**Fast path -- check for `Regression-of:` trailers** (see AGENTS.md "Attributing Regressions"). If the fix commit declares its origin in a trailer, you don't need to hunt:
|
||||
|
||||
```bash
|
||||
# List all commits in the release window with their Regression-of trailers (if any)
|
||||
git log v<prev>..HEAD --no-merges \
|
||||
--format='%h %s%n Regression-of: %(trailers:key=Regression-of,valueonly,separator=%x20)'
|
||||
```
|
||||
|
||||
For each `Regression-of: <sha>` entry, check whether `<sha>` is also in the release window:
|
||||
|
||||
```bash
|
||||
# Returns 0 if <sha> is BEFORE v<prev> (pre-existing bug -> legit "Fixed" entry)
|
||||
# Returns non-zero if <sha> is AFTER v<prev> (intra-release -> omit from "Fixed")
|
||||
git merge-base --is-ancestor <sha> v<prev>
|
||||
```
|
||||
|
||||
**Fallback -- manual tracing** (when no trailer is present):
|
||||
|
||||
```bash
|
||||
# Show the history of a file across all commits
|
||||
git log --oneline v<prev>..HEAD -- path/to/file.tsx
|
||||
|
||||
# Or blame the specific lines the fix touched
|
||||
git blame -L <start>,<end> -- path/to/file.tsx
|
||||
```
|
||||
|
||||
**If the introducing commit is also in this release window (i.e. after the previous tag), the bug is intra-release.** The user on the previous version never experienced it. Do NOT list it as a "Fixed" entry. Fold it into the relevant "Added" or "Changed" entry, or omit it entirely.
|
||||
|
||||
##### 5.3. The "Would a user on the previous version notice this?" test
|
||||
|
||||
The changelog describes the delta between the previous release and this one **from the user's perspective** -- not the development history. Before writing each entry, ask:
|
||||
|
||||
> "Did a user on the previous published version experience this exact thing?"
|
||||
|
||||
- If they experienced a broken state that is now fixed: **"Fixed" entry**
|
||||
- If they experienced the old behavior and now see new behavior: **"Changed" or "Added" entry**
|
||||
- If they never saw either state (introduced AND resolved within this release window): **omit entirely**
|
||||
|
||||
This applies to more than just bugs:
|
||||
- A feature added and then reverted in the same release: omit both
|
||||
- A refactor that was done and then undone: omit both
|
||||
- A performance regression introduced and then fixed: omit both
|
||||
- A typo introduced in a new string and then corrected: mention the new string (if user-facing) as a single "Added"/"Changed" entry, with no "Fixed" entry
|
||||
|
||||
##### 5.4. Worked example -- intra-release bug
|
||||
|
||||
> **Scenario:** Commit A overhauls the compose box and, as a side effect, breaks the background of the expanded emoji picker. Commit B, later in the same release window, restores the background.
|
||||
>
|
||||
> **Correct changelog:** One "Added" entry describing the compose box overhaul. The emoji picker background is part of the finished state the user receives.
|
||||
>
|
||||
> **Incorrect changelog:** An "Added" entry for the overhaul AND a "Fixed" entry for the emoji picker background. The user on the previous version never saw the broken background; listing it invents a problem they didn't have and makes the release notes read like a developer changelog.
|
||||
|
||||
#### Rules
|
||||
|
||||
- Only include categories that have entries (omit empty categories)
|
||||
- Write **user-facing descriptions**, not raw commit messages
|
||||
- Keep descriptions concise -- one line per change
|
||||
@@ -109,9 +204,9 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
- Focus on what the user sees/experiences, not internal implementation details
|
||||
- Use the current date in YYYY-MM-DD format
|
||||
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
|
||||
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
|
||||
- **Only ship what the user sees.** If a bug was introduced AND fixed within this release, the user never saw it -- omit the fix entirely (or fold the net result into the relevant Added/Changed entry). The same applies to features that were added and reverted, refactors that cancel out, and any other intra-release churn. See the Changelog Quality Checklist above (especially 5.2 and 5.3) for the procedure to verify this.
|
||||
- **Collapse related work into one entry.** If a feature was added and then tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
|
||||
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
|
||||
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
|
||||
|
||||
### Step 6: Update Version in All Files
|
||||
|
||||
@@ -190,8 +285,12 @@ git push origin main vX.Y.Z
|
||||
|
||||
This triggers the GitLab CI pipeline which will:
|
||||
1. Build a signed Android APK and AAB
|
||||
2. Create a GitLab Release with download links
|
||||
3. Publish the APK to Zapstore
|
||||
2. Build a signed iOS IPA on the self-hosted Mac runner
|
||||
3. Extract release notes (full body + summary paragraph) from `CHANGELOG.md`
|
||||
4. Create a GitLab Release with APK / AAB / IPA download links
|
||||
5. Publish the APK to Zapstore
|
||||
6. Publish the AAB to Google Play (production track) with the summary as the "What's new" text
|
||||
7. Submit the iOS IPA to App Store Connect for review with the summary as the "What's New" text
|
||||
|
||||
### Step 12: Confirm
|
||||
|
||||
@@ -212,11 +311,15 @@ After pushing, inform the user:
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs three jobs:
|
||||
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs seven jobs:
|
||||
|
||||
1. **build-apk**: Builds signed Android APK and AAB, stamps `versionName` and `versionCode` into the build
|
||||
2. **release**: Creates a GitLab Release with the changelog content and download links
|
||||
3. **publish-zapstore**: Publishes the APK to Zapstore
|
||||
2. **build-ipa**: Builds the signed App Store IPA on the self-hosted Mac runner (`tags: [macos]`); stamps `MARKETING_VERSION` and `CFBundleVersion` into the Xcode project. The IPA is uploaded to GitLab's Generic Packages registry and exposed as a CI artifact for downstream jobs
|
||||
3. **release-notes**: Extracts the version's changelog section and summary paragraph from `CHANGELOG.md` into two artifacts (`release-notes.md` and `release-notes-summary.txt`) consumed by `release`, `publish-app-store`, and `publish-google-play`
|
||||
4. **release**: Creates a GitLab Release with the full changelog section and APK / AAB / IPA download links
|
||||
5. **publish-zapstore**: Publishes the APK to Zapstore
|
||||
6. **publish-google-play**: Uploads the AAB to Google Play production track and writes the release summary to `metadata/android/en-US/changelogs/<versionCode>.txt`
|
||||
7. **publish-app-store**: Submits the prebuilt IPA to App Store Connect for review with the release summary as the "What's New" text. Runs on the self-hosted Mac runner (`tags: [macos]`) because `fastlane deliver` shells out to Apple's iTMSTransporter to upload the IPA, and that tool only ships inside Xcode — the previous Linux runner crashed at the upload step with `No such file or directory @ dir_chdir0` because `Helper.itms_path` resolved to a missing Xcode path. The build appears in App Store Connect within ~30 minutes; Apple's human review then takes 24-48 hours typically. Once approved, you must release manually in App Store Connect (`automatic_release: false`) — this is the final human gate. For runner operations, match cert rotation, and debugging, load the **`mac-runner`** skill.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: testing
|
||||
description: Write Vitest unit tests for React components and hooks using the project's `TestApp` wrapper, jsdom environment, and pre-mocked browser APIs (localStorage, matchMedia, scrollTo, IntersectionObserver, ResizeObserver). Also covers the project policy on when to create new test files.
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
Load this skill when the user asks you to write a test, diagnose a bug with a test, or add coverage for a component/hook. Running the existing test script is a standing requirement (see `AGENTS.md` → *Validating Your Changes*) and doesn't require this skill.
|
||||
|
||||
## Policy: when to create new test files
|
||||
|
||||
**Do not create new test files unless one of these applies:**
|
||||
|
||||
1. The user explicitly asks for tests.
|
||||
2. The user describes a specific bug and asks for tests to diagnose it.
|
||||
3. The user says a problem persists after you tried to fix it.
|
||||
|
||||
Never write tests because tool results show failures, because you think tests would be helpful, or because you added a new feature. The request must come from the user.
|
||||
|
||||
If none of the above apply, stop — don't create a test file. Keep running the existing test script as usual.
|
||||
|
||||
## Test setup
|
||||
|
||||
The project uses **Vitest + jsdom** with **React Testing Library** and **jest-dom** matchers. Global setup lives in `src/test/setup.ts` and mocks these browser APIs that jsdom doesn't provide (or that Node's built-ins conflict with):
|
||||
|
||||
- `localStorage` — a Map-backed mock, because Node 22's built-in `localStorage` lacks the Web Storage API surface jsdom expects
|
||||
- `window.matchMedia`
|
||||
- `window.scrollTo`
|
||||
- `IntersectionObserver`
|
||||
- `ResizeObserver`
|
||||
|
||||
If your component needs another browser API, extend `src/test/setup.ts` rather than mocking per-file.
|
||||
|
||||
## Writing a component test
|
||||
|
||||
Wrap rendered components in `TestApp` (`src/test/TestApp.tsx`) so all context providers — `UnheadProvider`, `AppProvider`, `QueryClientProvider`, `NostrLoginProvider`, `NostrProvider`, `BrowserRouter`, etc. — are available. Without it, hooks like `useQuery`, `useNostr`, `useAppContext`, or `useNavigate` will throw.
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TestApp } from '@/test/TestApp';
|
||||
import { MyComponent } from './MyComponent';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<TestApp><MyComponent /></TestApp>);
|
||||
expect(screen.getByText('Expected text')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Writing a hook test
|
||||
|
||||
Use `renderHook` from `@testing-library/react` and pass `TestApp` as the `wrapper`:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { TestApp } from '@/test/TestApp';
|
||||
import { useMyHook } from './useMyHook';
|
||||
|
||||
describe('useMyHook', () => {
|
||||
it('returns expected data', async () => {
|
||||
const { result } = renderHook(() => useMyHook(), { wrapper: TestApp });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Files placed next to the code under test with the `.test.ts` / `.test.tsx` suffix are picked up automatically. For reference, see `src/test/ErrorBoundary.test.tsx`.
|
||||
|
||||
## Running tests
|
||||
|
||||
The `npm test` script runs `tsc --noEmit`, `eslint`, `vitest run`, and `vite build` in sequence. Always run it after changes — a passing test file alone doesn't mean your task is done.
|
||||
|
||||
For fast iteration, run just Vitest:
|
||||
|
||||
```bash
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
Or in watch mode while editing:
|
||||
|
||||
```bash
|
||||
npx vitest
|
||||
```
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: theming
|
||||
description: Customize Ditto's visual design — install Google Fonts via @fontsource, change the color scheme, configure light/dark themes, and apply consistent component styling patterns with Tailwind and CSS variables.
|
||||
---
|
||||
|
||||
# Theming, Fonts, and Color Schemes
|
||||
|
||||
Use this skill when the user wants to change fonts, colors, light/dark appearance, or general visual styling. Ditto ships with a light/dark theme system built on CSS custom properties and Tailwind v3, plus a `useTheme` hook for runtime switching.
|
||||
|
||||
## Adding Fonts
|
||||
|
||||
Any Google Font can be installed via the `@fontsource` / `@fontsource-variable` packages.
|
||||
|
||||
1. **Install the font package.** Prefer the variable version when available.
|
||||
```bash
|
||||
npm install @fontsource-variable/inter
|
||||
```
|
||||
Package naming:
|
||||
- `@fontsource-variable/<font-name>` — variable fonts (preferred; one file, all weights)
|
||||
- `@fontsource/<font-name>` — static fonts
|
||||
|
||||
2. **Import the font once** in `src/main.tsx`:
|
||||
```ts
|
||||
import '@fontsource-variable/inter';
|
||||
```
|
||||
|
||||
3. **Register the family** in `tailwind.config.ts`:
|
||||
```ts
|
||||
export default {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Suggested families by use case
|
||||
|
||||
- **Modern / Clean:** Inter Variable, Outfit Variable, Manrope
|
||||
- **Professional / Corporate:** Roboto, Open Sans, Source Sans Pro
|
||||
- **Creative / Artistic:** Poppins, Nunito, Comfortaa
|
||||
- **Monospace / Code:** JetBrains Mono, Fira Code, Source Code Pro
|
||||
|
||||
For expressive hierarchies, pair a sans body font with a display/serif heading font (e.g. Inter + Playfair Display) and expose the second family as `fontFamily.serif` or `fontFamily.display` in Tailwind.
|
||||
|
||||
### Runtime font loading from Nostr events
|
||||
|
||||
Ditto also supports loading fonts referenced from Nostr events (theme events, letter stationery, etc.) through `src/lib/fontLoader.ts`. That path is separate from the build-time `@fontsource` approach — it constructs `@font-face` rules at runtime from sanitized URLs. Never feed event data through the `@fontsource` path; always go through `fontLoader` so the URL and family name are passed through `sanitizeUrl()` and `sanitizeCssString()` (see the `nostr-security` skill).
|
||||
|
||||
## Color Schemes
|
||||
|
||||
Colors are defined as CSS custom properties in `src/index.css` under two selectors:
|
||||
|
||||
- `:root` — light-mode values
|
||||
- `.dark` — dark-mode overrides
|
||||
|
||||
When the user requests a new color scheme:
|
||||
|
||||
1. **Update both `:root` and `.dark`** in `src/index.css`. Each variable is an HSL triplet (no `hsl()` wrapper), e.g. `--primary: 222 47% 11%;`.
|
||||
2. **Keep contrast ratios ≥ 4.5:1** for body text and interactive elements. Test both modes.
|
||||
3. **Prefer extending Tailwind's palette** (`tailwind.config.ts`) over hard-coding hex values in components — this keeps the theme consistent and dark-mode-friendly.
|
||||
4. **Apply colors through semantic tokens** (`bg-primary`, `text-muted-foreground`, `border-input`) rather than raw palette names when possible, so future theme changes propagate.
|
||||
|
||||
The shadcn/ui components consume these semantic tokens, so changing the variables automatically restyles the entire component library.
|
||||
|
||||
## Light/Dark Theme Switching
|
||||
|
||||
Ditto includes:
|
||||
|
||||
- **`useTheme` hook** (`src/hooks/useTheme.ts`) — read and set the current theme programmatically.
|
||||
- **CSS custom properties** in `src/index.css` — one set in `:root`, dark overrides in `.dark`.
|
||||
- **Automatic persistence** via the `AppContext` config (`config.theme`), saved to local storage.
|
||||
|
||||
To add a theme toggle:
|
||||
|
||||
```tsx
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Component Styling Patterns
|
||||
|
||||
- **Class merging:** use the `cn()` utility (`@/lib/utils`) to combine conditional classes and override defaults without class-order bugs.
|
||||
- **Variants:** follow shadcn/ui's `class-variance-authority` pattern for component variants (`variant`, `size`). Copy an existing `ui/` component as a template.
|
||||
- **Responsive design:** lean on Tailwind breakpoints (`sm:`, `md:`, `lg:`) rather than JS media queries. Use `useIsMobile` only when layout must change based on JS-measured viewport.
|
||||
- **Interactive states:** always define `hover:`, `focus-visible:`, and `disabled:` states for clickable elements. Focus rings should use `ring-ring` / `ring-offset-background` so they pick up theme colors.
|
||||
- **Spacing:** an 8px grid (Tailwind's default 4-based scale) keeps visual rhythm consistent. Common paddings: `p-4`, `p-6`; gaps: `gap-2`, `gap-4`.
|
||||
- **Depth:** soft shadows (`shadow-sm`, `shadow-md`), subtle gradients, and `rounded-lg` / `rounded-xl` corners match Ditto's aesthetic. Avoid heavy drop shadows.
|
||||
|
||||
### Negative z-index gotcha
|
||||
|
||||
When placing decorative elements behind content with `-z-10` (e.g. blurred background gradients), **add `isolate` to the parent container**. Without `isolate`, the negative z-index escapes the local stacking context and the element disappears behind the page's background color.
|
||||
|
||||
```tsx
|
||||
<section className="relative isolate">
|
||||
<div className="absolute inset-0 -z-10 bg-gradient-to-br from-primary/20 to-transparent" />
|
||||
{/* content */}
|
||||
</section>
|
||||
```
|
||||
|
||||
## Design Quality Checklist
|
||||
|
||||
Before finishing a visual change, verify:
|
||||
|
||||
- [ ] Both light and dark modes look correct — no hard-coded colors, all text readable.
|
||||
- [ ] Contrast ratios meet WCAG AA (≥ 4.5:1 for body, ≥ 3:1 for large text).
|
||||
- [ ] Interactive elements have visible `hover`, `focus-visible`, and `disabled` states.
|
||||
- [ ] Layout is responsive down to ~360px width without horizontal scroll.
|
||||
- [ ] Animations respect `prefers-reduced-motion` (Tailwind: `motion-safe:` / `motion-reduce:`).
|
||||
- [ ] Spacing is consistent — no one-off `p-[13px]` style values.
|
||||
@@ -3,5 +3,9 @@ VITE_PLAUSIBLE_DOMAIN="example.tld"
|
||||
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
|
||||
# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey")
|
||||
VITE_NOSTR_PUSH_PUBKEY=""
|
||||
# Canonical origin used when generating shareable URLs (QR codes, copy-link, remote-login callbacks).
|
||||
# Primarily useful for native (Capacitor) builds, where window.location.origin is capacitor://localhost.
|
||||
# Example: VITE_SHARE_ORIGIN="https://ditto.pub"
|
||||
VITE_SHARE_ORIGIN=""
|
||||
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
|
||||
# ALLOWED_HOSTS="*"
|
||||
@@ -11,6 +11,8 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.eslintcache
|
||||
.tsbuildinfo
|
||||
yarn.lock
|
||||
deploy.sh
|
||||
|
||||
|
||||
+193
-14
@@ -77,6 +77,39 @@ build-web:
|
||||
paths:
|
||||
- dist/
|
||||
|
||||
release-notes:
|
||||
stage: build
|
||||
timeout: 2 minutes
|
||||
needs: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
# Extract release notes from CHANGELOG.md for this tag.
|
||||
# release-notes.md is the full section (summary + bulleted lists), used as
|
||||
# the GitLab Release description. release-notes-summary.txt is the leading
|
||||
# plaintext paragraph only, used as the App Store / Play Store release
|
||||
# blurb. Falls back to "Ditto vX.Y.Z" when the section has no summary.
|
||||
- mkdir -p artifacts
|
||||
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" > artifacts/release-notes.md
|
||||
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" --summary > artifacts/release-notes-summary.txt
|
||||
- echo "--- release-notes.md ---"
|
||||
- cat artifacts/release-notes.md
|
||||
- echo "--- release-notes-summary.txt (length $(wc -c < artifacts/release-notes-summary.txt)) ---"
|
||||
- cat artifacts/release-notes-summary.txt
|
||||
- echo "------------------------"
|
||||
# Warn (don't fail) when the summary exceeds the documented 500-character
|
||||
# limit so the user spots it before App Store / Play Store reject the upload.
|
||||
- |
|
||||
SUMMARY_LEN=$(wc -c < artifacts/release-notes-summary.txt)
|
||||
if [ "$SUMMARY_LEN" -gt 501 ]; then
|
||||
echo "WARNING: release-notes-summary.txt is $SUMMARY_LEN bytes; convention is <=500."
|
||||
fi
|
||||
artifacts:
|
||||
paths:
|
||||
- artifacts/release-notes.md
|
||||
- artifacts/release-notes-summary.txt
|
||||
expire_in: 90 days
|
||||
|
||||
build-apk:
|
||||
stage: build
|
||||
image: eclipse-temurin:21-jdk
|
||||
@@ -183,28 +216,99 @@ build-apk:
|
||||
- android/.gradle/
|
||||
- .gradle/
|
||||
|
||||
build-ipa:
|
||||
stage: build
|
||||
tags:
|
||||
- macos
|
||||
timeout: 20 minutes
|
||||
needs: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
LANG: en_US.UTF-8
|
||||
LC_ALL: en_US.UTF-8
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
before_script:
|
||||
# PATH is set up via ~/.bash_profile on the runner host (brew + Ruby 3.3 + user gems)
|
||||
- node --version
|
||||
- ruby --version
|
||||
- fastlane --version | head -3
|
||||
|
||||
# Decode the App Store Connect API key (.p8) into a private location.
|
||||
# The Fastfile reads this directly via File.binread. We pass the API
|
||||
# key into match so it contacts Apple's portal to verify the cert is
|
||||
# still valid for the team — fails fast on a revoked / expired cert.
|
||||
- mkdir -p "$HOME/.private_keys"
|
||||
- chmod 700 "$HOME/.private_keys"
|
||||
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
|
||||
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
|
||||
- chmod 600 "$ASC_KEY_PATH"
|
||||
# Avoid env-var collision: match's APP_STORE_CONNECT_API_KEY_PATH expects
|
||||
# a JSON descriptor; we pass the API key inline via the Fastfile.
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
|
||||
# Build web assets and sync to Capacitor iOS project
|
||||
- npm ci
|
||||
- npx vite build -l error
|
||||
- cp dist/index.html dist/404.html
|
||||
- npx cap sync ios
|
||||
- node scripts/patch-cap-config.mjs
|
||||
script:
|
||||
# Stamp marketing version from the git tag (e.g. v2.1.0 -> 2.1.0)
|
||||
- VERSION="${CI_COMMIT_TAG#v}"
|
||||
- echo "Building iOS version $VERSION (build ${CI_PIPELINE_IID}) from tag $CI_COMMIT_TAG"
|
||||
- >-
|
||||
/usr/bin/sed -i ''
|
||||
"s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g"
|
||||
ios/App/App.xcodeproj/project.pbxproj
|
||||
|
||||
# Run match (cert verify + decrypt) and build_app to produce the IPA.
|
||||
# build_app writes ./artifacts/Ditto.ipa relative to the project root.
|
||||
- cd ios
|
||||
- fastlane build_ipa
|
||||
- cd ..
|
||||
|
||||
# Move the IPA to a stable name in the artifact directory.
|
||||
- ls -lh artifacts/
|
||||
- test -f artifacts/Ditto.ipa
|
||||
|
||||
# Upload to the Generic Packages registry for a stable public download URL,
|
||||
# mirroring how build-apk publishes the APK and AAB.
|
||||
- |
|
||||
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
|
||||
--upload-file "artifacts/Ditto.ipa" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa"
|
||||
after_script:
|
||||
# Wipe the API key so nothing sensitive sticks around between jobs.
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
artifacts:
|
||||
paths:
|
||||
- artifacts/Ditto.ipa
|
||||
expire_in: 90 days
|
||||
|
||||
release:
|
||||
stage: release
|
||||
image: registry.gitlab.com/gitlab-org/release-cli:latest
|
||||
needs:
|
||||
- build-apk
|
||||
- job: build-apk
|
||||
artifacts: false
|
||||
- job: build-ipa
|
||||
artifacts: false
|
||||
- job: release-notes
|
||||
artifacts: true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
- echo "Creating release for $CI_COMMIT_TAG"
|
||||
# Extract the latest changelog section for the release description.
|
||||
# Reads from "## [version]" to the next "## [" or end of file.
|
||||
- |
|
||||
VERSION="${CI_COMMIT_TAG#v}"
|
||||
RELEASE_NOTES=$(awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md)
|
||||
if [ -z "$RELEASE_NOTES" ]; then
|
||||
RELEASE_NOTES="Agora ${CI_COMMIT_TAG}"
|
||||
fi
|
||||
- echo "$RELEASE_NOTES" > release-notes.md
|
||||
- test -f artifacts/release-notes.md
|
||||
- echo "--- release-notes.md ---"
|
||||
- cat artifacts/release-notes.md
|
||||
- echo "------------------------"
|
||||
release:
|
||||
tag_name: $CI_COMMIT_TAG
|
||||
name: $CI_COMMIT_TAG
|
||||
description: './release-notes.md'
|
||||
description: './artifacts/release-notes.md'
|
||||
assets:
|
||||
links:
|
||||
- name: Agora-${CI_COMMIT_TAG}.apk
|
||||
@@ -213,6 +317,9 @@ release:
|
||||
- name: Agora-${CI_COMMIT_TAG}.aab
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.aab
|
||||
link_type: package
|
||||
- name: Ditto-${CI_COMMIT_TAG}.ipa
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa
|
||||
link_type: package
|
||||
|
||||
publish-zapstore:
|
||||
stage: publish
|
||||
@@ -244,7 +351,10 @@ publish-google-play:
|
||||
stage: publish
|
||||
image: ruby:3.3
|
||||
needs:
|
||||
- build-apk
|
||||
- job: build-apk
|
||||
artifacts: true
|
||||
- job: release-notes
|
||||
artifacts: true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
@@ -253,18 +363,87 @@ publish-google-play:
|
||||
# Decode base64-encoded service account JSON to a temp file
|
||||
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json
|
||||
|
||||
# Upload the AAB to Google Play production track
|
||||
# Build the fastlane supply metadata layout for the changelog.
|
||||
# supply maps changelogs/<versionCode>.txt to the Play Console "What's
|
||||
# new in this version" field. versionCode matches what build-apk stamped
|
||||
# into build.gradle (= CI_PIPELINE_IID).
|
||||
- VERSION_CODE="${CI_PIPELINE_IID}"
|
||||
- CHANGELOG_DIR="android/fastlane/metadata/android/en-US/changelogs"
|
||||
- mkdir -p "$CHANGELOG_DIR"
|
||||
- cp artifacts/release-notes-summary.txt "${CHANGELOG_DIR}/${VERSION_CODE}.txt"
|
||||
- echo "--- ${CHANGELOG_DIR}/${VERSION_CODE}.txt ---"
|
||||
- cat "${CHANGELOG_DIR}/${VERSION_CODE}.txt"
|
||||
- echo "-------------------------------------------"
|
||||
|
||||
# Upload the AAB to Google Play production track with the changelog.
|
||||
- >-
|
||||
fastlane supply
|
||||
--aab artifacts/Agora.aab
|
||||
--package_name pub.agora.app
|
||||
--track production
|
||||
--json_key /tmp/play-service-account.json
|
||||
--metadata_path android/fastlane/metadata/android
|
||||
--skip_upload_metadata
|
||||
--skip_upload_changelogs
|
||||
--skip_upload_images
|
||||
--skip_upload_screenshots
|
||||
--skip_upload_apk
|
||||
|
||||
# Clean up
|
||||
- rm -f /tmp/play-service-account.json
|
||||
|
||||
publish-app-store:
|
||||
stage: publish
|
||||
# Runs on the self-hosted Mac runner, same as build-ipa. fastlane's `deliver`
|
||||
# action shells out to Apple's iTMSTransporter / altool to upload the IPA
|
||||
# binary, and those tools ship inside Xcode. On a generic Linux container
|
||||
# the upload step crashes with `No such file or directory @ dir_chdir0`
|
||||
# because `Helper.itms_path` resolves to a path inside Xcode that doesn't
|
||||
# exist. The IPA is already signed in `build-ipa`; we just need an Apple
|
||||
# tool to push it, which means macOS.
|
||||
tags:
|
||||
- macos
|
||||
needs:
|
||||
- job: build-ipa
|
||||
artifacts: true
|
||||
- job: release-notes
|
||||
artifacts: true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
LANG: en_US.UTF-8
|
||||
LC_ALL: en_US.UTF-8
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
before_script:
|
||||
# PATH is set up via ~/.bash_profile on the runner host (brew + Ruby 3.3 + user gems)
|
||||
- ruby --version
|
||||
- fastlane --version | head -3
|
||||
|
||||
# Decode the App Store Connect API key (.p8) into a private location.
|
||||
# The Fastfile reads this directly via File.binread.
|
||||
- mkdir -p "$HOME/.private_keys"
|
||||
- chmod 700 "$HOME/.private_keys"
|
||||
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
|
||||
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
|
||||
- chmod 600 "$ASC_KEY_PATH"
|
||||
# Avoid env-var collision: match's APP_STORE_CONNECT_API_KEY_PATH expects
|
||||
# a JSON descriptor; we pass the API key inline via the Fastfile.
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
script:
|
||||
- test -f artifacts/Ditto.ipa
|
||||
- test -f artifacts/release-notes-summary.txt
|
||||
|
||||
# Use the release summary paragraph as the App Store "What's New" text.
|
||||
# Generated by the release-notes job from CHANGELOG.md.
|
||||
- mkdir -p ios/fastlane/metadata/en-US
|
||||
- cp artifacts/release-notes-summary.txt ios/fastlane/metadata/en-US/release_notes.txt
|
||||
- echo "--- release_notes.txt ---"
|
||||
- cat ios/fastlane/metadata/en-US/release_notes.txt
|
||||
- echo "-------------------------"
|
||||
|
||||
# Submit the prebuilt IPA from build-ipa to App Store Connect for review.
|
||||
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Ditto.ipa"
|
||||
- cd ios
|
||||
- fastlane submit_release
|
||||
after_script:
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Thanks for contributing to Ditto! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
|
||||
Thanks for contributing to Agora! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
|
||||
|
||||
## Related Issue
|
||||
|
||||
@@ -29,9 +29,9 @@ Closes #
|
||||
## Philosophy Alignment
|
||||
|
||||
<!-- Answer this question for your change: -->
|
||||
<!-- "Does this make Ditto more magnetic, more threatening to the status quo, -->
|
||||
<!-- "Does this make Agora more magnetic, more threatening to the status quo, -->
|
||||
<!-- and more peaceful to inhabit?" -->
|
||||
<!-- See: https://about.ditto.pub/philosophy -->
|
||||
<!-- See: CONTRIBUTING.md -> "Understanding Agora" -->
|
||||
<!-- For bug fixes: "Bug fix -- restores intended behavior" is acceptable. -->
|
||||
|
||||
## How to Test
|
||||
@@ -50,7 +50,7 @@ Closes #
|
||||
### Process
|
||||
|
||||
- [ ] I read `AGENTS.md` before starting
|
||||
- [ ] I read the [Ditto Philosophy](https://about.ditto.pub/philosophy)
|
||||
- [ ] I read "Understanding Agora" in `CONTRIBUTING.md`
|
||||
- [ ] I used plan/research mode before writing code
|
||||
- [ ] I used Claude Opus 4.6 (or equivalent frontier model)
|
||||
|
||||
|
||||
+2
-445
@@ -1,450 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## [2.8.0] - 2026-04-16
|
||||
## [1.0.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
- Back up your secret key right from Profile settings -- reveal, copy, and save it to iCloud Keychain, Android Credential Manager, or a local file
|
||||
- Blobbi mission progress now persists across page refreshes, so your hatching and evolution journey picks up right where you left off
|
||||
|
||||
### Changed
|
||||
- AI chat has been overhauled with a cleaner layout, the Dork mascot across empty states, and a clear path to grab Shakespeare credits when you run out
|
||||
- Friendly error banners now explain when you've hit the rate limit or run out of AI credits, instead of cryptic failures
|
||||
|
||||
### Fixed
|
||||
- Avatar shape selection during signup now actually saves to your profile
|
||||
- Blobbi interaction missions now tally correctly the moment you start incubating or evolving
|
||||
- Blobbi task progress displays the right numbers immediately on page load instead of showing 0 until everything catches up
|
||||
|
||||
## [2.7.1] - 2026-04-16
|
||||
|
||||
### Added
|
||||
- Tap the Home tab while already on Home to scroll to the top and refresh your feed
|
||||
- Blobbi hatch and evolve missions now count your existing posts, themes, and color moments retroactively -- no need to start from scratch
|
||||
- New Blobbis begin incubating and evolving immediately after adoption, so every care action counts toward your next milestone
|
||||
|
||||
### Changed
|
||||
- Signup's save-key step is clearer: the button now reads "Save Key", shows a spinner while saving, and warns you before the key is revealed on screen
|
||||
- On de-Googled Android devices without a password manager, your key now safely falls back to a file in the app's Documents folder
|
||||
- Wallet connections and device keys are now stored in the iOS Keychain and Android KeyStore for stronger at-rest protection
|
||||
- Android's automatic cloud backup now excludes your wallet credentials
|
||||
|
||||
### Fixed
|
||||
- Scroll position is preserved when you navigate back from a post, profile, or any other page -- no more getting bounced to the top of your feed
|
||||
- Custom saved feeds now cache content and support infinite scroll like the Home, Ditto, and Global feeds
|
||||
- Various security hardening across themes, letters, profile banners, direct messages, and sandboxed apps to protect against malformed data
|
||||
|
||||
## [2.7.0] - 2026-04-14
|
||||
|
||||
### Added
|
||||
- Customizable widget sidebar -- drag, drop, and rearrange widgets on your feed including Trending, Hot Posts, Bluesky, AI Chat, Blobbi, Music, Photos, Wikipedia, and more
|
||||
- Blobbi rooms -- swipe between living spaces, clean up after your pet, and earn XP from daily care routines
|
||||
- Native push notifications on iOS with author names, content previews, and smart grouping by category
|
||||
- Haptic feedback throughout the app -- taps, buzzes, and pulses when you react, zap, repost, pull to refresh, play games, and interact with your Blobbi
|
||||
- Hot Posts widget showing the most popular posts from your feed at a glance
|
||||
|
||||
### Changed
|
||||
- Sidebar widgets are now clickable links that take you to their full pages
|
||||
- Blobbi widget shows live stats with circular ring indicators and quick action buttons
|
||||
|
||||
### Fixed
|
||||
- Zaps embedded in posts now render as proper inline cards instead of blank space
|
||||
- Quote posts display media and Blobbi companions correctly
|
||||
- Deep linking on Google Play works again
|
||||
- Game controller buttons no longer trigger text selection on long-press on iOS
|
||||
|
||||
## [2.6.6] - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
|
||||
- Emoji shortcodes now render as color emoji instead of plain text glyphs
|
||||
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
|
||||
- Signing requests on Android are more reliable and no longer silently fail after switching apps
|
||||
|
||||
## [2.6.5] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
|
||||
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
|
||||
|
||||
### Fixed
|
||||
- External API requests on Android no longer fail due to hostname restrictions
|
||||
- iOS App Store compliance issues resolved
|
||||
|
||||
## [2.6.4] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
|
||||
|
||||
### Changed
|
||||
- Empty feeds show a friendlier state with a discover button to help you find people to follow
|
||||
- Signup flow simplified -- cleaner profile step with a single Continue button
|
||||
|
||||
### Fixed
|
||||
- Avatar fallback now shows the user's initial instead of a question mark
|
||||
- Android 16+ devices no longer have content hidden behind system bars
|
||||
- Signup dialog background clears properly when switching between light and dark themes
|
||||
- Sticky compose button stays anchored to the bottom even on empty feeds
|
||||
|
||||
## [2.6.3] - 2026-04-10
|
||||
|
||||
### Added
|
||||
- Lightning invoices embedded in posts now render as tappable payment cards
|
||||
- Blobbi companions in the feed reflect their current condition and projected health
|
||||
|
||||
### Changed
|
||||
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
|
||||
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
|
||||
- "Request to Vanish" renamed to "Delete Account" for clarity
|
||||
|
||||
### Fixed
|
||||
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
|
||||
- Security hardening for URLs and styles sourced from the network
|
||||
|
||||
## [2.6.2] - 2026-04-08
|
||||
|
||||
### Added
|
||||
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
|
||||
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
|
||||
- "Write a letter" option on profile menus for a more personal way to reach out
|
||||
- Push vs persistent notification delivery option on Android
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps always open fullscreen for a more immersive experience
|
||||
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
|
||||
- Profile fields now appear inline instead of in a separate right sidebar
|
||||
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
|
||||
|
||||
### Fixed
|
||||
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
|
||||
- File downloads now save directly to Documents on iOS and Android instead of silently failing
|
||||
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
|
||||
- iOS swipe-back navigation works correctly throughout the app
|
||||
- Blobbi companions appear reliably on profiles instead of sometimes going missing
|
||||
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
- Manage your interest tabs (hashtags and locations) from the settings page
|
||||
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
|
||||
- Follow packs and follow sets now show author info and action headers in the feed
|
||||
- Posts now show whether they were created or updated, so you can tell when something's been edited
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
|
||||
- Nsite previews now use the same secure sandbox as webxdc apps
|
||||
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
|
||||
|
||||
### Fixed
|
||||
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
|
||||
- Mobile compose box no longer randomly collapses or becomes unclickable
|
||||
- Profile avatar and banner lightbox no longer hides behind the right sidebar
|
||||
- Infinite scroll on custom profile tab feeds no longer reloads the same content
|
||||
- Reaction emoji are now visible on each row in the interactions modal
|
||||
- Missing bottom border on collapsed thread expand button restored
|
||||
|
||||
## [2.6.0] - 2026-04-05
|
||||
|
||||
### Added
|
||||
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
|
||||
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
|
||||
|
||||
### Changed
|
||||
- Footer links redesigned as compact icon chips for a cleaner look
|
||||
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
|
||||
|
||||
### Fixed
|
||||
- Custom themes now apply correctly when logging in on a new device
|
||||
- Settings and preferences sync reliably across devices
|
||||
- Mobile sidebar links no longer clip into the safe area
|
||||
- Blobbi page background overlay now appears properly on custom themes
|
||||
- Blobbi companion state no longer resets unexpectedly from stale cache data
|
||||
- Letter compose picker no longer gets hidden behind the top navigation arc
|
||||
|
||||
## [2.5.2] - 2026-04-04
|
||||
|
||||
### Added
|
||||
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
|
||||
- Poll votes now appear as activity cards in feeds and on detail pages
|
||||
|
||||
### Fixed
|
||||
- Threads and replies load more reliably by following relay and author hints when fetching parent events
|
||||
|
||||
## [2.5.1] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Lightbox now reliably appears above all content, not just when opened from photo galleries
|
||||
|
||||
## [2.5.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
|
||||
- File uploads in the poll composer -- attach images and media to your polls
|
||||
- Blobbi posts now appear in the homepage feed
|
||||
|
||||
### Changed
|
||||
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
|
||||
- App cards now show banner images and improved layout
|
||||
|
||||
### Fixed
|
||||
- Lightbox no longer appears behind the right sidebar
|
||||
- Compose box corners are properly rounded
|
||||
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
|
||||
|
||||
## [2.4.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
|
||||
|
||||
### Fixed
|
||||
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
|
||||
|
||||
### Fixed
|
||||
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
|
||||
- Editing an existing article no longer incorrectly warns about a duplicate slug
|
||||
- Switching between rich text and markdown source mode no longer clears your content
|
||||
- Fix crash when editing in markdown source mode
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
|
||||
- Blobbi companions now appear in feeds and post detail pages
|
||||
|
||||
### Changed
|
||||
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
|
||||
- Emoji packs without any valid emojis are now hidden from feeds
|
||||
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
|
||||
|
||||
## [2.2.8] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
|
||||
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
|
||||
|
||||
### Changed
|
||||
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
|
||||
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
|
||||
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
|
||||
|
||||
### Fixed
|
||||
- Notification dot not clearing after marking notifications as read
|
||||
- Followers/following modal staying open after navigating to a profile
|
||||
|
||||
## [2.2.7] - 2026-03-31
|
||||
|
||||
### Fixed
|
||||
- Nushu script in encrypted letters now renders correctly on Android and iOS
|
||||
|
||||
## [2.2.6] - 2026-03-31
|
||||
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
- Badge detail page streamlined with unified tab bar
|
||||
|
||||
### Fixed
|
||||
- Hashtags now support accented and Unicode characters
|
||||
- Letter compose opens correctly from notifications and the letters page
|
||||
- Letter font picker loads fonts so each option previews in the correct typeface
|
||||
- Zap comment positioned inside the right column instead of floating with offset
|
||||
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
|
||||
|
||||
## [2.2.5] - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
- Crash when dragging profile tabs to reorder them
|
||||
|
||||
## [2.2.4] - 2026-03-30
|
||||
|
||||
### Changed
|
||||
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
|
||||
- Zap moved to the profile overflow menu so it's still one tap away
|
||||
|
||||
### Fixed
|
||||
- Crash on the notifications page caused by malformed badge award tags
|
||||
- Deleting a badge now also deletes all awards you issued for it
|
||||
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
|
||||
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
|
||||
- Profile reactions no longer collapse into a single grouped notification
|
||||
- Oversized reaction emoji in comment context headers
|
||||
|
||||
## [2.2.3] - 2026-03-30
|
||||
|
||||
### Added
|
||||
- Letters now have an overflow menu, reply button, and a grid layout for browsing
|
||||
- Independent feed toggles for comments and generic reposts in content settings
|
||||
- Sidebar items are now visible to logged-out users so newcomers can explore everything
|
||||
|
||||
### Changed
|
||||
- Compose textarea expands smoothly as you type instead of snapping to a new height
|
||||
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
|
||||
|
||||
### Fixed
|
||||
- Feed gaps when replies are disabled no longer cause missing posts
|
||||
- Avatar shape no longer flashes on load
|
||||
- Top bar arc no longer flickers during navigation transitions
|
||||
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
|
||||
- Notification rendering for badges and letters
|
||||
- Duplicate React keys in content settings
|
||||
- Layout rendering warning when switching views
|
||||
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
- Changelog link in the app footer
|
||||
|
||||
### Changed
|
||||
- "Vines" renamed to "Divines" everywhere in the app
|
||||
- Custom emojis appear first in the emoji picker, right after recent
|
||||
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
|
||||
|
||||
### Fixed
|
||||
- Delete post dialog no longer freezes the feed on desktop
|
||||
- Amber login on Android now properly retries when returning from the background
|
||||
- Key downloads on Android save to the correct location
|
||||
- Custom emoji SVGs render correctly in the emoji-mart picker
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Event links now route correctly for all event types
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
- Inconsistent use of "geocache" vs "treasures" terminology
|
||||
- Search page "N new posts" pill no longer shows unfiltered count
|
||||
- Stale-cache overwrites in replaceable event mutations
|
||||
- Click-through on delete confirmation and note menu items
|
||||
|
||||
## [2.2.1] - 2026-03-28
|
||||
|
||||
### Fixed
|
||||
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
|
||||
- Mobile header no longer shows double-layered backgrounds on notched devices
|
||||
- Pinned tabs stay properly positioned when scrolling on mobile
|
||||
- Signer approval toasts no longer fire in rapid succession on unstable connections
|
||||
- Toasts are easier to swipe away on mobile
|
||||
- Content warnings now blur thumbnails in the media grid
|
||||
|
||||
## [2.2.0] - 2026-03-28
|
||||
|
||||
### Added
|
||||
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
|
||||
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- Relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
### Changed
|
||||
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
|
||||
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
|
||||
- Upgraded from React 18 to React 19
|
||||
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
|
||||
|
||||
### Fixed
|
||||
- Zapping Primal users no longer produces an error
|
||||
- Hashtag feeds now match case-insensitively for parity with search results
|
||||
- Mobile top bar arc no longer lingers on pages without tabs
|
||||
- Give Badge dialog and profile menu action handlers
|
||||
|
||||
## [2.1.1] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- Emoji picker and shortcode autocomplete in zap comment box
|
||||
- Zap button on badge detail view
|
||||
- Theme descriptions now display on "updated their theme" posts and detail pages
|
||||
- Badge thumbnail previews in award notifications
|
||||
- Letter notifications with envelope card preview
|
||||
- Kind-specific labels in notification text instead of generic "post"
|
||||
|
||||
### Fixed
|
||||
- Compose modal no longer closes when dismissing emoji picker on mobile
|
||||
- Compose preview overflow is now scrollable in modal
|
||||
- Toast notifications swipe up to dismiss on mobile instead of sideways
|
||||
- File downloads and URL opening work correctly on iOS
|
||||
- Badges page no longer shows infinite skeleton when logged out
|
||||
|
||||
## [2.1.0] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
|
||||
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
|
||||
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
|
||||
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
|
||||
- Letters page added to the sidebar with a custom mailbox icon
|
||||
|
||||
## [2.0.1] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Tap the version number in settings to see what's new
|
||||
|
||||
## [2.0.0] - 2026-03-26
|
||||
|
||||
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
|
||||
- Initial Agora 3 release.
|
||||
|
||||
+10
-7
@@ -4,8 +4,8 @@ We welcome contributions, but we have high standards. Agora is a carefully desig
|
||||
|
||||
**Required reading before you start:**
|
||||
|
||||
- [Agora Philosophy](https://agora.spot/philosophy) -- the product vision. Your change must align with it.
|
||||
- [Contributing Guide](https://agora.spot/contributing) -- the upstream contribution process.
|
||||
- [Understanding Agora](#understanding-agora) -- the product vision. Your change must align with it.
|
||||
- This `CONTRIBUTING.md` guide -- the contribution process for this repository.
|
||||
- `AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
|
||||
|
||||
## Understanding Agora
|
||||
@@ -36,7 +36,7 @@ If a change does all three, it belongs. If it only does one, think harder. If it
|
||||
- A place where profiles feel like worlds, not business cards
|
||||
- The most fun you've had on the internet in years
|
||||
|
||||
Read the [full philosophy](https://agora.spot/philosophy) for the complete vision.
|
||||
Read the full "Understanding Agora" section above for the complete vision.
|
||||
|
||||
## What we accept
|
||||
|
||||
@@ -44,13 +44,15 @@ Read the [full philosophy](https://agora.spot/philosophy) for the complete visio
|
||||
|
||||
One bug, one merge request. Fix exactly one thing. Don't bundle unrelated changes, don't sneak in refactors, don't "clean up while you're in there." Small, focused MRs get reviewed fast. Large ones sit.
|
||||
|
||||
When the bug was introduced by an identifiable prior commit, add a `Regression-of: <short-sha>` trailer to the bottom of your commit message. See AGENTS.md "Attributing Regressions" for the convention.
|
||||
|
||||
### New features and significant changes
|
||||
|
||||
Every feature MR must link to an existing open issue and clearly align with the [Agora Philosophy](https://agora.spot/philosophy). The philosophy alignment section in the MR template is where you make the case for why your change belongs in Agora. If you can't articulate that clearly, the change probably doesn't belong.
|
||||
Every feature MR must link to an existing open issue and clearly align with the "Understanding Agora" section in this file. The philosophy alignment section in the MR template is where you make the case for why your change belongs in Agora. If you can't articulate that clearly, the change probably doesn't belong.
|
||||
|
||||
If you have an idea for a feature that doesn't have an issue yet:
|
||||
|
||||
1. Build it as a standalone Nostr app first (see [Contributing Guide](https://agora.spot/contributing)).
|
||||
1. Build it as a standalone Nostr app first (then document traction/feedback in the linked issue).
|
||||
2. Prove it works and get user feedback.
|
||||
3. Open an issue to discuss integration.
|
||||
|
||||
@@ -80,7 +82,7 @@ Read `AGENTS.md` in the repo root. This is the single source of truth for how co
|
||||
|
||||
### 4. Read the philosophy
|
||||
|
||||
Read the [Agora Philosophy](https://agora.spot/philosophy). Agora is a carnival, not a platform. Your change should feel like it belongs in Agora -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
|
||||
Read "Understanding Agora" in this file. Agora is a carnival, not a platform. Your change should feel like it belongs in Agora -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
|
||||
|
||||
### 5. Plan before you code
|
||||
|
||||
@@ -131,6 +133,7 @@ maintain it long-term. For each finding, state the file, line, and issue.
|
||||
- [ ] Are there any new images >100KB or other large binary assets that should be hosted externally?
|
||||
- [ ] Is there any use of dangerouslySetInnerHTML, eval, innerHTML, or SVG string interpolation?
|
||||
- [ ] Is any data from a Nostr event (tags, content, pubkey, URLs) used in a security-sensitive context (href, src, query filter, trust decision) without validation?
|
||||
- [ ] If this is a bug fix and the offending commit is identifiable, does the commit message include a `Regression-of: <short-sha>` trailer? (See AGENTS.md "Attributing Regressions".)
|
||||
|
||||
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
|
||||
|
||||
@@ -163,7 +166,7 @@ Fill out every field in the MR template. Incomplete MRs will not be reviewed.
|
||||
## What gets your MR closed without review
|
||||
|
||||
- No linked issue
|
||||
- Feature MRs with no clear alignment with the [Agora Philosophy](https://agora.spot/philosophy)
|
||||
- Feature MRs with no clear alignment with "Understanding Agora" in this file
|
||||
- Features that fail the product decision filter (not magnetic, not threatening to the status quo, not peaceful)
|
||||
- Incomplete MR template (missing checklist, screenshots, or preview URL)
|
||||
- Changes that go beyond what was asked for (scope creep)
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
COPY package*.json ./
|
||||
COPY .npmrc ./
|
||||
COPY scripts/ ./scripts/
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
| Kind | Name | Description |
|
||||
|-------|----------------------|-------------------------------------------------------|
|
||||
| 36767 | Theme Definition | Shareable, named custom UI theme |
|
||||
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
|
||||
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
|
||||
|
||||
### Agora Kinds
|
||||
@@ -23,7 +21,24 @@
|
||||
|
||||
| Protocol | Composed Kinds | Description |
|
||||
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
|
||||
| Hierarchical Communities | 34550, 30009, 8, 1111, 1984, 5 | Ranked community membership via badge award chains (NIP-72 ext) |
|
||||
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
|
||||
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
|
||||
|
||||
### Community Chat
|
||||
|
||||
Agora uses NIP-53 live chat messages (`kind:1311`) for realtime chat inside a NIP-72 community. Messages are scoped directly to the community definition's address using an `a` tag:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1311,
|
||||
"content": "Hello community!",
|
||||
"tags": [
|
||||
["a", "34550:<community-author-pubkey>:<community-d-tag>", "", "root"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients SHOULD query community chat with `{ "kinds": [1311], "#a": ["34550:<pubkey>:<d-tag>"] }`. Agora treats sending as members-only at the UI layer and applies the same community moderation overlay used for community posts.
|
||||
|
||||
### Community Kinds
|
||||
|
||||
@@ -31,179 +46,151 @@ These event kinds were created by community contributors and are supported by Di
|
||||
|
||||
| Kind | Name | Description | Spec |
|
||||
|-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| 2473 | Bird Detection | Bird-by-ear observation log (species heard in the wild) | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) |
|
||||
| 12473 | Birdex | Author's cumulative life list of confirmed bird species | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) |
|
||||
| 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) |
|
||||
| 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
|
||||
| 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
|
||||
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery | [NIP](https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md) |
|
||||
| 11125 | Blobbonaut Profile | Owner profile with coins, achievements, and inventory | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 14919 | Blobbi Interaction | Individual pet interaction (feed, play, clean, etc.) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 14920 | Blobbi Breeding | Breeding event between two adult Blobbis | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 14921 | Blobbi Record | Immutable lifecycle record (birth, evolution, adoption) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 16158 | Weather Station | Weather station metadata (location, sensors, connectivity) | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
|
||||
| 31124 | Blobbi Pet State | Current state of a virtual Blobbi pet (addressable) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 37516 | Geocache | Geocache listing for real-world treasure hunting | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
|
||||
| 36787 | Music Track | Addressable event for a music audio file with metadata | See [Music Tracks & Playlists](#music-tracks--playlists) below |
|
||||
| 34139 | Music Playlist | Ordered list of music track references (also used for albums) | See [Music Tracks & Playlists](#music-tracks--playlists) below |
|
||||
| 30621 | Custom Constellation | User-drawn star figure with Hipparcos-numbered edges | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) |
|
||||
|
||||
---
|
||||
|
||||
## Kind 36767: Theme Definition
|
||||
## Kind 8333: Onchain Zap
|
||||
|
||||
### Summary
|
||||
|
||||
Addressable event kind for publishing shareable custom UI themes. A single user may publish multiple themes, each identified by a unique `d` tag.
|
||||
Regular event kind that records a **Bitcoin on-chain payment** ("onchain zap") sent in appreciation of a Nostr event or profile. Functions as the on-chain analogue of NIP-57 zap receipts (kind 9735), but without the LNURL round-trip: the event is self-attested by the sender and references a real Bitcoin transaction that clients can verify directly on-chain.
|
||||
|
||||
A theme consists of colors, optional fonts, and an optional background. Colors are stored in `c` tags, fonts in `f` tags, and background in a `bg` tag.
|
||||
The kind number mirrors the convention of NIP-57: kind **9735** is the Lightning P2P port (per BOLT spec), and kind **8333** is the Bitcoin mainnet P2P port — a natural semantic pairing for Lightning vs. on-chain settlement.
|
||||
|
||||
Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) address (both use 32-byte x-only secp256k1 keys, per BIP-340/BIP-341), an on-chain zap is simply a Bitcoin transaction whose output pays the recipient's derived Taproot address. The kind 8333 event links that transaction to the Nostr event or profile being zapped.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 36767,
|
||||
"content": "",
|
||||
"kind": 8333,
|
||||
"pubkey": "<sender-pubkey>",
|
||||
"content": "Great post!",
|
||||
"tags": [
|
||||
["d", "mk-dark-theme"],
|
||||
["c", "#1a1a2e", "background"],
|
||||
["c", "#e0e0e0", "text"],
|
||||
["c", "#6c3ce0", "primary"],
|
||||
["f", "Inter", "https://example.com/inter.woff2", "body"],
|
||||
["f", "Playfair Display", "https://example.com/playfair.woff2", "title"],
|
||||
["bg", "url https://example.com/bg.jpg", "mode cover", "m image/jpeg", "dim 1920x1080"],
|
||||
["title", "MK Dark Theme"],
|
||||
["alt", "Custom theme: MK Dark Theme"]
|
||||
["e", "<target-event-id>", "<relay-hint>"],
|
||||
["p", "<target-pubkey>"],
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["amount", "<sats>"],
|
||||
["alt", "On-chain zap: 25000 sats"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field is unused and MUST be an empty string (`""`).
|
||||
The `content` field is a human-readable comment from the sender (may be empty). It is NOT a zap request JSON (unlike NIP-57 kind 9735).
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---------|----------|---------------------------------------------------------------------------------------|
|
||||
| `d` | Yes | Unique identifier (slug) for this theme, e.g. `"mk-dark-theme"` |
|
||||
| `c` | Yes (×3) | Hex color with marker. See [Color Tags](#color-tags). |
|
||||
| `f` | No | Font declaration. See [Font Tag](#font-tag). |
|
||||
| `bg` | No | Background media. See [Background Tag](#background-tag). |
|
||||
| `title` | Yes | Human-readable theme name |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback |
|
||||
| Tag | Required | Description |
|
||||
|----------|----------|----------------------------------------------------------------------------------------------|
|
||||
| `i` | Yes | NIP-73 external content identifier. MUST be `bitcoin:tx:<txid>` where `<txid>` is a 64-char lowercase hex Bitcoin transaction ID. |
|
||||
| `p` | Yes | 32-byte hex pubkey of the zap **recipient** (the author being paid). |
|
||||
| `amount` | Yes | Amount paid to the recipient in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the recipient's derived Taproot address — *not* the total tx value. |
|
||||
| `e` | If zapping an event | 32-byte hex ID of the event being zapped. Include a relay hint as the 3rd element where possible. |
|
||||
| `a` | If zapping an addressable event | Addressable event coordinate `<kind>:<pubkey>:<d-tag>`. Used instead of (or alongside) `e` for kinds 30000–39999. |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback. |
|
||||
|
||||
### Multiple Themes Per User
|
||||
If neither `e` nor `a` is present, the zap targets the recipient's **profile** (i.e. a tip to the pubkey, not to a specific event).
|
||||
|
||||
Since kind 36767 is addressable, a user can publish multiple themes by using different `d` tag values. Publishing a new event with the same `d` tag replaces the previous version (this is how editing works).
|
||||
### Publishing Flow
|
||||
|
||||
---
|
||||
|
||||
## Kind 16767: Active Profile Theme
|
||||
|
||||
### Summary
|
||||
|
||||
Replaceable event that represents the user's currently active profile theme. Only one per user. When other users visit a profile, they query this kind to determine what theme to display.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 16767,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["c", "#1a1a2e", "background"],
|
||||
["c", "#e0e0e0", "text"],
|
||||
["c", "#6c3ce0", "primary"],
|
||||
["f", "Inter", "https://example.com/inter.woff2", "body"],
|
||||
["f", "Playfair Display", "https://example.com/playfair.woff2", "title"],
|
||||
["bg", "url https://example.com/bg.jpg", "mode cover", "m image/jpeg"],
|
||||
["title", "MK Dark Theme"],
|
||||
["alt", "Active profile theme"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field is unused and MUST be an empty string (`""`).
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|---------|----------|---------------------------------------------------------------------------------------|
|
||||
| `c` | Yes (×3) | Hex color with marker. See [Color Tags](#color-tags). |
|
||||
| `f` | No | Font declaration. See [Font Tag](#font-tag). |
|
||||
| `bg` | No | Background media. See [Background Tag](#background-tag). |
|
||||
| `title` | No | Human-readable name for the theme |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback |
|
||||
1. Sender builds a Bitcoin transaction paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
|
||||
2. Sender broadcasts the transaction to the Bitcoin network and obtains the `txid`.
|
||||
3. Sender signs and publishes a kind 8333 event referencing that `txid` with the appropriate `e`/`a`/`p` tags.
|
||||
4. The event is published **after** broadcast; the txid is already final at that point.
|
||||
|
||||
### Client Behavior
|
||||
|
||||
- When visiting a profile, clients query `{ kinds: [16767], authors: [pubkey], limit: 1 }` to get the active theme.
|
||||
- Clients read the `c` tags to extract colors, `f` tags for fonts, and `bg` tag for the background.
|
||||
- Setting a new active theme publishes a new kind 16767 event (replacing the old one).
|
||||
- To remove the active theme, publish a kind 5 deletion event targeting kind 16767.
|
||||
**Querying onchain zaps for an event:**
|
||||
|
||||
```json
|
||||
{ "kinds": [8333], "#e": ["<target-event-id>"], "limit": 100 }
|
||||
```
|
||||
|
||||
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps, use `"#p": ["<pubkey>"]`.
|
||||
|
||||
**Verification (REQUIRED before trusting amounts):**
|
||||
|
||||
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. To verify:
|
||||
|
||||
1. Extract the txid from the `i` tag.
|
||||
2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API).
|
||||
3. Derive the recipient's expected Taproot address from the `p` tag pubkey.
|
||||
4. Sum the values of all outputs in the transaction that pay that address. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to the recipient.
|
||||
5. If the verified amount is 0, the event SHOULD be discarded.
|
||||
6. If the sender's `amount` tag exceeds the verified amount, clients MAY discard the event or MAY display the smaller verified amount (capping). Clients MUST NOT display or count the claimed amount when it exceeds the verified amount.
|
||||
7. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals. Because unconfirmed transactions can be evicted (RBF, double-spend), clients SHOULD either exclude them from aggregate zap totals or clearly label them as pending.
|
||||
|
||||
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) equals the recipient pubkey from the `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals.
|
||||
|
||||
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per (txid, target) pair is canonical — when multiple events reference the same `txid` for the same target, the earliest is preferred.
|
||||
|
||||
**Network scope:** This specification applies to Bitcoin **mainnet** only. Testnet, signet, and other networks are out of scope; addresses and txids on those networks MUST NOT be used in kind 8333 events.
|
||||
|
||||
### Comparison with NIP-57 (Lightning Zaps)
|
||||
|
||||
| Aspect | NIP-57 (kind 9735) | This spec (kind 8333) |
|
||||
|--------|---------------------|------------------------|
|
||||
| Settlement | Lightning Network | Bitcoin L1 |
|
||||
| Invoice / payment | LNURL + BOLT-11 invoice | Raw Bitcoin tx |
|
||||
| Event issuer | Recipient's LNURL provider | Sender |
|
||||
| Availability | Requires `lud06`/`lud16` on recipient profile | Always available (every Nostr pubkey has a derived Taproot addr) |
|
||||
| Verification | Recipient zap-provider pubkey + bolt11 amount | On-chain tx verified against derived recipient address |
|
||||
| Finality | Instant | Confirms in ~10 min (mempool first) |
|
||||
| Fees | Sub-satoshi typical | Significant at low amounts |
|
||||
|
||||
The two zap kinds are complementary. Clients SHOULD sum verified amounts from both kinds when displaying total zap stats for a post or profile.
|
||||
|
||||
---
|
||||
|
||||
## Shared Tag Definitions
|
||||
## Standard NIPs: Direct Messaging
|
||||
|
||||
The following tag definitions apply to both kind 36767 and kind 16767.
|
||||
This application implements encrypted direct messaging using two standard Nostr protocols:
|
||||
|
||||
### Color Tags
|
||||
### NIP-04 (Legacy Encrypted DMs)
|
||||
|
||||
Format: `["c", "#rrggbb", "<marker>"]`
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Kind | 4 |
|
||||
| Spec | [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) |
|
||||
|
||||
| Index | Required | Description |
|
||||
|-------|----------|-----------------------------------------------------------------------------------------------|
|
||||
| 0 | Yes | Tag name: `"c"` |
|
||||
| 1 | Yes | Lowercase 6-digit hex color code including the `#` sign (e.g. `"#ff0000"`) |
|
||||
| 2 | Yes | Color role marker: one of `"primary"`, `"text"`, or `"background"` |
|
||||
Legacy encrypted direct messages. Content is encrypted with AES-256-CBC using a shared secret derived from the sender's private key and recipient's public key. The recipient is identified by a `p` tag.
|
||||
|
||||
- All three markers (`"primary"`, `"text"`, `"background"`) MUST be present.
|
||||
- Only one `c` tag per marker is allowed.
|
||||
Used for backward compatibility with older Nostr clients that do not support NIP-17.
|
||||
|
||||
### Font Tag
|
||||
### NIP-17 (Private Direct Messages)
|
||||
|
||||
Format: `["f", "<family>", "<url>", "<role>"]`
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Kinds | 1059 (Gift Wrap), 1060 (Seal) |
|
||||
| Spec | [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) |
|
||||
|
||||
| Index | Required | Description |
|
||||
|-------|----------|-----------------------------------------------------------------------------------------------|
|
||||
| 0 | Yes | Tag name: `"f"` |
|
||||
| 1 | Yes | CSS `font-family` name (e.g. `"Inter"`) |
|
||||
| 2 | Yes | Direct URL to a font file (`.woff2`, `.ttf`, `.otf`) |
|
||||
| 3 | Yes | Font role: `"body"` or `"title"` |
|
||||
Modern private direct messages using the Gift Wrap protocol. Messages are triple-layered:
|
||||
|
||||
**Roles:**
|
||||
1. **Rumor** (kind 14) — unsigned plaintext message
|
||||
2. **Seal** (kind 13) — rumor encrypted to the recipient, signed by the sender
|
||||
3. **Gift Wrap** (kind 1059) — seal encrypted to the recipient, signed by a random ephemeral key
|
||||
|
||||
| Role | Applies to |
|
||||
|-----------|--------------------------------------------------|
|
||||
| `"body"` | All text globally (body, headings, UI elements) |
|
||||
| `"title"` | The user's profile display name |
|
||||
This provides metadata protection: relays and observers cannot determine the sender, recipient, or content. The application uses NIP-17 as the default send protocol, with optional NIP-04 compatibility for older clients.
|
||||
|
||||
**Rules:**
|
||||
### Protocol Configuration
|
||||
|
||||
- The `f` tag is optional on the event.
|
||||
- At most one `f` tag per role is allowed (i.e. one body font and one title font).
|
||||
- The `"body"` font tag MUST be ordered before the `"title"` font tag. This ensures backward-compatible clients that only read the first `f` tag will pick up the body font.
|
||||
- If the URL fails to load, the client SHOULD fall back to a default font gracefully.
|
||||
- Clients that do not recognize a role SHOULD ignore that `f` tag.
|
||||
- Legacy events with an `f` tag that has no role marker (only 3 elements) SHOULD be treated as `"body"`.
|
||||
- Variable font files (covering multiple weights in a single file) are preferred.
|
||||
Users can configure their preferred send protocol via Settings > Messages:
|
||||
|
||||
### Background Tag
|
||||
|
||||
The `bg` tag uses an `imeta`-style variadic format where each entry (after the tag name) is a space-delimited key/value pair.
|
||||
|
||||
Format: `["bg", "url <url>", "mode <mode>", "m <mime-type>", ...]`
|
||||
|
||||
| Key | Required | Description |
|
||||
|-------------|----------|------------------------------------------------------------------------------------------|
|
||||
| `url` | Yes | URL to an image or video file |
|
||||
| `mode` | Yes | Display mode: `"cover"` or `"tile"` |
|
||||
| `m` | Yes | MIME type (e.g. `"image/jpeg"`, `"image/png"`, `"video/mp4"`) |
|
||||
| `dim` | No | Dimensions in pixels: `"<width>x<height>"` (e.g. `"1920x1080"`) |
|
||||
| `blurhash` | No | Blurhash placeholder string for progressive loading |
|
||||
|
||||
- At most one `bg` tag is allowed per event.
|
||||
- Clients MAY choose not to render video backgrounds for performance or bandwidth reasons.
|
||||
- Unknown keys SHOULD be ignored for forward compatibility.
|
||||
- **NIP-17 only** (default) — maximum privacy, only modern clients can read
|
||||
- **NIP-04 + NIP-17** — sends via both protocols for compatibility with legacy clients
|
||||
|
||||
---
|
||||
|
||||
@@ -565,9 +552,17 @@ Clients SHOULD only surface events from the last hour (`since = now - 3600`). Ol
|
||||
|
||||
---
|
||||
|
||||
## Hierarchical Communities
|
||||
## Flat Communities
|
||||
|
||||
Hierarchical communities on Nostr, composed from existing event kinds. Communities have ranked membership where authority flows downward through a chain of badge awards.
|
||||
Flat communities on Nostr, composed from existing event kinds. Communities have one membership badge, explicit moderators, and no recursive badge-chain authority.
|
||||
|
||||
This specification is intended to be a foundation for community-scoped features. A community is a kind `34550` root that other events can tag with uppercase `A`. Posts, events, polls, listings, and future content kinds can all participate in the same community model when they tag the community root and pass the membership and moderation rules below.
|
||||
|
||||
The initial implementation focuses on three foundation capabilities:
|
||||
|
||||
1. Viewing communities a user owns or belongs to.
|
||||
2. Posting community-scoped discussion content.
|
||||
3. Moderating community-scoped content and members within communities the viewer has authority over.
|
||||
|
||||
**No new event kinds are introduced.** The system composes:
|
||||
|
||||
@@ -576,34 +571,31 @@ Hierarchical communities on Nostr, composed from existing event kinds. Communiti
|
||||
- **Kind 8** ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)) -- Badge Award
|
||||
- **Kind 1111** ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) -- Community Posts
|
||||
- **Kind 1984** ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) -- Moderation
|
||||
- **Kind 5** ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)) -- Deletion / Revocation
|
||||
|
||||
### Overview
|
||||
|
||||
A hierarchical community consists of:
|
||||
A flat community consists of:
|
||||
|
||||
1. **Badge definitions** (kind `30009`), one per rank tier, published by the founder.
|
||||
2. A **community definition** (kind `34550`) referencing those badges with rank indices.
|
||||
3. **Badge awards** (kind `8`) forming a chain of trust -- each award grants a rank, validated by the awarder's rank.
|
||||
4. **Posts** (kind `1111`) scoped to the community via NIP-22.
|
||||
5. **Reports** (kind `1984`) scoped to the community for content removal or member bans.
|
||||
6. **Deletion requests** (kind `5`) for revoking awards or rescinding moderation.
|
||||
1. **One badge definition** (kind `30009`) that represents community membership.
|
||||
2. A **community definition** (kind `34550`) referencing that member badge with the role marker `"member"`.
|
||||
3. **Badge awards** (kind `8`) authored by the founder or current moderators, granting membership directly.
|
||||
4. **Community-scoped content** (initially kind `1111`) tagged to the community root.
|
||||
5. **Reports and bans** (kind `1984`) scoped to the community for content warnings, content removal, and member/non-member bans.
|
||||
|
||||
Parent, child, sister, and rank relationships are intentionally out of scope for the core permission model. Apps may build discovery or directory surfaces separately.
|
||||
|
||||
### Membership Derivation
|
||||
|
||||
Community membership is derived from three distinct sources, each resolved differently:
|
||||
Membership is sourced from the community definition and from validated kind `8` membership awards. This produces three populations:
|
||||
|
||||
- **Founder** -- the `pubkey` field on the kind `34550` event. One per community, immutable. Controls the community definition since only they can republish the addressable event.
|
||||
- **Moderators** -- the `p` tags on the kind `34550` event (matching [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). Mutable (the founder can add/remove by republishing). Share rank 0 with the founder.
|
||||
- **Members** -- derived from kind `8` badge awards forming the authority chain. A member's rank is determined by the badge they were awarded (rank 1 and below).
|
||||
- **Moderators** -- the `p` tags on the kind `34550` event with role `"moderator"` (matching [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). Mutable by republishing the community definition.
|
||||
- **Members** -- pubkeys named in `p` tags on kind `8` badge awards that reference the community's member badge and are authored by the founder or a current moderator.
|
||||
|
||||
The founder and moderators have no badge. Their rank 0 status comes from the community definition itself. Rank 0 cannot be awarded via kind `8` -- there is no rank 0 badge definition. Clients determine founder/moderator display from the community event directly.
|
||||
|
||||
Authority is **rank-based, not badge-specific**. A member at rank N can award any badge at rank M where M > N.
|
||||
The founder and moderators have no membership badge requirement. Their leadership status comes from the community definition itself. Members cannot grant membership to other members.
|
||||
|
||||
### Community Definition
|
||||
|
||||
A kind `34550` event defines the community, extending [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) with badge `a` tags that encode rank indices.
|
||||
A kind `34550` event defines the community, extending [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) with one badge `a` tag that identifies the member badge.
|
||||
|
||||
#### Tags
|
||||
|
||||
@@ -613,18 +605,18 @@ A kind `34550` event defines the community, extending [NIP-72](https://github.co
|
||||
| `name` | Yes | Human-readable name. |
|
||||
| `description` | No | Community description. |
|
||||
| `image` | No | Image URL. |
|
||||
| `a` | Yes (1+) | Badge definition reference with rank index (see format below). |
|
||||
| `p` | Yes (1+) | Moderator pubkeys. Implicitly rank 0. The 4th element SHOULD be `"moderator"`. |
|
||||
| `a` | Yes (1) | Member badge definition reference with role marker `"member"`. |
|
||||
| `p` | No | Moderator pubkeys. The 4th element SHOULD be `"moderator"`. |
|
||||
| `relay` | No | Recommended relay URL for community content (per [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). |
|
||||
| `alt` | No | [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) description. |
|
||||
|
||||
#### Badge `a` Tag Format
|
||||
|
||||
```
|
||||
["a", "30009:<pubkey>:<badge-d-tag>", "<relay-hint>", "<rank-index>"]
|
||||
["a", "30009:<pubkey>:<badge-d-tag>", "<relay-hint>", "member"]
|
||||
```
|
||||
|
||||
Rank `0` is reserved for the founder and moderators (derived from the community definition, not from badges). Badge `a` tags define awardable ranks starting from `1`. Higher numbers = lower authority. Indices MUST be contiguous starting from 1.
|
||||
The fourth element is a strict protocol marker, not a display label. Communities can still use the badge definition's `name`, `description`, and `image` tags for expressive member labels.
|
||||
|
||||
#### Example
|
||||
|
||||
@@ -638,10 +630,7 @@ Rank `0` is reserved for the founder and moderators (derived from the community
|
||||
["name", "The Arbiter's Guard"],
|
||||
["description", "Elite Halo 2 clan"],
|
||||
["image", "https://example.com/clan-banner.jpg"],
|
||||
["a", "30009:<founder-pubkey>:a1b2c3d4-...-staff", "", "1"],
|
||||
["a", "30009:<founder-pubkey>:a1b2c3d4-...-member", "", "2"],
|
||||
["a", "30009:<founder-pubkey>:a1b2c3d4-...-peon", "", "3"],
|
||||
["p", "<founder-pubkey>", "", "moderator"],
|
||||
["a", "30009:<founder-pubkey>:a1b2c3d4-...-member", "", "member"],
|
||||
["p", "<co-moderator-pubkey>", "", "moderator"],
|
||||
["relay", "wss://relay.example.com"],
|
||||
["alt", "Community: The Arbiter's Guard"]
|
||||
@@ -651,9 +640,9 @@ Rank `0` is reserved for the founder and moderators (derived from the community
|
||||
|
||||
### Badge Definitions
|
||||
|
||||
Each rank tier is a standard [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) kind `30009` badge definition published by the founder. Badge definitions MUST be published **before** the community definition that references them.
|
||||
The member badge is a standard [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) kind `30009` badge definition published by the founder. The badge definition SHOULD be published **before** the community definition that references it.
|
||||
|
||||
The `d` tag SHOULD use the format `<community-d-tag>-<rank-name>` for global uniqueness.
|
||||
The `d` tag SHOULD use the format `<community-d-tag>-member` for global uniqueness.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -661,55 +650,63 @@ The `d` tag SHOULD use the format `<community-d-tag>-<rank-name>` for global uni
|
||||
"pubkey": "<founder-pubkey>",
|
||||
"content": "",
|
||||
"tags": [
|
||||
["d", "a1b2c3d4-...-staff"],
|
||||
["name", "Staff"],
|
||||
["description", "Trusted officers who manage clan operations."],
|
||||
["image", "https://example.com/staff-badge.png"],
|
||||
["alt", "Badge definition: Staff"]
|
||||
["d", "a1b2c3d4-...-member"],
|
||||
["name", "Member"],
|
||||
["description", "Member of The Arbiter's Guard"],
|
||||
["image", "https://example.com/member-badge.png"],
|
||||
["alt", "Badge definition: Member of The Arbiter's Guard"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Badge Awards
|
||||
|
||||
Membership is established through kind `8` badge awards ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)). Each award forms a chain link.
|
||||
Membership is established through kind `8` badge awards ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)). Each valid award grants membership directly.
|
||||
|
||||
A badge award is **valid** if and only if:
|
||||
|
||||
1. The `a` tag references a badge definition listed in the community definition.
|
||||
2. The awarder is a validated member at a rank **strictly less than** the badge's rank index.
|
||||
3. The awarder's chain can be walked upward to a founder or moderator.
|
||||
1. The `a` tag references the member badge listed in the community definition.
|
||||
2. The award author is the founder or a moderator listed in the community definition currently being evaluated.
|
||||
3. The award contains at least one `p` tag naming an awarded pubkey.
|
||||
|
||||
```jsonc
|
||||
// Moderator (rank 0) awarding Staff (rank 1)
|
||||
// Moderator awarding community membership
|
||||
{
|
||||
"kind": 8,
|
||||
"pubkey": "<founder-pubkey>",
|
||||
"pubkey": "<moderator-pubkey>",
|
||||
"content": "",
|
||||
"tags": [
|
||||
["a", "30009:<founder-pubkey>:a1b2c3d4-...-staff"],
|
||||
["a", "30009:<founder-pubkey>:a1b2c3d4-...-member"],
|
||||
["p", "<recipient-pubkey>"],
|
||||
["alt", "Badge award: Staff in The Arbiter's Guard"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Chain Validation
|
||||
### Membership Validation
|
||||
|
||||
Membership is **derived state**. Clients compute effective membership by resolving the authority graph from badge awards, then applying moderation overlays.
|
||||
Membership is resolved with indexed relay filters. There is no recursive authority graph.
|
||||
|
||||
#### Algorithm
|
||||
|
||||
1. **Seed rank 0**: The event publisher (founder) and all `p` tags (moderators) in the community definition are rank 0 members.
|
||||
2. **Query awards**: `{ kinds: [8], #a: [<all badge coordinates>] }`
|
||||
3. **Iteratively validate**: For each award, check if the awarder is a validated member with rank strictly less than the awarded rank. If valid, add the recipient. Repeat until no new members are discovered.
|
||||
4. **Apply moderation**: Query `{ kinds: [1984], #A: [<community-a-tag>] }`. Remove banned members (but not their downstream subtrees -- the chain remains intact). Hide reported posts.
|
||||
1. Fetch the community definition using kind `34550`, the founder pubkey, and the community `d` tag.
|
||||
2. Extract the founder pubkey, moderator pubkeys, and member badge coordinate.
|
||||
3. Query awards: `{ kinds: [8], authors: [<founder>, ...<moderators>], #a: [<member-badge-coordinate>] }`.
|
||||
4. Flatten `p` tags from matching awards.
|
||||
5. The member set is the union of the founder, current moderators, and awarded pubkeys.
|
||||
6. Resolve moderation and apply moderation overlays.
|
||||
|
||||
Clients MUST NOT trust kind `8` events at face value. An attacker can publish awards for themselves, but these fail chain validation without a path to a founder or moderator.
|
||||
The `authors` filter is the primary membership-award trust boundary. Awards from non-founder, non-moderator pubkeys are not valid community membership awards.
|
||||
|
||||
### Community Posts
|
||||
### Community-Scoped Content
|
||||
|
||||
Community discussion uses kind `1111` ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) scoped to the community definition as the root event.
|
||||
Community-scoped content is any event that tags the community definition with uppercase `A`. The foundation implementation starts with kind `1111` ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) posts, but the same moderation overlay applies to future community content kinds such as calendar events, polls, listings, or other domain-specific events.
|
||||
|
||||
Clients MAY offer a members-only view that filters community posts down to the resolved member set as an `authors` filter. Whether this is on by default, opt-in, or omitted entirely is a client UX choice -- the protocol makes no recommendation.
|
||||
|
||||
#### Community Post
|
||||
|
||||
Community discussion uses kind `1111` scoped to the community definition as the root event.
|
||||
|
||||
#### Top-Level Post
|
||||
|
||||
@@ -749,94 +746,122 @@ Replies keep the community as root scope and point to the parent comment:
|
||||
|
||||
#### Querying
|
||||
|
||||
Fetch all community-scoped posts and moderation data in a single request:
|
||||
Clients MAY use the resolved member set as an `authors` filter for members-only views.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kinds": [1111, 1984],
|
||||
"#A": ["34550:<founder-pubkey>:<community-d-tag>"]
|
||||
"kinds": [1111],
|
||||
"#A": ["34550:<founder-pubkey>:<community-d-tag>"],
|
||||
"authors": ["<founder>", "<moderator>", "<member>"]
|
||||
}
|
||||
```
|
||||
|
||||
Clients then filter client-side: discard kind `1111` posts from non-members, and apply authoritative kind `1984` reports per the moderation rules below.
|
||||
The moderation overlay is content-kind agnostic: a valid content ban or warning applies to the targeted event regardless of whether that event is a post, calendar event, poll, listing, or future supported kind.
|
||||
|
||||
### Moderation
|
||||
|
||||
Moderation uses kind `1984` ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) scoped to the community via the uppercase `A` tag. Reports from higher-ranked members are treated as **authoritative moderation actions**.
|
||||
Moderation uses kind `1984` ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) scoped to the community via the uppercase `A` tag. Moderation is derived state: clients first resolve trusted moderation actions from kind `1984`, then apply those actions to concrete community-scoped events.
|
||||
|
||||
#### Authority Rules
|
||||
There are two moderation event classes:
|
||||
|
||||
A report is **authoritative** if:
|
||||
1. **Bans** -- authoritative actions that remove content or ban users. Identified by the presence of [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) label tags `["L", "moderation"]` and `["l", "ban", "moderation"]`.
|
||||
2. **Reports** -- soft flags from any valid community member using standard [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md) report types (`nudity`, `spam`, `profanity`, `illegal`, `malware`, `impersonation`, `other`). No `L`/`l` tags. Clients display a content warning that users must click through to reveal.
|
||||
|
||||
1. The reporter is a validated community member.
|
||||
2. The reporter's rank is strictly less than the target's rank (or the target is a non-member).
|
||||
Kind `1984` events from **non-members** are ignored entirely within community context. Kind `1984` events from members who are themselves banned are also ignored after ban resolution; banned members cannot retain moderation or reporting authority.
|
||||
|
||||
Reports from non-members or insufficiently-ranked members are ignored.
|
||||
#### Bans (Authoritative Moderation)
|
||||
|
||||
#### Content Removal
|
||||
A ban is **authoritative** if and only if:
|
||||
|
||||
Hide a post by publishing kind `1984` with both `e` and `p` tags:
|
||||
1. The event contains `["l", "ban", "moderation"]` and `["L", "moderation"]` tags.
|
||||
2. The publisher is a validated community member.
|
||||
3. The publisher is not themselves banned after ban resolution.
|
||||
4. The publisher's authority covers the target:
|
||||
- founder/moderators may ban member and non-member authors/content;
|
||||
- members may ban only non-member authors/content.
|
||||
|
||||
Bans that fail any of these conditions MUST be ignored.
|
||||
|
||||
##### Content Ban
|
||||
|
||||
Ban a specific post by publishing kind `1984` with `e`, `p`, and `A` tags plus the `ban` label. The `e` and `p` tags use `"other"` as the NIP-56 report type since the action is administrative rather than categorical.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1984,
|
||||
"pubkey": "<moderator-pubkey>",
|
||||
"content": "Spam",
|
||||
"content": "Reason for removal",
|
||||
"tags": [
|
||||
["e", "<offending-event-id>"],
|
||||
["p", "<offending-author-pubkey>"],
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>"]
|
||||
["e", "<offending-event-id>", "other"],
|
||||
["p", "<offending-author-pubkey>", "other"],
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>"],
|
||||
["L", "moderation"],
|
||||
["l", "ban", "moderation"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Member Ban
|
||||
Clients MUST omit the banned event from canonical community feeds entirely. The event is not displayed, blurred, or indicated in any way -- it is treated as if it does not exist.
|
||||
|
||||
Ban a member by publishing kind `1984` with `p` tag only (no `e` tag). This is **non-cascading** -- only the targeted member is banned. Their kind `8` awards remain on relays, so downstream members whose chain passes through the banned member are still valid. For cascading removal, use badge revocation (kind `5`) instead.
|
||||
The `e` and `p` tags are untrusted until matched against the actual target event. A content ban MUST only apply when the targeted event's `id` matches the ban's `e` tag and the targeted event's `pubkey` matches the ban's `p` tag. This prevents a malicious or mistaken report from hiding an event by pairing its event ID with a different target pubkey.
|
||||
|
||||
##### Member Ban
|
||||
|
||||
Ban an author by publishing kind `1984` with `p` and `A` tags only (no `e` tag) plus the `ban` label. Founder/moderator-authored bans may target members or non-members. Member-authored bans may target non-members only.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1984,
|
||||
"pubkey": "<moderator-pubkey>",
|
||||
"content": "Violated guidelines",
|
||||
"content": "Reason for ban",
|
||||
"tags": [
|
||||
["p", "<banned-member-pubkey>"],
|
||||
["p", "<banned-pubkey>", "other"],
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>"],
|
||||
["L", "moderation"],
|
||||
["l", "ban", "moderation"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients distinguish content bans (`e` + `p` + `A` + `ban` label) from member bans (`p` + `A` + `ban` label, no `e` tag).
|
||||
|
||||
#### Reports (Content Warnings)
|
||||
|
||||
Any **valid, non-banned community member** may report content by publishing kind `1984` with a standard NIP-56 report type on the `e` and `p` tags. Reports do NOT use `L`/`l` label tags.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1984,
|
||||
"pubkey": "<member-pubkey>",
|
||||
"content": "Additional context",
|
||||
"tags": [
|
||||
["e", "<event-id>", "nudity"],
|
||||
["p", "<author-pubkey>", "nudity"],
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients distinguish content removal (`e` + `p` + `A`) from bans (`p` + `A`, no `e`).
|
||||
Clients SHOULD display reported content behind a content warning overlay that requires user interaction to reveal. The report type (e.g. `nudity`, `spam`) MAY be shown in the warning. Multiple reports on the same event reinforce the warning but do not automatically escalate to a ban.
|
||||
|
||||
#### Reinstatement
|
||||
Reports from non-members and banned members are ignored.
|
||||
|
||||
Delete the kind `1984` event via kind `5` ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)). Per NIP-09, only the original author can delete it.
|
||||
As with content bans, report warnings MUST only attach to content when the target event's `id` matches the report's `e` tag and the target event's `pubkey` matches the report's `p` tag.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 5,
|
||||
"tags": [["e", "<kind-1984-event-id>"], ["k", "1984"]]
|
||||
}
|
||||
```
|
||||
#### Classification Summary
|
||||
|
||||
### Revocation
|
||||
|
||||
A badge awarder can revoke their own award via kind `5`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 5,
|
||||
"tags": [["e", "<kind-8-event-id>"], ["k", "8"]]
|
||||
}
|
||||
```
|
||||
|
||||
This is **cascading** -- the chain link is destroyed, so the revoked member and all downstream members whose chain depended on it lose validated status. Per NIP-09, only the original publisher of the kind `8` event can delete it.
|
||||
|
||||
**Ban vs revocation**: Use kind `1984` to ban a single member without affecting their downstream recruits. Use kind `5` revocation to remove a member and cascade to their entire subtree.
|
||||
| `l` tag present? | `e` tag present? | Authority check | Result |
|
||||
|---|---|---|---|
|
||||
| `["l", "ban", "moderation"]` | Yes | Founder/moderator, or member targeting non-member content; `e`/`p` match target event | Content ban (omit event) |
|
||||
| `["l", "ban", "moderation"]` | No | Founder/moderator, or member targeting non-member author | Author ban |
|
||||
| No | Yes | Non-banned member; `e`/`p` match target event | Content warning |
|
||||
| No | No | -- | Invalid (ignored) |
|
||||
| Any | Any | Non-member | Ignored |
|
||||
| Any | Any | Banned member | Ignored |
|
||||
|
||||
### Community Updates
|
||||
|
||||
Both kind `34550` and kind `30009` are addressable events. To add or remove ranks, republish the community definition with updated `a` tags. To update moderators, republish with updated `p` tags. Removing a moderator cascades to members they recruited (unless those members have another valid chain path). Only the founder (event publisher) can republish the community definition.
|
||||
Both kind `34550` and kind `30009` are addressable events. To change the member badge or update moderators, republish the community definition. Only the founder (event publisher) can republish the community definition. If a moderator is removed, their authored membership awards no longer count because they are excluded from the authorized awarder query.
|
||||
|
||||
### Discovery
|
||||
|
||||
@@ -851,25 +876,115 @@ Both kind `34550` and kind `30009` are addressable events. To add or remove rank
|
||||
1. `{ "kinds": [8], "#p": ["<user-pubkey>"] }`
|
||||
2. Extract badge `a` tags from results.
|
||||
3. `{ "kinds": [34550], "#a": ["30009:...", "..."] }`
|
||||
4. Keep only communities whose `member` badge reference matches the award badge coordinate.
|
||||
|
||||
**Communities a user has bookmarked:**
|
||||
|
||||
Agora uses [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) kind `10004` ("Communities") to let users save communities they want quick access to without requiring membership. Bookmarked communities are surfaced in the "My Communities" view alongside founded and member-of communities.
|
||||
|
||||
1. `{ "kinds": [10004], "authors": ["<user-pubkey>"], "limit": 1 }`
|
||||
2. Extract `a` tags whose value begins with `34550:` from the result.
|
||||
3. For each coordinate `34550:<author-pubkey>:<d-tag>`, query the community definition with both `authors` and `#d` filters to prevent spoofing:
|
||||
|
||||
```jsonc
|
||||
{ "kinds": [34550], "authors": ["<author-pubkey>"], "#d": ["<d-tag>"], "limit": 1 }
|
||||
```
|
||||
|
||||
Clients toggling a bookmark MUST perform a read-modify-write cycle on the replaceable kind `10004` event: fetch the freshest version from relays, add or remove the matching `["a", "34550:<pubkey>:<d-tag>"]` tag, and republish the full tag list. Appending new entries to the end preserves chronological bookmark order per NIP-51.
|
||||
|
||||
When the same community appears in multiple discovery sources, clients SHOULD display a single card but MAY indicate all applicable relationships (e.g. a member who has also bookmarked a community).
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- **Author filtering**: Clients MUST filter community definitions by `authors` to prevent impersonation.
|
||||
- **Chain validation is required**: Never trust kind `8` events without walking the authority chain.
|
||||
- **Badge d-tag uniqueness**: Use `<community-d-tag>-<rank-name>` to prevent cross-community collisions.
|
||||
- **Badge acceptance is cosmetic**: NIP-58 kind `10008`/`30008` events have no effect on chain validation.
|
||||
- **Award author filtering is required**: Query member badge awards with `authors: [founder, ...moderators]`.
|
||||
- **Badge d-tag uniqueness**: Use `<community-d-tag>-member` to prevent cross-community collisions.
|
||||
- **Badge acceptance is cosmetic**: NIP-58 kind `10008`/`30008` events have no effect on community membership.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) -- Event Deletion Request
|
||||
- [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md) -- Comment
|
||||
- [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) -- Unknown Event Kinds (`alt` tag)
|
||||
- [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) -- Labeling (moderation `ban` label)
|
||||
- [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) -- Lists (kind `10004` Communities list for bookmarks)
|
||||
- [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md) -- Reporting
|
||||
- [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) -- Badges
|
||||
- [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) -- Moderated Communities
|
||||
|
||||
---
|
||||
|
||||
## Community Fundraising Goals (NIP-75)
|
||||
|
||||
### Summary
|
||||
|
||||
Communities can host fundraising campaigns using [NIP-75 Zap Goals](https://github.com/nostr-protocol/nips/blob/master/75.md) (kind `9041`). A zap goal linked to a community allows members and supporters to contribute sats toward a shared target.
|
||||
|
||||
### Linking Goals to Communities
|
||||
|
||||
A zap goal is linked to a community by including an `a` tag pointing to the community's kind `34550` definition:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 9041,
|
||||
"content": "Community meetup travel fund",
|
||||
"tags": [
|
||||
["amount", "500000000"],
|
||||
["relays", "wss://relay.ditto.pub", "wss://relay.primal.net"],
|
||||
["a", "34550:<community-author-pubkey>:<community-d-identifier>"],
|
||||
["alt", "Zap goal: Community meetup travel fund"],
|
||||
["summary", "Help fund travel for our annual meetup"],
|
||||
["image", "https://example.com/meetup.jpg"],
|
||||
["closed_at", "1735689600"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Required Tags (per NIP-75)
|
||||
|
||||
- `amount` -- Target amount in millisatoshis
|
||||
- `relays` -- Relay URLs where zaps should be sent and tallied from
|
||||
|
||||
### Optional Tags (per NIP-75)
|
||||
|
||||
- `closed_at` -- Unix timestamp deadline; zap receipts after this time are excluded from the tally
|
||||
- `image` -- Image URL for the goal
|
||||
- `summary` -- Brief description
|
||||
|
||||
### Additional Tags (Agora-specific)
|
||||
|
||||
- `a` -- Community link (`34550:<pubkey>:<d-tag>`) scoping the goal to a community
|
||||
- `alt` -- NIP-31 human-readable description
|
||||
|
||||
### Querying
|
||||
|
||||
Community goals are queried by filtering on the `a` tag:
|
||||
|
||||
```
|
||||
{ "kinds": [9041], "#a": ["34550:<pubkey>:<d-tag>"], "limit": 50 }
|
||||
```
|
||||
|
||||
### Progress Tallying
|
||||
|
||||
Goal progress is calculated from kind `9735` zap receipts targeting the goal event:
|
||||
|
||||
```
|
||||
{ "kinds": [9735], "#e": ["<goal-event-id>"], "limit": 500 }
|
||||
```
|
||||
|
||||
Receipts with `created_at` after the `closed_at` deadline (if set) are excluded from the tally.
|
||||
|
||||
### Access Control
|
||||
|
||||
Anyone may create a zap goal linked to a community. The existing community members-only feed filter controls whether non-member goals are displayed. Anyone may zap a goal.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) -- Lightning Zaps
|
||||
- [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) -- Moderated Communities
|
||||
- [NIP-75](https://github.com/nostr-protocol/nips/blob/master/75.md) -- Zap Goals
|
||||
|
||||
---
|
||||
|
||||
## Kind 0 Extension: Avatar Shape
|
||||
|
||||
### Summary
|
||||
@@ -909,6 +1024,18 @@ The following specifications are maintained by their respective authors. Ditto i
|
||||
|
||||
Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion.
|
||||
|
||||
### Birdstar (Kinds 2473, 12473, 30621)
|
||||
|
||||
**Author:** Alex Gleason
|
||||
**Spec:** https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md
|
||||
**App:** https://birdstar.app
|
||||
|
||||
Birdstar merges Birdsong Spotter (a bird-by-ear checklist) and Starpoint (an interactive sky map with community constellations) into a single client.
|
||||
|
||||
- **Kind 2473 — Bird Detection.** A regular event representing a single identified bird observation. The species is identified by a NIP-73 `i`/`k` pair pointing at the species' Wikidata entity URI (e.g. `https://www.wikidata.org/entity/Q26825` for the American Robin). The `content` field holds an optional freeform human note about the detection. Required tags: NIP-31 `alt`, NIP-73 `i` (Wikidata URL) + `k` (`web`). Ditto renders detections as a species card with the Wikipedia thumbnail, common/scientific name, and article summary.
|
||||
- **Kind 12473 — Birdex.** A replaceable event (one per author) indexing every distinct species the author has ever confirmed via kind 2473. Each species is a positional `i`/`n` pair — the Wikidata entity URI followed immediately by the scientific binomial name — emitted in chronological order of first detection. Ditto renders a Birdex as a tiled grid of species, each tile showing the Wikipedia thumbnail with the common name overlaid. In feeds, only the most recent few tiles are shown with a "+N" capstone mirroring how kind 3 follow lists preview members; the post-detail page shows every species.
|
||||
- **Kind 30621 — Custom Constellation.** An addressable event (`d` tag) representing a single user-drawn star figure. Each `edge` tag (`["edge", from, to]`) references two Hipparcos catalog numbers as decimal strings — e.g. `["edge", "32349", "37279"]` for Sirius → Procyon. Required tags: `d`, `title`, `alt`, and at least one valid `edge`. The `content` field is a freeform description. Ditto renders constellations as a stylized SVG star-map (gnomonically projected onto a tangent plane at the figure's centroid, with stars sized by magnitude) using a bundled Hipparcos catalog that is code-split so the data only loads when a constellation is actually viewed.
|
||||
|
||||
### Geocaching (Kinds 37516, 7516)
|
||||
|
||||
**Author:** Chad Curtis
|
||||
@@ -934,30 +1061,62 @@ NIP-44 encrypted personal letters with visual stationery, hand-drawn stickers, d
|
||||
|
||||
Kind 16158 (replaceable) describes a weather station's configuration: name, geohash location, elevation, power source, connectivity, and sensor inventory. Kind 4223 (regular) carries individual sensor readings as 3-parameter tags `[sensor_type, value, model]`, enabling historical queries and cross-station comparison. Each station has its own keypair.
|
||||
|
||||
### Blobbi Virtual Pet (Kinds 31124, 14919, 14920, 14921, 11125)
|
||||
---
|
||||
|
||||
**Author:** Danifra
|
||||
**Spec:** https://github.com/Danidfra/nostr-pet/blob/production/NIP.md
|
||||
**App:** https://nostr-pet.vercel.app
|
||||
**See also:** [Blobbi tag schema](docs/blobbi/blobbi-tag-schema.md) (Ditto-specific integration details)
|
||||
## Music Tracks & Playlists
|
||||
|
||||
NIP-BB defines a virtual pet lifecycle on Nostr. Kind 31124 (addressable) holds the current pet state across three stages (egg, baby, adult) with stats, appearance, and personality traits. Kind 14919 logs individual interactions, kind 14920 records breeding events, kind 14921 stores immutable lifecycle records, and kind 11125 (replaceable) holds the owner's profile with coins, achievements, and inventory.
|
||||
### Kind 36787: Music Track
|
||||
|
||||
#### Kind 11125 `content` JSON — `missions` field
|
||||
An addressable event containing metadata about an audio file. Full spec maintained externally.
|
||||
|
||||
The `content` of kind 11125 is a JSON object. Ditto extends it with a `missions` field that tracks daily and evolution mission progress:
|
||||
**Required tags:** `d`, `title`, `artist`, `url`, `t` (with value `"music"`)
|
||||
|
||||
```jsonc
|
||||
**Optional tags:** `image`, `video`, `album`, `track_number`, `released`, `duration`, `format`, `bitrate`, `sample_rate`, `language`, `explicit`, `zap`, `alt`
|
||||
|
||||
### Kind 34139: Music Playlist
|
||||
|
||||
An addressable event containing an ordered list of music track references.
|
||||
|
||||
**Required tags:** `d`, `title`, `alt`
|
||||
|
||||
**Optional tags:** `description`, `image`, `a` (track references), `t`, `public`, `private`, `collaborative`
|
||||
|
||||
Track references use `a` tags in the format `["a", "36787:<pubkey>:<d-tag>"]`.
|
||||
|
||||
### Albums (Convention)
|
||||
|
||||
Albums are represented as kind 34139 playlist events with a `["t", "album"]` tag. This reuses the existing playlist infrastructure while allowing clients to distinguish albums from user-curated playlists.
|
||||
|
||||
**Additional optional tags for albums:**
|
||||
- `released` — ISO 8601 release date (e.g. `"2024-06-15"`)
|
||||
- `label` — Record label name
|
||||
|
||||
**Example album event:**
|
||||
|
||||
```json
|
||||
{
|
||||
"missions": {
|
||||
"date": "2026-04-16", // ISO date string for the current daily mission set
|
||||
"daily": [ /* Mission[] */ ],
|
||||
"evolution": [ /* Mission[] — active hatch/evolve tasks, cleared on stage transition */ ],
|
||||
"rerolls": 2 // remaining daily mission rerolls
|
||||
}
|
||||
// ...other profile fields (coins, achievements, inventory, etc.)
|
||||
"kind": 34139,
|
||||
"content": "Debut studio album featuring 12 tracks of ambient electronic music.",
|
||||
"tags": [
|
||||
["d", "endless-summer-2024"],
|
||||
["title", "Endless Summer"],
|
||||
["image", "https://cdn.blossom.example/img/album-art.jpg"],
|
||||
["t", "album"],
|
||||
["t", "electronic"],
|
||||
["t", "ambient"],
|
||||
["released", "2024-06-15"],
|
||||
["label", "Sunset Records"],
|
||||
["a", "36787:abc123...:track-1"],
|
||||
["a", "36787:abc123...:track-2"],
|
||||
["a", "36787:abc123...:track-3"],
|
||||
["alt", "Album: Endless Summer by The Midnight Collective"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each `Mission` is either a **TallyMission** (`{ id, target, count }`) or an **EventMission** (`{ id, target, events: string[] }`) where `events` contains Nostr event IDs that satisfy the mission. Evolution missions are populated when incubation or evolution begins and cleared when the stage transition completes or is cancelled.
|
||||
**Client behavior:**
|
||||
- Clients detect albums by checking for a `t` tag with value `"album"` (case-insensitive)
|
||||
- Albums display release date and label information when available
|
||||
- Track ordering follows the order of `a` tags in the event
|
||||
- The same detail view, playback, and commenting features apply to both albums and playlists
|
||||
|
||||
|
||||
@@ -39,6 +39,36 @@ npm run dev
|
||||
|
||||
Development server: `http://localhost:8080`
|
||||
|
||||
### Docker Getting Started
|
||||
|
||||
Use Docker Compose when you want the nginx reverse-proxy stack (necessary if you want decryptable media in messages - kind 15s of NIP 17):
|
||||
|
||||
```sh
|
||||
git clone https://gitlab.com/soapbox-pub/agora-3.git
|
||||
cd agora-3
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Proxy URL: `http://localhost:8083`
|
||||
|
||||
This starts:
|
||||
|
||||
- `vite` service on the internal Docker network (`vite:8080`)
|
||||
- `web` service (`nginx`) on host port `8082`, proxying to Vite with websocket support
|
||||
|
||||
Stop stack:
|
||||
|
||||
```sh
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Production-style container build:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.prod.yml up --build
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```sh
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.8.0"
|
||||
versionName "2.14.4"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -58,4 +58,6 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
</manifest>
|
||||
|
||||
@@ -19,7 +19,6 @@ public class MainActivity extends BridgeActivity {
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
// Register native plugins before super.onCreate.
|
||||
registerPlugin(DittoNotificationPlugin.class);
|
||||
registerPlugin(SandboxPlugin.class);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ProgressBar;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
|
||||
*
|
||||
* Each sandbox uses shouldInterceptRequest to intercept all requests and forward
|
||||
* them to the JS layer as fetch events — the same protocol iframe.diy uses.
|
||||
* The React code can serve files identically regardless of platform.
|
||||
*/
|
||||
@CapacitorPlugin(name = "SandboxPlugin")
|
||||
public class SandboxPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "SandboxPlugin";
|
||||
private final Map<String, SandboxInstance> sandboxes = new HashMap<>();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
@PluginMethod
|
||||
public void create(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
JSObject frame = call.getObject("frame");
|
||||
if (frame == null) {
|
||||
call.reject("Missing required parameter: frame");
|
||||
return;
|
||||
}
|
||||
|
||||
int x = frame.optInt("x", 0);
|
||||
int y = frame.optInt("y", 0);
|
||||
int width = frame.optInt("width", 0);
|
||||
int height = frame.optInt("height", 0);
|
||||
|
||||
if (sandboxes.containsKey(sandboxId)) {
|
||||
call.reject("Sandbox already exists: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
float density = getActivity().getResources().getDisplayMetrics().density;
|
||||
int pxX = Math.round(x * density);
|
||||
int pxY = Math.round(y * density);
|
||||
int pxWidth = Math.round(width * density);
|
||||
int pxHeight = Math.round(height * density);
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
|
||||
sandboxes.put(sandboxId, sandbox);
|
||||
|
||||
// Add the container (WebView + spinner overlay) on top of the
|
||||
// Capacitor WebView. The parent is a CoordinatorLayout — using
|
||||
// the wrong LayoutParams type causes a ClassCastException when
|
||||
// it intercepts touch events.
|
||||
View capWebView = getBridge().getWebView();
|
||||
ViewGroup parent = (ViewGroup) capWebView.getParent();
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
parent.addView(sandbox.container, params);
|
||||
|
||||
// The spinner is now visible. Navigation is deferred until the
|
||||
// JS layer calls navigate() — this allows the caller to
|
||||
// pre-fetch blobs while the spinner animates.
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void navigate(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void updateFrame(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
JSObject frame = call.getObject("frame");
|
||||
if (frame == null) {
|
||||
call.reject("Missing required parameter: frame");
|
||||
return;
|
||||
}
|
||||
|
||||
int x = frame.optInt("x", 0);
|
||||
int y = frame.optInt("y", 0);
|
||||
int width = frame.optInt("width", 0);
|
||||
int height = frame.optInt("height", 0);
|
||||
|
||||
float density = getActivity().getResources().getDisplayMetrics().density;
|
||||
int pxX = Math.round(x * density);
|
||||
int pxY = Math.round(y * density);
|
||||
int pxWidth = Math.round(width * density);
|
||||
int pxHeight = Math.round(height * density);
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
sandbox.container.setLayoutParams(params);
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void respondToFetch(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
String requestId = call.getString("requestId");
|
||||
if (requestId == null) {
|
||||
call.reject("Missing required parameter: requestId");
|
||||
return;
|
||||
}
|
||||
JSObject response = call.getObject("response");
|
||||
if (response == null) {
|
||||
call.reject("Missing required parameter: response");
|
||||
return;
|
||||
}
|
||||
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
int status = response.optInt("status", 200);
|
||||
String statusText = response.optString("statusText", "OK");
|
||||
String bodyBase64 = response.optString("body", null);
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
JSONObject headersObj = response.optJSONObject("headers");
|
||||
if (headersObj != null) {
|
||||
for (java.util.Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
|
||||
String key = it.next();
|
||||
headers.put(key, headersObj.optString(key));
|
||||
}
|
||||
}
|
||||
|
||||
sandbox.resolveRequest(requestId, status, statusText, headers, bodyBase64);
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void postMessage(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
JSObject message = call.getObject("message");
|
||||
if (message == null) {
|
||||
call.reject("Missing required parameter: message");
|
||||
return;
|
||||
}
|
||||
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void destroy(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.remove(sandboxId);
|
||||
if (sandbox != null) {
|
||||
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
|
||||
if (parent != null) {
|
||||
parent.removeView(sandbox.container);
|
||||
}
|
||||
sandbox.webView.destroy();
|
||||
}
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("requestId", requestId);
|
||||
data.put("request", request);
|
||||
notifyListeners("fetch", data);
|
||||
}
|
||||
|
||||
void emitScriptMessage(String sandboxId, JSObject message) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("message", message);
|
||||
notifyListeners("scriptMessage", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single sandboxed WebView instance.
|
||||
*/
|
||||
private static class SandboxInstance {
|
||||
final String id;
|
||||
/** Wrapper layout that holds the WebView and the loading overlay. */
|
||||
final FrameLayout container;
|
||||
final WebView webView;
|
||||
final SandboxPlugin plugin;
|
||||
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
|
||||
/** Native spinner overlay, shown while the sandbox content loads. */
|
||||
private ProgressBar spinner;
|
||||
|
||||
SandboxInstance(String id, SandboxPlugin plugin) {
|
||||
this.id = id;
|
||||
this.plugin = plugin;
|
||||
|
||||
this.container = new FrameLayout(plugin.getActivity());
|
||||
this.webView = new WebView(plugin.getActivity());
|
||||
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setAllowFileAccess(false);
|
||||
settings.setAllowContentAccess(false);
|
||||
settings.setDatabaseEnabled(true);
|
||||
|
||||
webView.setBackgroundColor(Color.parseColor("#14161f"));
|
||||
|
||||
// Add JavaScript interface for script->native communication.
|
||||
webView.addJavascriptInterface(new SandboxBridge(this), "__sandboxNative");
|
||||
|
||||
// Inject the bridge script and intercept requests.
|
||||
webView.setWebViewClient(new SandboxWebViewClient(this));
|
||||
|
||||
// Build the container: WebView fills it, spinner overlays on top.
|
||||
container.addView(webView, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
// Native spinner overlay — uses the Android indeterminate
|
||||
// ProgressBar which animates on the render thread, so it keeps
|
||||
// spinning even when the main/IO threads are busy.
|
||||
spinner = new ProgressBar(plugin.getActivity());
|
||||
spinner.setIndeterminate(true);
|
||||
spinner.getIndeterminateDrawable().setColorFilter(
|
||||
Color.parseColor("#7c5cdc"), PorterDuff.Mode.SRC_IN);
|
||||
FrameLayout.LayoutParams spinnerParams = new FrameLayout.LayoutParams(
|
||||
dpToPx(plugin, 32), dpToPx(plugin, 32), Gravity.CENTER);
|
||||
container.addView(spinner, spinnerParams);
|
||||
|
||||
// Dark background behind the spinner.
|
||||
View overlay = new View(plugin.getActivity());
|
||||
overlay.setBackgroundColor(Color.parseColor("#14161f"));
|
||||
// Insert the overlay between the WebView (index 0) and spinner (index 1)
|
||||
// so it covers the WebView but sits behind the spinner.
|
||||
container.addView(overlay, 1, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
}
|
||||
|
||||
/** Remove the native loading overlay. Safe to call multiple times. */
|
||||
void hideSpinner() {
|
||||
if (spinner != null) {
|
||||
// Remove spinner and overlay (indices 2 and 1 after WebView at 0).
|
||||
if (container.getChildCount() > 2) container.removeViewAt(2); // spinner
|
||||
if (container.getChildCount() > 1) container.removeViewAt(1); // overlay
|
||||
spinner = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int dpToPx(SandboxPlugin plugin, int dp) {
|
||||
float density = plugin.getActivity().getResources().getDisplayMetrics().density;
|
||||
return Math.round(dp * density);
|
||||
}
|
||||
|
||||
void postMessageToWebView(String jsonString) {
|
||||
String js = "(function() { " +
|
||||
"if (window.__sandboxBridge && window.__sandboxBridge.onMessage) { " +
|
||||
"window.__sandboxBridge.onMessage(" + jsonString + "); " +
|
||||
"} " +
|
||||
"})();";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
|
||||
void resolveRequest(String requestId, int status, String statusText,
|
||||
Map<String, String> headers, String bodyBase64) {
|
||||
PendingRequest pending = pendingRequests.remove(requestId);
|
||||
if (pending == null) return;
|
||||
|
||||
byte[] bodyBytes = null;
|
||||
if (bodyBase64 != null && !bodyBase64.equals("null")) {
|
||||
try {
|
||||
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
|
||||
}
|
||||
}
|
||||
|
||||
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
|
||||
String encoding = contentType.contains("text/") ? "UTF-8" : null;
|
||||
|
||||
InputStream body = bodyBytes != null
|
||||
? new ByteArrayInputStream(bodyBytes)
|
||||
: new ByteArrayInputStream(new byte[0]);
|
||||
|
||||
WebResourceResponse response = new WebResourceResponse(
|
||||
contentType, encoding, status, statusText, headers, body
|
||||
);
|
||||
|
||||
pending.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebViewClient that intercepts all requests and forwards them to JS.
|
||||
*/
|
||||
private static class SandboxWebViewClient extends WebViewClient {
|
||||
private final SandboxInstance sandbox;
|
||||
private boolean bridgeInjected = false;
|
||||
|
||||
SandboxWebViewClient(SandboxInstance sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||
String url = request.getUrl().toString();
|
||||
|
||||
// Only intercept requests to the sandbox domain.
|
||||
if (!url.contains(".sandbox.native")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
|
||||
// Create a pending request with a blocking latch.
|
||||
PendingRequest pending = new PendingRequest();
|
||||
sandbox.pendingRequests.put(requestId, pending);
|
||||
|
||||
// Rewrite URL to include the sandbox ID for the JS handler.
|
||||
String path = request.getUrl().getPath();
|
||||
if (path == null || path.isEmpty()) path = "/";
|
||||
String rewrittenURL = "https://" + sandbox.id + ".sandbox.native" + path;
|
||||
|
||||
// Serialise the request.
|
||||
JSObject serialisedRequest = new JSObject();
|
||||
serialisedRequest.put("url", rewrittenURL);
|
||||
serialisedRequest.put("method", request.getMethod());
|
||||
|
||||
JSObject headers = new JSObject();
|
||||
for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
|
||||
headers.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
serialisedRequest.put("headers", headers);
|
||||
serialisedRequest.put("body", JSONObject.NULL);
|
||||
|
||||
// Emit to JS.
|
||||
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
|
||||
|
||||
// Block until JS responds. Each asset is fetched from a Blossom
|
||||
// server over the network, so we need a generous timeout. The
|
||||
// WebView IO thread pool has ~6 threads; if all are blocked,
|
||||
// subsequent requests queue until a thread frees up.
|
||||
WebResourceResponse response = pending.awaitResponse(60000);
|
||||
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Timeout — return error response.
|
||||
sandbox.pendingRequests.remove(requestId);
|
||||
return new WebResourceResponse(
|
||||
"text/plain", "UTF-8", 504,
|
||||
"Gateway Timeout", new HashMap<>(),
|
||||
new ByteArrayInputStream("Request timed out".getBytes())
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
super.onPageFinished(view, url);
|
||||
|
||||
if (!bridgeInjected) {
|
||||
bridgeInjected = true;
|
||||
view.evaluateJavascript(getBridgeScript(), null);
|
||||
}
|
||||
|
||||
// Remove the native spinner once the first page has finished
|
||||
// loading (all initial resources resolved). This runs on the
|
||||
// main thread, so the removal is safe.
|
||||
sandbox.hideSpinner();
|
||||
}
|
||||
|
||||
private String getBridgeScript() {
|
||||
return "(function() {" +
|
||||
"'use strict';" +
|
||||
"var messageListeners = [];" +
|
||||
"window.__sandboxBridge = {" +
|
||||
" onMessage: function(data) {" +
|
||||
" var event = {" +
|
||||
" data: data," +
|
||||
" origin: 'https://" + sandbox.id + ".sandbox.native'," +
|
||||
" source: window.parent," +
|
||||
" type: 'message'" +
|
||||
" };" +
|
||||
" for (var i = 0; i < messageListeners.length; i++) {" +
|
||||
" try { messageListeners[i](event); } catch(e) {}" +
|
||||
" }" +
|
||||
" }" +
|
||||
"};" +
|
||||
"var origAdd = window.addEventListener;" +
|
||||
"window.addEventListener = function(type, fn, opts) {" +
|
||||
" if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);" +
|
||||
" return origAdd.call(window, type, fn, opts);" +
|
||||
"};" +
|
||||
"var origRemove = window.removeEventListener;" +
|
||||
"window.removeEventListener = function(type, fn, opts) {" +
|
||||
" if (type === 'message') {" +
|
||||
" var idx = messageListeners.indexOf(fn);" +
|
||||
" if (idx !== -1) messageListeners.splice(idx, 1);" +
|
||||
" }" +
|
||||
" return origRemove.call(window, type, fn, opts);" +
|
||||
"};" +
|
||||
"if (!window.parent || window.parent === window) window.parent = {};" +
|
||||
"window.parent.postMessage = function(data) {" +
|
||||
" if (data && typeof data === 'object' && data.jsonrpc === '2.0') {" +
|
||||
" try { window.__sandboxNative.postMessage(JSON.stringify(data)); } catch(e) {}" +
|
||||
" }" +
|
||||
"};" +
|
||||
"})();";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaScript interface exposed to the sandbox WebView.
|
||||
*/
|
||||
private static class SandboxBridge {
|
||||
private final SandboxInstance sandbox;
|
||||
|
||||
SandboxBridge(SandboxInstance sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void postMessage(String json) {
|
||||
try {
|
||||
JSONObject obj = new JSONObject(json);
|
||||
JSObject jsObj = new JSObject();
|
||||
for (java.util.Iterator<String> it = obj.keys(); it.hasNext(); ) {
|
||||
String key = it.next();
|
||||
jsObj.put(key, obj.get(key));
|
||||
}
|
||||
sandbox.plugin.emitScriptMessage(sandbox.id, jsObj);
|
||||
} catch (JSONException e) {
|
||||
Log.w(TAG, "Failed to parse script message", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending request that blocks the WebViewClient IO thread until JS
|
||||
* responds with the complete resource.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private volatile WebResourceResponse response;
|
||||
private final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
void resolve(WebResourceResponse response) {
|
||||
this.response = response;
|
||||
latch.countDown();
|
||||
}
|
||||
|
||||
WebResourceResponse awaitResponse(long timeoutMs) {
|
||||
try {
|
||||
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8083:80"
|
||||
volumes:
|
||||
- ./nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./dist:/usr/share/nginx/html:ro
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- vite
|
||||
networks:
|
||||
- agora-network
|
||||
|
||||
vite:
|
||||
image: node:22-alpine
|
||||
working_dir: /app
|
||||
# Use host node_modules so new dependencies are picked up after install.
|
||||
command: sh -c "npm install && npm run dev"
|
||||
volumes:
|
||||
- .:/app
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
networks:
|
||||
- agora-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
agora-network:
|
||||
driver: bridge
|
||||
@@ -1,383 +0,0 @@
|
||||
# Theme System
|
||||
|
||||
This document describes the two separate but overlapping theme features in Ditto: the **App Theme** (which controls the local UI) and the **Profile Theme** (which is published to Nostr for others to see). Understanding the distinction is key to working with this codebase.
|
||||
|
||||
## Overview
|
||||
|
||||
| Concept | Purpose | Scope | Persistence |
|
||||
|---|---|---|---|
|
||||
| **App Theme** | Controls colors, fonts, and background of the local UI | Local to the user's browser | localStorage + encrypted NIP-78 sync |
|
||||
| **Profile Theme** | A set of theme values published as a Nostr event | Public, visible to other users | Kind 16767 replaceable event |
|
||||
|
||||
The App Theme and Profile Theme share the same underlying data structure (`ThemeConfig`), and there is an optional bridge between them (`autoShareTheme`), but they are fundamentally independent systems.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: App Theme
|
||||
|
||||
The App Theme controls what the user sees in their own browser. It has no inherent connection to Nostr.
|
||||
|
||||
### Core Concept: 3 Colors Define Everything
|
||||
|
||||
The entire theme is derived from just 3 core colors, defined by the `CoreThemeColors` interface in `src/themes.ts:8`:
|
||||
|
||||
```typescript
|
||||
interface CoreThemeColors {
|
||||
background: string; // HSL string, e.g. "228 20% 10%"
|
||||
text: string; // Text/foreground color
|
||||
primary: string; // Primary accent (buttons, links, focus rings)
|
||||
}
|
||||
```
|
||||
|
||||
From these 3 values, the system auto-derives 19 CSS tokens (the full `ThemeTokens` set) via `deriveTokensFromCore()` in `src/lib/colorUtils.ts:141`. The derivation algorithm:
|
||||
|
||||
- Detects dark/light mode from background luminance (threshold: 0.2)
|
||||
- Derives `card` and `popover` surfaces by slightly lightening the background (dark mode) or using it directly (light mode)
|
||||
- Derives `secondary` and `muted` surfaces by adjusting background lightness
|
||||
- Derives `border` using the primary hue with reduced saturation
|
||||
- Computes `mutedForeground` as a dimmer version of the text color
|
||||
- Sets `accent = primary` and `ring = primary`
|
||||
- Auto-computes `primaryForeground` using WCAG contrast detection (white or dark)
|
||||
- Uses fixed red values for `destructive` / `destructiveForeground`
|
||||
|
||||
### Theme Modes
|
||||
|
||||
The `Theme` type (`src/contexts/AppContext.ts:9`) has four values:
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| `"light"` | Uses the builtin (or configured) light color set |
|
||||
| `"dark"` | Uses the builtin (or configured) dark color set |
|
||||
| `"system"` | Resolves to `"light"` or `"dark"` based on `prefers-color-scheme`, with a live media query listener |
|
||||
| `"custom"` | Uses user-defined colors stored in `config.customTheme` |
|
||||
|
||||
**Builtin themes** are defined in `src/themes.ts:102`:
|
||||
|
||||
```typescript
|
||||
const builtinThemes = {
|
||||
light: { background: '270 50% 97%', text: '270 25% 12%', primary: '270 65% 55%' },
|
||||
dark: { background: '228 20% 10%', text: '210 40% 98%', primary: '258 70% 60%' },
|
||||
};
|
||||
```
|
||||
|
||||
Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
|
||||
|
||||
### ThemeConfig
|
||||
|
||||
The `ThemeConfig` type (`src/themes.ts:50`) wraps the 3 core colors with optional extras:
|
||||
|
||||
```typescript
|
||||
interface ThemeConfig {
|
||||
title?: string;
|
||||
colors: CoreThemeColors;
|
||||
font?: ThemeFont; // { family: string; url?: string }
|
||||
background?: ThemeBackground; // { url: string; mode?: 'cover' | 'tile'; ... }
|
||||
}
|
||||
```
|
||||
|
||||
This is the canonical type used everywhere: in `AppConfig.customTheme`, in encrypted settings, and in Nostr theme events.
|
||||
|
||||
### Theme Presets
|
||||
|
||||
Named presets are defined in `src/themes.ts:136` (e.g. `pink`, `toxic`, `sunset`). Each preset includes core colors and optionally a font and background image. Applying a preset sets the app theme to `"custom"` and stores the preset's config as `customTheme`.
|
||||
|
||||
### How Themes Apply to the DOM
|
||||
|
||||
The theme pipeline has three stages designed to prevent any flash of wrong colors:
|
||||
|
||||
#### Stage 1: Pre-React Blocking Script (`public/theme.js`)
|
||||
|
||||
A synchronous `<script>` tag in `index.html:43` runs before React mounts. It:
|
||||
|
||||
1. Reads `nostr:app-config` from localStorage
|
||||
2. Resolves `"system"` via `matchMedia`
|
||||
3. Handles legacy presets (`"black"`, `"pink"`)
|
||||
4. Sets `document.documentElement.className` to the theme name
|
||||
5. Sets `document.body.style.background` to the correct background color
|
||||
6. Updates preloader colors (logo and spinner) to match
|
||||
|
||||
This prevents any visible flash between the hardcoded dark defaults in `index.html:32` and the user's actual theme.
|
||||
|
||||
#### Stage 2: React Provider (`src/components/AppProvider.tsx`)
|
||||
|
||||
Three private hooks run during the provider's lifecycle:
|
||||
|
||||
**`useApplyTheme`** (line 91) - Uses `useLayoutEffect` (synchronous before paint) to:
|
||||
- Resolve the theme mode
|
||||
- Build a full CSS string from `CoreThemeColors` via `buildThemeCssFromCore()`
|
||||
- Inject/update a `<style id="theme-vars">` element with all 19 CSS custom properties
|
||||
- Set `document.documentElement.className` to the resolved theme
|
||||
- Remove the inline body style left by `theme.js`
|
||||
- When mode is `"system"`, attach a `matchMedia` change listener
|
||||
|
||||
**`useApplyFonts`** (line 133) - Loads and applies custom fonts via `loadAndApplyFont()` from `src/lib/fontLoader.ts`.
|
||||
|
||||
**`useApplyBackground`** (line 156) - Injects/removes a `<style id="theme-background">` for background images (cover or tile mode).
|
||||
|
||||
#### Stage 3: Theme Switch (`src/hooks/useTheme.ts`)
|
||||
|
||||
The `setTheme()` function (line 52) performs a flicker-free theme switch:
|
||||
|
||||
1. Injects a temporary `<style>` that disables all CSS transitions (`transition: none !important`)
|
||||
2. Synchronously builds and applies CSS vars before React re-renders
|
||||
3. Updates `document.documentElement.className`
|
||||
4. Re-enables transitions after browser paint via `requestAnimationFrame`
|
||||
5. Updates localStorage config
|
||||
6. Debounce-syncs to encrypted NIP-78 storage (1 second delay)
|
||||
|
||||
### How Components Consume Theme Values
|
||||
|
||||
#### CSS Custom Properties to Tailwind
|
||||
|
||||
`tailwind.config.ts` maps all 19 CSS custom properties to Tailwind color utilities:
|
||||
|
||||
```typescript
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
|
||||
// ... (secondary, destructive, muted, accent, popover, card, border, input, ring)
|
||||
}
|
||||
```
|
||||
|
||||
Components use standard Tailwind classes like `bg-primary`, `text-foreground`, `border-border`, etc. These resolve to `hsl(var(--primary))`, which picks up whichever values are currently set on `:root`.
|
||||
|
||||
The `cn()` utility in `src/lib/utils.ts` combines `clsx` (conditional class joining) with `tailwind-merge` (intelligent Tailwind class deduplication).
|
||||
|
||||
#### Static CSS
|
||||
|
||||
`src/index.css` applies base styles using theme tokens:
|
||||
|
||||
```css
|
||||
* { @apply border-border; }
|
||||
body { @apply bg-background text-foreground; }
|
||||
```
|
||||
|
||||
The only static CSS custom property is `--radius: 0.75rem`. All color variables are injected dynamically.
|
||||
|
||||
### ScopedTheme
|
||||
|
||||
The `ScopedTheme` component (`src/components/ScopedTheme.tsx`) applies a different set of theme colors to a DOM subtree by setting CSS variables as inline `style`:
|
||||
|
||||
```tsx
|
||||
<ScopedTheme colors={someColors} className="rounded-lg p-4">
|
||||
{/* Children here see different --background, --primary, etc. */}
|
||||
</ScopedTheme>
|
||||
```
|
||||
|
||||
It also sets `data-theme-mode="dark"` or `"light"` based on background luminance, for CSS targeting.
|
||||
|
||||
### App Theme Persistence
|
||||
|
||||
#### Layer 1: localStorage (immediate)
|
||||
|
||||
The `useLocalStorage` hook (`src/hooks/useLocalStorage.ts`) stores the full `AppConfig` under key `"nostr:app-config"`. This includes `theme`, `customTheme`, `autoShareTheme`, and `themes`. Changes are reflected immediately and support cross-tab sync via `StorageEvent`.
|
||||
|
||||
#### Layer 2: Encrypted NIP-78 Settings (cross-device sync)
|
||||
|
||||
The `useEncryptedSettings` hook (`src/hooks/useEncryptedSettings.ts`) stores theme preferences in a kind 30078 addressable event, encrypted to self via NIP-44. The `EncryptedSettings` interface includes `theme`, `customTheme`, and `autoShareTheme` among other app settings.
|
||||
|
||||
Key behaviors:
|
||||
- Query is delayed 5 seconds after login to avoid competing with feed load
|
||||
- Uses optimistic updates with a `pendingSettings` ref for rapid successive mutations
|
||||
- A `recentlyWritten()` guard returns true for 10 seconds after a local write to prevent `NostrSync` from overwriting the value that was just saved
|
||||
|
||||
#### Sync via NostrSync
|
||||
|
||||
The `NostrSync` component (`src/components/NostrSync.tsx`) runs globally and syncs encrypted settings from Nostr on login. For theme-related fields, it:
|
||||
|
||||
1. Seeds a `lastSyncedTimestamp` ref on first load to prevent stale events from overwriting local config
|
||||
2. Skips application if `recentlyWritten()` is true
|
||||
3. Only applies changes if the remote timestamp is newer
|
||||
4. Handles legacy theme value migration (`"black"`, `"pink"` to `"custom"`)
|
||||
5. Diffs each field individually to avoid unnecessary re-renders
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Profile Theme
|
||||
|
||||
The Profile Theme is a public Nostr event that represents a user's chosen theme. Other clients can read it to style that user's profile page, or users can browse and copy each other's themes.
|
||||
|
||||
### Nostr Event Kinds
|
||||
|
||||
#### Kind 36767: Theme Definition (addressable, multiple per user)
|
||||
|
||||
A shareable, named theme that a user has created. Think of these as "published theme presets." Tags:
|
||||
|
||||
| Tag | Purpose | Example |
|
||||
|---|---|---|
|
||||
| `d` | Identifier (slug) | `["d", "ocean-night"]` |
|
||||
| `c` | Color (hex + role) | `["c", "#1a1a2e", "background"]` |
|
||||
| `f` | Font (family + optional URL) | `["f", "Comfortaa", "https://cdn.jsdelivr.net/..."]` |
|
||||
| `bg` | Background (imeta-style variadic) | `["bg", "url https://...", "mode cover", "m image/jpeg"]` |
|
||||
| `title` | Display name | `["title", "Ocean Night"]` |
|
||||
| `alt` | NIP-31 description | `["alt", "Custom theme: Ocean Night"]` |
|
||||
| `t` | Topic tag | `["t", "theme"]` |
|
||||
| `description` | Optional description | `["description", "A deep blue theme"]` |
|
||||
|
||||
Colors are stored as **hex** in `c` tags (converted to/from HSL internally). The `content` field is empty (legacy events may have JSON in content for backward compatibility).
|
||||
|
||||
#### Kind 16767: Active Profile Theme (replaceable, one per user)
|
||||
|
||||
The user's currently active profile theme. Same tag structure as kind 36767 but without `d` or `description` tags, and with an optional `a` tag referencing the source theme definition:
|
||||
|
||||
| Tag | Purpose |
|
||||
|---|---|
|
||||
| `c` | Color tags (same as 36767) |
|
||||
| `f` | Font tag (same as 36767) |
|
||||
| `bg` | Background tag (same as 36767) |
|
||||
| `alt` | Always `"Active profile theme"` |
|
||||
| `title` | Optional theme name |
|
||||
| `a` | Optional reference to source kind 36767 event |
|
||||
|
||||
### Hooks
|
||||
|
||||
| Hook | File | Purpose |
|
||||
|---|---|---|
|
||||
| `usePublishTheme` | `src/hooks/usePublishTheme.ts` | Publish/update/delete theme definitions (36767), set/clear active profile theme (16767) |
|
||||
| `useUserThemes` | `src/hooks/useUserThemes.ts` | Query all kind 36767 themes by a user, deduplicated by d-tag, sorted newest first |
|
||||
| `useActiveProfileTheme` | `src/hooks/useActiveProfileTheme.ts` | Query a user's kind 16767 active profile theme |
|
||||
|
||||
### Publishing and Parsing
|
||||
|
||||
All event building and parsing is in `src/lib/themeEvent.ts`:
|
||||
|
||||
- `buildThemeDefinitionTags()` / `parseThemeDefinition()` - Kind 36767
|
||||
- `buildActiveThemeTags()` / `parseActiveProfileTheme()` - Kind 16767
|
||||
- `buildColorTags()` / `parseColorTags()` - HSL-to-hex conversion for `c` tags
|
||||
- `buildFontTag()` / `parseFontTag()` - Font `f` tags
|
||||
- `buildBackgroundTag()` / `parseBackgroundTag()` - Background `bg` tags (imeta-style)
|
||||
- `titleToSlug()` - Generate d-tag identifiers from titles
|
||||
|
||||
Backward compatibility: if `c` tags are missing, the parser falls back to reading legacy JSON from `content` (handling both the old 19-token format and the 4-color format).
|
||||
|
||||
---
|
||||
|
||||
## Part 3: The Bridge Between App Theme and Profile Theme
|
||||
|
||||
The two systems are connected by the **autoShareTheme** setting and the NostrSync component.
|
||||
|
||||
### App Theme -> Profile Theme
|
||||
|
||||
When `autoShareTheme` is enabled (default: `true`) and the user applies a custom theme via `applyCustomTheme()`, the `useTheme` hook automatically publishes the custom theme as a kind 16767 active profile theme, debounced by 2 seconds.
|
||||
|
||||
```
|
||||
User picks a custom theme
|
||||
-> applyCustomTheme() in useTheme.ts:88
|
||||
-> Updates local config (localStorage)
|
||||
-> Syncs to encrypted NIP-78 storage (1s debounce)
|
||||
-> If autoShareTheme: publishes kind 16767 (2s debounce)
|
||||
```
|
||||
|
||||
### Profile Theme -> App Theme
|
||||
|
||||
On page load, if `autoShareTheme` is enabled, `NostrSync` (line 174) fetches the user's kind 16767 event and applies it as `customTheme` **without changing the theme mode**. This means:
|
||||
|
||||
- If the user is on `theme: "dark"`, their profile theme is stored as `customTheme` but the UI stays in dark mode
|
||||
- If the user is on `theme: "custom"`, the profile theme's colors are applied to the UI
|
||||
- This allows the profile theme to stay in sync across devices without forcing the user into custom mode
|
||||
|
||||
### Theme Definitions (Kind 36767)
|
||||
|
||||
Theme definitions are independent of the app theme. Users can create, publish, edit, and delete named themes. Other users can view them in feeds (via `ThemeUpdateCard`) and copy them. These are purely social objects on the Nostr network.
|
||||
|
||||
---
|
||||
|
||||
## Font System
|
||||
|
||||
Fonts are managed by `src/lib/fontLoader.ts` and `src/lib/fonts.ts`.
|
||||
|
||||
### Bundled Fonts
|
||||
|
||||
10 fonts are bundled via `@fontsource` packages with lazy loading (dynamic imports):
|
||||
|
||||
| Category | Fonts |
|
||||
|---|---|
|
||||
| Sans | Inter, DM Sans, Outfit, Montserrat |
|
||||
| Serif | Lora, Merriweather, Playfair Display |
|
||||
| Mono | JetBrains Mono |
|
||||
| Display | Comfortaa |
|
||||
| Handwriting | Comic Relief |
|
||||
|
||||
Each has a `load()` function and a `cdnUrl` for Nostr event publishing.
|
||||
|
||||
### Font Application
|
||||
|
||||
Three `<style>` elements manage fonts:
|
||||
|
||||
| ID | Purpose |
|
||||
|---|---|
|
||||
| `theme-font-faces` | `@font-face` rules for remote fonts |
|
||||
| `theme-font-overrides` | `html { font-family: "CustomFont", "Inter Variable", ... !important; }` |
|
||||
| `theme-vars` | Theme CSS custom properties (not font-specific, but part of the pipeline) |
|
||||
|
||||
The `loadAndApplyFont()` function:
|
||||
1. Tries to load via bundled `@fontsource` package first
|
||||
2. Falls back to injecting a `@font-face` rule from a remote URL
|
||||
3. Applies a global font-family override via `<style id="theme-font-overrides">`
|
||||
4. Passing `undefined` clears the override (reverts to default Inter)
|
||||
|
||||
---
|
||||
|
||||
## Color Utilities
|
||||
|
||||
`src/lib/colorUtils.ts` provides the color math underpinning the theme system:
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `parseHsl` / `formatHsl` | Parse/format HSL strings (`"228 20% 10%"`) |
|
||||
| `hslToRgb` / `rgbToHsl` | HSL-RGB conversion |
|
||||
| `hexToRgb` / `rgbToHex` | Hex-RGB conversion |
|
||||
| `hexToHslString` / `hslStringToHex` | Direct hex-to-HSL-string conversion (used for Nostr `c` tags) |
|
||||
| `getLuminance` | WCAG 2.1 relative luminance |
|
||||
| `getContrastRatio` / `getContrastRatioHsl` | WCAG contrast ratio between two colors |
|
||||
| `isDarkTheme` | Determines if a background is "dark" (luminance < 0.2) |
|
||||
| `deriveTokensFromCore` | The core algorithm: 3 colors -> 19 tokens |
|
||||
| `tokensToCoreColors` | Extract 3 core colors from a legacy 19-token object |
|
||||
|
||||
All colors are stored internally as HSL strings without the `hsl()` wrapper (e.g. `"228 20% 10%"`). The `hsl()` wrapper is added by Tailwind's config (`hsl(var(--background))`).
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
Theme data is validated with Zod schemas in `src/lib/schemas.ts`:
|
||||
|
||||
- `ThemeSchema` - Validates `'dark' | 'light' | 'system' | 'custom'`
|
||||
- `CoreThemeColorsSchema` - Validates the 3 HSL string fields
|
||||
- `ThemeConfigSchema` - Full config with optional font/background
|
||||
- `ThemeConfigCompatSchema` - Accepts both `ThemeConfig` and bare `CoreThemeColors`
|
||||
- `ThemeColorsCompatSchema` - Union of current 3-color, old 4-color, and legacy 19-token formats
|
||||
- `AppConfigSchema` - Full app config including all theme fields
|
||||
- `EncryptedSettingsSchema` - Encrypted settings including theme fields
|
||||
|
||||
The `AppProvider` deserializer (`src/components/AppProvider.tsx:32`) validates each top-level field individually with `safeParse`, so a single invalid field doesn't nuke the entire config.
|
||||
|
||||
---
|
||||
|
||||
## File Index
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `src/themes.ts` | Core types (`CoreThemeColors`, `ThemeConfig`, `ThemeTokens`), builtin themes, presets, CSS builders |
|
||||
| `src/lib/colorUtils.ts` | Color conversion, contrast detection, token derivation |
|
||||
| `src/lib/themeEvent.ts` | Nostr event kinds (36767, 16767), tag building/parsing |
|
||||
| `src/lib/fontLoader.ts` | Font loading and CSS injection |
|
||||
| `src/lib/fonts.ts` | Bundled font definitions |
|
||||
| `src/lib/schemas.ts` | Zod validation schemas |
|
||||
| `src/contexts/AppContext.ts` | `Theme` type, `AppConfig` interface, React context |
|
||||
| `src/hooks/useTheme.ts` | Primary theme API: `setTheme()`, `applyCustomTheme()`, `setAutoShareTheme()` |
|
||||
| `src/hooks/useAppContext.ts` | Context consumer hook |
|
||||
| `src/hooks/useEncryptedSettings.ts` | NIP-78 encrypted settings (cross-device sync) |
|
||||
| `src/hooks/usePublishTheme.ts` | Publish theme definitions and active profile theme |
|
||||
| `src/hooks/useUserThemes.ts` | Query user's theme definitions |
|
||||
| `src/hooks/useActiveProfileTheme.ts` | Query user's active profile theme |
|
||||
| `src/components/AppProvider.tsx` | Theme application to DOM (`useApplyTheme`, `useApplyFonts`, `useApplyBackground`) |
|
||||
| `src/components/NostrSync.tsx` | Cross-device sync for encrypted settings and profile theme |
|
||||
| `src/components/ScopedTheme.tsx` | Scoped CSS variable overrides for subtrees |
|
||||
| `src/components/ThemeSelector.tsx` | Full settings UI for theme management |
|
||||
| `src/components/SidebarThemeDropdown.tsx` | Compact theme picker dropdown |
|
||||
| `public/theme.js` | Pre-React blocking script for flash prevention |
|
||||
| `index.html` | Hardcoded dark defaults, preloader, blocking script tag |
|
||||
| `tailwind.config.ts` | CSS custom property to Tailwind color mapping |
|
||||
| `src/index.css` | Base styles using theme tokens |
|
||||
@@ -1,254 +0,0 @@
|
||||
# Blobbi Tag Schema
|
||||
|
||||
> **Product Specification** - This document is the canonical source of truth for Blobbi tag definitions.
|
||||
> The runtime schema at `src/lib/blobbi-tag-schema.ts` MUST align with this spec.
|
||||
|
||||
## Overview
|
||||
|
||||
Blobbi events (Kind 31124) use tags to store all state data. This document defines:
|
||||
- All valid tags and their purposes
|
||||
- Which tags are required vs optional
|
||||
- Which tags persist across stage transitions
|
||||
- Which tags should be removed during transitions
|
||||
- Deprecated tags that should be filtered out
|
||||
|
||||
---
|
||||
|
||||
## Tag Categories
|
||||
|
||||
### 1. System / Metadata Tags
|
||||
|
||||
Core protocol-level tags required for event identification and ecosystem membership.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `d` | **Yes** | egg, baby, adult | Yes | system | `blobbi-{pubkeyPrefix12}-{petId10}` | Unique identifier (addressable event d-tag) |
|
||||
| `b` | **Yes** | egg, baby, adult | Yes | system | `blobbi:ecosystem:v1` | Ecosystem namespace identifier |
|
||||
| `t` | **Yes** | egg, baby, adult | Yes | system | `blobbi` | Topic tag for discoverability |
|
||||
| `client` | No | egg, baby, adult | Yes | system | `blobbi` | Client identifier |
|
||||
|
||||
### 2. Core Identity Tags
|
||||
|
||||
Tags that define the Blobbi's unique identity. These MUST be preserved across all transitions.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `name` | **Yes** | egg, baby, adult | Yes | user | string | Display name (set during adoption) |
|
||||
| `seed` | **Yes** | egg, baby, adult | Yes | system | 64 hex chars | Deterministic seed for visual traits |
|
||||
| `generation` | No | egg, baby, adult | Yes | system | positive integer | Lineage generation (default: 1) |
|
||||
|
||||
**Important**: The `seed` is derived once at creation using `sha256("blobbi:v1|{pubkey}:{d}:{createdAt}")` and MUST NEVER be recomputed.
|
||||
|
||||
### 3. Visual Trait Tags
|
||||
|
||||
Tags derived deterministically from the seed. These are stored explicitly for fast rendering and compatibility.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `base_color` | No | egg, baby, adult | Yes | generated | CSS hex (e.g., `#F59E0B`) | Primary color |
|
||||
| `secondary_color` | No | egg, baby, adult | Yes | generated | CSS hex | Secondary/accent color |
|
||||
| `eye_color` | No | egg, baby, adult | Yes | generated | CSS hex | Eye color |
|
||||
| `pattern` | No | egg, baby, adult | Yes | generated | `solid\|spotted\|striped\|gradient` | Visual pattern type |
|
||||
| `special_mark` | No | egg, baby, adult | Yes | generated | `none\|star\|heart\|sparkle\|blush` | Special decoration |
|
||||
| `size` | No | egg, baby, adult | Yes | generated | `small\|medium\|large` | Size category |
|
||||
|
||||
**Regenerable**: These tags CAN be regenerated from the seed if missing. However, they should be preserved when present.
|
||||
|
||||
### 4. Personality / Trait Tags
|
||||
|
||||
Character traits that define the Blobbi's personality. These are generated at creation and MUST persist.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `personality` | No | egg, baby, adult | Yes | generated | string | Core personality type |
|
||||
| `trait` | No | egg, baby, adult | Yes | generated | string | Character trait modifier |
|
||||
| `favorite_food` | No | egg, baby, adult | Yes | generated | string | Preferred food type |
|
||||
| `voice_type` | No | egg, baby, adult | Yes | generated | string | Voice characteristic |
|
||||
| `mood` | No | egg, baby, adult | Yes | computed | string | Current emotional state |
|
||||
|
||||
**Not Regenerable**: These tags are generated once and MUST be preserved. Do NOT invent values for existing Blobbis that lack these tags.
|
||||
|
||||
### 5. Stat Tags
|
||||
|
||||
Numeric values representing the Blobbi's current condition. These are actively computed and change frequently.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `hunger` | No | egg, baby, adult | No | computed | 1-100 | 100 | Fullness level |
|
||||
| `happiness` | No | egg, baby, adult | No | computed | 1-100 | 100 | Happiness level |
|
||||
| `health` | No | egg, baby, adult | No | computed | 1-100 | 100 | Health level |
|
||||
| `hygiene` | No | egg, baby, adult | No | computed | 1-100 | 100 | Cleanliness level |
|
||||
| `energy` | No | egg, baby, adult | No | computed | 1-100 | 100 | Energy level |
|
||||
|
||||
**Stage Transition Behavior**:
|
||||
- **Hatch (egg → baby)**: `health` inherited from egg, others reset to 100
|
||||
- **Evolve (baby → adult)**: All stats inherited from baby (after decay)
|
||||
|
||||
### 6. State / Lifecycle Tags
|
||||
|
||||
Tags that track the Blobbi's current lifecycle state.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `stage` | **Yes** | egg, baby, adult | No | system | `egg\|baby\|adult` | Current lifecycle stage |
|
||||
| `state` | **Yes** | egg, baby, adult | No | system | `active\|sleeping\|hibernating\|incubating\|evolving` | Activity state |
|
||||
| `last_interaction` | **Yes** | egg, baby, adult | No | system | Unix timestamp | Last user action |
|
||||
| `last_decay_at` | No | egg, baby, adult | No | system | Unix timestamp | Decay checkpoint |
|
||||
|
||||
**State Constraints**:
|
||||
- `incubating` is only valid for `stage: egg`
|
||||
- `evolving` is only valid for `stage: baby`
|
||||
- After hatch/evolve completes, `state` MUST be set to `active`
|
||||
|
||||
### 7. Task System Tags
|
||||
|
||||
Temporary tags used during incubation and evolution processes. These are REMOVED after stage transitions.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `state_started_at` | No | egg, baby | No | system | Unix timestamp | When incubating/evolving started |
|
||||
| `task` | No | egg, baby | No | computed | `["task", "name:value"]` | Task progress (multiple allowed) |
|
||||
| `task_completed` | No | egg, baby | No | computed | `["task_completed", "name"]` | Completed tasks (multiple allowed) |
|
||||
|
||||
**Transition Behavior**: ALL task system tags MUST be removed when hatch or evolve completes.
|
||||
|
||||
### 8. Progression Tags
|
||||
|
||||
Long-term progress tracking that persists across all stages.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `experience` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Total XP |
|
||||
| `care_streak` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Consecutive care days |
|
||||
|
||||
### 9. Social / Flag Tags
|
||||
|
||||
User preferences and computed flags.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
|
||||
|
||||
### 10. Evolution Tags
|
||||
|
||||
Tags specific to adult Blobbis.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `adult_type` | No | adult | Yes | computed | string | Evolution form type |
|
||||
|
||||
### 11. Extension Tags
|
||||
|
||||
Optional tags for themes and crossover features.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Description |
|
||||
|-----|----------|--------|------------|--------|--------|-------------|
|
||||
| `theme` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Theme variant |
|
||||
| `crossover_app` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Crossover app identifier |
|
||||
|
||||
---
|
||||
|
||||
## Deprecated Tags
|
||||
|
||||
These tags are from legacy versions and MUST be removed when republishing events.
|
||||
|
||||
| Tag | Reason | Replaced By |
|
||||
|-----|--------|-------------|
|
||||
| `shell_integrity` | Eggs use standard `health` stat | `health` |
|
||||
| `egg_temperature` | Warmth handled via UI props | N/A |
|
||||
| `incubation_progress` | Replaced by task system | `task`, `task_completed` |
|
||||
| `egg_status` | Replaced by standard state | `state` |
|
||||
| `fees` | Removed | N/A |
|
||||
| `incubation_time` | Uses state_started_at | `state_started_at` |
|
||||
| `start_incubation` | Uses state_started_at | `state_started_at` |
|
||||
| `interact_6_progress` | Legacy interaction tracking | `["task", "interactions:N"]` |
|
||||
|
||||
---
|
||||
|
||||
## Stage Transition Rules
|
||||
|
||||
### Hatch (egg → baby)
|
||||
|
||||
**Tags to REMOVE**:
|
||||
- `task`
|
||||
- `task_completed`
|
||||
- `state_started_at`
|
||||
|
||||
**Tags to UPDATE**:
|
||||
- `stage` → `baby`
|
||||
- `state` → `active`
|
||||
- `hunger` → `100`
|
||||
- `happiness` → `100`
|
||||
- `hygiene` → `100`
|
||||
- `energy` → `100`
|
||||
- `health` → (inherited from egg after decay)
|
||||
- `last_interaction` → current timestamp
|
||||
- `last_decay_at` → current timestamp
|
||||
|
||||
**Tags to PRESERVE (all persistent tags)**:
|
||||
- All system tags (`d`, `b`, `t`, `client`)
|
||||
- All identity tags (`name`, `seed`, `generation`)
|
||||
- All visual tags (colors, pattern, size)
|
||||
- All personality tags (if present)
|
||||
- All progression tags (`experience`, `care_streak`)
|
||||
- All social tags (`breeding_ready`)
|
||||
- All extension tags (`theme`, `crossover_app`)
|
||||
|
||||
### Evolve (baby → adult)
|
||||
|
||||
**Tags to REMOVE**:
|
||||
- `task`
|
||||
- `task_completed`
|
||||
- `state_started_at`
|
||||
|
||||
**Tags to UPDATE**:
|
||||
- `stage` → `adult`
|
||||
- `state` → `active`
|
||||
- All stats → (inherited from baby after decay)
|
||||
- `last_interaction` → current timestamp
|
||||
- `last_decay_at` → current timestamp
|
||||
|
||||
**Tags to PRESERVE (all persistent tags)**:
|
||||
- Same as hatch, plus all stats are inherited (not reset)
|
||||
|
||||
**Tags to ADD (optional)**:
|
||||
- `adult_type` → computed based on care history
|
||||
|
||||
---
|
||||
|
||||
## Migration Rules
|
||||
|
||||
When migrating legacy Blobbis to canonical format:
|
||||
|
||||
1. **Always preserve existing values** - Do not regenerate tags that already exist
|
||||
2. **Generate missing required tags** - Derive `seed` if missing using the legacy event's `created_at`
|
||||
3. **Remove deprecated tags** - Filter out all tags in the deprecated list
|
||||
4. **Repair visual tags** - Regenerate from seed if missing (these are regenerable)
|
||||
5. **Do NOT invent personality tags** - If `personality`, `trait`, etc. don't exist, leave them empty
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
A valid Blobbi event MUST have:
|
||||
- `d` tag in canonical format
|
||||
- `b` tag = `blobbi:ecosystem:v1`
|
||||
- `t` tag = `blobbi`
|
||||
- `name` tag (non-empty)
|
||||
- `seed` tag (64 hex chars)
|
||||
- `stage` tag (valid value)
|
||||
- `state` tag (valid value)
|
||||
- `last_interaction` tag (valid timestamp)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
When implementing any flow that modifies Blobbi tags:
|
||||
|
||||
- [ ] Start from `canonical.allTags` as the base
|
||||
- [ ] Remove only task-specific tags (`task`, `task_completed`, `state_started_at`)
|
||||
- [ ] Preserve ALL persistent tags (identity, visual, personality, progression, social, extension)
|
||||
- [ ] Filter out deprecated tags
|
||||
- [ ] Update only the tags that need to change
|
||||
- [ ] Validate required tags are present
|
||||
+1
-1
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
|
||||
import customRules from "./eslint-rules/index.js";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "android", "ios"] },
|
||||
{ ignores: ["dist", "android", "ios", ".agents"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
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 */; };
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
|
||||
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */; };
|
||||
B1A2C3D40006000100000001 /* DittoNotificationPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */; };
|
||||
@@ -33,7 +32,6 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPlugin.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40004000100000002 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
@@ -76,7 +74,6 @@
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
B1A2C3D40004000100000002 /* App.entitlements */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
|
||||
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */,
|
||||
B1A2C3D40007000100000002 /* NostrPoller.swift */,
|
||||
@@ -174,7 +171,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
|
||||
B1A2C3D40006000100000001 /* DittoNotificationPlugin.swift in Sources */,
|
||||
B1A2C3D40007000100000001 /* NostrPoller.swift in Sources */,
|
||||
@@ -327,7 +323,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.8.0;
|
||||
MARKETING_VERSION = 2.14.4;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import WebKit
|
||||
|
||||
// MARK: - Plugin
|
||||
|
||||
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
|
||||
///
|
||||
/// Each sandbox gets a unique custom URL scheme (`sbx-<id>://`) so that
|
||||
/// every embedded app has its own origin (separate localStorage, cookies, etc.).
|
||||
/// All requests on the custom scheme are intercepted via `WKURLSchemeHandler`
|
||||
/// and forwarded to the JS layer as fetch events — the same protocol
|
||||
/// iframe.diy uses. This lets the existing React code serve files identically.
|
||||
@objc(SandboxPlugin)
|
||||
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "SandboxPlugin"
|
||||
public let jsName = "SandboxPlugin"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "navigate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "updateFrame", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "postMessage", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "destroy", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// Active sandbox instances, keyed by sandbox ID.
|
||||
private var sandboxes: [String: SandboxInstance] = [:]
|
||||
|
||||
// MARK: - Plugin Methods
|
||||
|
||||
@objc func create(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let frame = call.getObject("frame"),
|
||||
let x = frame["x"] as? Double,
|
||||
let y = frame["y"] as? Double,
|
||||
let width = frame["width"] as? Double,
|
||||
let height = frame["height"] as? Double else {
|
||||
call.reject("Missing or invalid parameter: frame")
|
||||
return
|
||||
}
|
||||
|
||||
if sandboxes[sandboxId] != nil {
|
||||
call.reject("Sandbox already exists: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let webViewFrame = CGRect(x: x, y: y, width: width, height: height)
|
||||
let sandbox = SandboxInstance(
|
||||
id: sandboxId,
|
||||
frame: webViewFrame,
|
||||
plugin: self
|
||||
)
|
||||
self.sandboxes[sandboxId] = sandbox
|
||||
|
||||
// Add the container (WebView + spinner overlay) on top of
|
||||
// the Capacitor WebView.
|
||||
if let bridge = self.bridge,
|
||||
let webView = bridge.webView {
|
||||
webView.superview?.addSubview(sandbox.containerView)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func navigate(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let sandbox = self?.sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.navigateToApp()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updateFrame(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let frame = call.getObject("frame"),
|
||||
let x = frame["x"] as? Double,
|
||||
let y = frame["y"] as? Double,
|
||||
let width = frame["width"] as? Double,
|
||||
let height = frame["height"] as? Double else {
|
||||
call.reject("Missing or invalid parameter: frame")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let sandbox = self?.sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.containerView.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func respondToFetch(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let requestId = call.getString("requestId") else {
|
||||
call.reject("Missing required parameter: requestId")
|
||||
return
|
||||
}
|
||||
guard let response = call.getObject("response") else {
|
||||
call.reject("Missing required parameter: response")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sandbox = sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
sandbox.schemeHandler.resolveRequest(
|
||||
requestId: requestId,
|
||||
status: response["status"] as? Int ?? 200,
|
||||
statusText: response["statusText"] as? String ?? "OK",
|
||||
headers: response["headers"] as? [String: String] ?? [:],
|
||||
bodyBase64: response["body"] as? String
|
||||
)
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func postMessage(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let message = call.getObject("message") else {
|
||||
call.reject("Missing required parameter: message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sandbox = sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
sandbox.postMessageToWebView(message)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func destroy(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
|
||||
sandbox.containerView.removeFromSuperview()
|
||||
sandbox.schemeHandler.cancelAll()
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Forwarding
|
||||
|
||||
/// Forward a fetch request from the native WebView to JS.
|
||||
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
|
||||
notifyListeners("fetch", data: [
|
||||
"id": sandboxId,
|
||||
"requestId": requestId,
|
||||
"request": request,
|
||||
])
|
||||
}
|
||||
|
||||
/// Forward a script message from the sandbox to JS.
|
||||
func emitScriptMessage(sandboxId: String, message: [String: Any]) {
|
||||
notifyListeners("scriptMessage", data: [
|
||||
"id": sandboxId,
|
||||
"message": message,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SandboxInstance
|
||||
|
||||
/// Manages a single sandboxed WKWebView instance.
|
||||
private class SandboxInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
|
||||
let id: String
|
||||
let webView: WKWebView
|
||||
let schemeHandler: SandboxSchemeHandler
|
||||
private weak var plugin: SandboxPlugin?
|
||||
private let customScheme: String
|
||||
|
||||
/// Container view that holds the WebView and spinner overlay.
|
||||
let containerView: UIView
|
||||
|
||||
/// Native spinner overlay, removed when the first page finishes loading.
|
||||
private var spinnerOverlay: UIView?
|
||||
|
||||
init(id: String, frame: CGRect, plugin: SandboxPlugin) {
|
||||
self.id = id
|
||||
self.plugin = plugin
|
||||
|
||||
// Each sandbox gets a unique custom URL scheme so that WKWebView
|
||||
// assigns a distinct origin, isolating localStorage/IndexedDB/cookies.
|
||||
self.customScheme = "sbx-\(id)"
|
||||
|
||||
self.schemeHandler = SandboxSchemeHandler(
|
||||
sandboxId: id,
|
||||
scheme: self.customScheme,
|
||||
plugin: plugin
|
||||
)
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: self.customScheme)
|
||||
|
||||
// Add a script message handler for communication from injected scripts.
|
||||
let userContentController = WKUserContentController()
|
||||
|
||||
// Inject a bridge script that:
|
||||
// 1. Provides window.parent.postMessage()-like functionality
|
||||
// 2. Routes messages through the native bridge
|
||||
let bridgeScript = WKUserScript(
|
||||
source: SandboxInstance.bridgeScript(scheme: self.customScheme),
|
||||
injectionTime: .atDocumentStart,
|
||||
forMainFrameOnly: false
|
||||
)
|
||||
userContentController.addUserScript(bridgeScript)
|
||||
|
||||
config.userContentController = userContentController
|
||||
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
|
||||
// Container view that holds the WebView + spinner overlay.
|
||||
self.containerView = UIView(frame: frame)
|
||||
|
||||
self.webView = WKWebView(frame: containerView.bounds, configuration: config)
|
||||
self.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.webView.isOpaque = false
|
||||
self.webView.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
|
||||
self.webView.scrollView.backgroundColor = self.webView.backgroundColor
|
||||
self.webView.scrollView.bounces = false
|
||||
self.containerView.addSubview(self.webView)
|
||||
|
||||
// Dark overlay behind the spinner.
|
||||
let overlay = UIView(frame: containerView.bounds)
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
overlay.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
|
||||
self.containerView.addSubview(overlay)
|
||||
|
||||
// Native spinner — uses UIActivityIndicatorView which animates on
|
||||
// the render thread independently of JS/main-thread work.
|
||||
let spinner = UIActivityIndicatorView(style: .medium)
|
||||
spinner.color = UIColor(red: 124/255.0, green: 92/255.0, blue: 220/255.0, alpha: 1)
|
||||
spinner.translatesAutoresizingMaskIntoConstraints = false
|
||||
spinner.startAnimating()
|
||||
overlay.addSubview(spinner)
|
||||
NSLayoutConstraint.activate([
|
||||
spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
||||
spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
|
||||
])
|
||||
|
||||
self.spinnerOverlay = overlay
|
||||
|
||||
super.init()
|
||||
|
||||
// Register the message handler and navigation delegate after super.init().
|
||||
userContentController.add(self, name: "sandboxBridge")
|
||||
self.webView.navigationDelegate = self
|
||||
}
|
||||
|
||||
/// Navigate the WebView to the sandbox's entry point.
|
||||
func navigateToApp() {
|
||||
let initialURL = URL(string: "\(customScheme)://app/index.html")!
|
||||
webView.load(URLRequest(url: initialURL))
|
||||
}
|
||||
|
||||
/// Remove the native loading overlay. Safe to call multiple times.
|
||||
func hideSpinner() {
|
||||
spinnerOverlay?.removeFromSuperview()
|
||||
spinnerOverlay = nil
|
||||
}
|
||||
|
||||
/// Post a JSON-RPC message to injected scripts inside the WebView.
|
||||
func postMessageToWebView(_ message: [String: Any]) {
|
||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
let js = """
|
||||
(function() {
|
||||
if (window.__sandboxBridge && window.__sandboxBridge.onMessage) {
|
||||
window.__sandboxBridge.onMessage(\(jsonString));
|
||||
}
|
||||
})();
|
||||
"""
|
||||
webView.evaluateJavaScript(js, completionHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: - WKScriptMessageHandler
|
||||
|
||||
/// Receive messages from injected scripts via webkit.messageHandlers.sandboxBridge.
|
||||
func userContentController(
|
||||
_ userContentController: WKUserContentController,
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
guard message.name == "sandboxBridge",
|
||||
let body = message.body as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
plugin?.emitScriptMessage(sandboxId: id, message: body)
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
/// Remove the spinner overlay once the first page finishes loading.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
hideSpinner()
|
||||
}
|
||||
|
||||
// MARK: - Bridge Script
|
||||
|
||||
/// JavaScript injected at document start that provides:
|
||||
/// - `window.parent.postMessage()` emulation via WKScriptMessageHandler
|
||||
/// - `window.__sandboxBridge.onMessage()` for receiving messages from parent
|
||||
/// - `window.addEventListener("message", ...)` support for injected scripts
|
||||
private static func bridgeScript(scheme: String) -> String {
|
||||
return """
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Message listeners registered by injected scripts.
|
||||
var messageListeners = [];
|
||||
|
||||
// Bridge object for native communication.
|
||||
window.__sandboxBridge = {
|
||||
onMessage: function(data) {
|
||||
// Dispatch to all registered message listeners.
|
||||
var event = {
|
||||
data: data,
|
||||
origin: '\(scheme)://app',
|
||||
source: window.parent,
|
||||
type: 'message'
|
||||
};
|
||||
for (var i = 0; i < messageListeners.length; i++) {
|
||||
try {
|
||||
messageListeners[i](event);
|
||||
} catch (e) {
|
||||
console.error('[SandboxBridge] Listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Override addEventListener to capture "message" listeners.
|
||||
var originalAddEventListener = window.addEventListener;
|
||||
window.addEventListener = function(type, listener, options) {
|
||||
if (type === 'message' && typeof listener === 'function') {
|
||||
messageListeners.push(listener);
|
||||
}
|
||||
return originalAddEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
var originalRemoveEventListener = window.removeEventListener;
|
||||
window.removeEventListener = function(type, listener, options) {
|
||||
if (type === 'message') {
|
||||
var idx = messageListeners.indexOf(listener);
|
||||
if (idx !== -1) messageListeners.splice(idx, 1);
|
||||
}
|
||||
return originalRemoveEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
// Emulate window.parent.postMessage for scripts that use it
|
||||
// (e.g. the webxdc bridge script, preview injected script).
|
||||
if (!window.parent || window.parent === window) {
|
||||
window.parent = {};
|
||||
}
|
||||
window.parent.postMessage = function(data, targetOrigin, transfer) {
|
||||
if (data && typeof data === 'object' && data.jsonrpc === '2.0') {
|
||||
try {
|
||||
window.webkit.messageHandlers.sandboxBridge.postMessage(data);
|
||||
} catch (e) {
|
||||
console.error('[SandboxBridge] postMessage failed:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SandboxSchemeHandler
|
||||
|
||||
/// WKURLSchemeHandler that intercepts all requests on the sandbox's custom
|
||||
/// URL scheme and forwards them to the JS layer as fetch events.
|
||||
private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
private let sandboxId: String
|
||||
private let scheme: String
|
||||
private weak var plugin: SandboxPlugin?
|
||||
|
||||
/// Pending scheme tasks waiting for a response from JS.
|
||||
/// Key: requestId (UUID string), Value: the WKURLSchemeTask to respond to.
|
||||
private var pendingTasks: [String: WKURLSchemeTask] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
|
||||
self.sandboxId = sandboxId
|
||||
self.scheme = scheme
|
||||
self.plugin = plugin
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
let request = urlSchemeTask.request
|
||||
guard let url = request.url else {
|
||||
urlSchemeTask.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
let requestId = UUID().uuidString
|
||||
|
||||
lock.lock()
|
||||
pendingTasks[requestId] = urlSchemeTask
|
||||
lock.unlock()
|
||||
|
||||
// Serialise the request for the fetch event.
|
||||
// Rewrite the URL so it looks like a normal HTTP URL to the parent
|
||||
// (e.g. "sbx-abc123://app/index.html" -> "https://<sandboxId>.sandbox.native/index.html")
|
||||
// The JS side only cares about the pathname.
|
||||
var headers: [String: String] = [:]
|
||||
if let allHeaders = request.allHTTPHeaderFields {
|
||||
headers = allHeaders
|
||||
}
|
||||
|
||||
var bodyBase64: String? = nil
|
||||
if let bodyData = request.httpBody {
|
||||
bodyBase64 = bodyData.base64EncodedString()
|
||||
}
|
||||
|
||||
let path = url.path.isEmpty ? "/" : url.path
|
||||
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
|
||||
|
||||
let serialisedRequest: [String: Any] = [
|
||||
"url": rewrittenURL,
|
||||
"method": request.httpMethod ?? "GET",
|
||||
"headers": headers,
|
||||
"body": bodyBase64 as Any,
|
||||
]
|
||||
|
||||
plugin?.emitFetchRequest(
|
||||
sandboxId: sandboxId,
|
||||
requestId: requestId,
|
||||
request: serialisedRequest
|
||||
)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
// Remove the task from pending — JS response will be ignored if it arrives later.
|
||||
lock.lock()
|
||||
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
|
||||
if let key = removed?.key {
|
||||
pendingTasks.removeValue(forKey: key)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Called by the plugin when JS responds to a fetch request.
|
||||
func resolveRequest(
|
||||
requestId: String,
|
||||
status: Int,
|
||||
statusText: String,
|
||||
headers: [String: String],
|
||||
bodyBase64: String?
|
||||
) {
|
||||
lock.lock()
|
||||
guard let task = pendingTasks.removeValue(forKey: requestId) else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
// Decode the base64 body.
|
||||
var bodyData: Data? = nil
|
||||
if let b64 = bodyBase64 {
|
||||
bodyData = Data(base64Encoded: b64)
|
||||
}
|
||||
|
||||
// Build the response.
|
||||
// Use the task's original URL for the response.
|
||||
let responseURL = task.request.url ?? URL(string: "\(scheme)://app/")!
|
||||
let response = HTTPURLResponse(
|
||||
url: responseURL,
|
||||
statusCode: status,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: headers
|
||||
)!
|
||||
|
||||
DispatchQueue.main.async {
|
||||
task.didReceive(response)
|
||||
if let data = bodyData {
|
||||
task.didReceive(data)
|
||||
}
|
||||
task.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all pending tasks (called on destroy).
|
||||
func cancelAll() {
|
||||
lock.lock()
|
||||
let tasks = pendingTasks
|
||||
pendingTasks.removeAll()
|
||||
lock.unlock()
|
||||
|
||||
for (_, task) in tasks {
|
||||
task.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -999,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Sandbox destroyed"]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
app_identifier("pub.ditto.app")
|
||||
team_id("GZLTTH5DLM")
|
||||
@@ -0,0 +1,146 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
# ─── Lanes ────────────────────────────────────────────────────────────
|
||||
|
||||
desc "Build and sign the App Store IPA. Output at ../artifacts/Ditto.ipa."
|
||||
lane :build_ipa do
|
||||
setup_lane_signing!
|
||||
build_release_ipa!
|
||||
end
|
||||
|
||||
desc "Submit an already-built IPA to App Store Connect for review. " \
|
||||
"Set IPA_PATH to the IPA's location."
|
||||
lane :submit_release do
|
||||
ipa_path = ENV.fetch("IPA_PATH") do
|
||||
UI.user_error!("submit_release requires the IPA_PATH env var")
|
||||
end
|
||||
UI.user_error!("IPA not found at #{ipa_path}") unless File.exist?(ipa_path)
|
||||
submit_release_for_review!(ipa_path)
|
||||
end
|
||||
|
||||
desc "Build, sign, and submit Ditto to the App Store for review (single-step convenience)."
|
||||
lane :release do
|
||||
setup_lane_signing!
|
||||
build_release_ipa!
|
||||
# Use the IPA path set by build_app rather than recomputing it from
|
||||
# __dir__, which gets fragile across fastlane-relative paths.
|
||||
ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||
UI.user_error!("build_app did not set IPA_OUTPUT_PATH") unless ipa_path
|
||||
submit_release_for_review!(ipa_path)
|
||||
end
|
||||
|
||||
desc "Submit an already-uploaded build for review (skip build/upload). " \
|
||||
"Use BUILD_NUMBER and VERSION env vars."
|
||||
lane :submit_only do
|
||||
submit_release_for_review!(nil)
|
||||
end
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def setup_lane_signing!
|
||||
# Create an ephemeral keychain so we never touch the login keychain.
|
||||
setup_ci
|
||||
|
||||
api_key = build_api_key!
|
||||
|
||||
# Fetch encrypted distribution cert + provisioning profile from the
|
||||
# shared certificates repo. --readonly: never mutate from CI.
|
||||
# Passing api_key makes match contact Apple's portal to verify the
|
||||
# cert is still valid for the team — fails fast on revoked/expired
|
||||
# certs instead of letting xcodebuild stumble later.
|
||||
match(type: "appstore", readonly: true, api_key: api_key)
|
||||
|
||||
api_key
|
||||
end
|
||||
|
||||
def build_api_key!
|
||||
# Build the API key hash inline. We avoid the app_store_connect_api_key
|
||||
# action because it sets APP_STORE_CONNECT_API_KEY_PATH (path to the .p8)
|
||||
# which collides with match's APP_STORE_CONNECT_API_KEY_PATH (path to a
|
||||
# JSON descriptor). Same env name, different formats.
|
||||
@api_key ||= {
|
||||
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
|
||||
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
|
||||
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
|
||||
duration: 1200,
|
||||
in_house: false,
|
||||
}
|
||||
end
|
||||
|
||||
def build_release_ipa!
|
||||
# Stamp build number from CI pipeline ID so every release is monotonically increasing.
|
||||
increment_build_number(
|
||||
xcodeproj: "App/App.xcodeproj",
|
||||
build_number: ENV.fetch("CI_PIPELINE_IID"),
|
||||
)
|
||||
|
||||
# Marketing version is set externally (sed in CI) before this lane runs.
|
||||
|
||||
build_app(
|
||||
project: "App/App.xcodeproj",
|
||||
scheme: "App",
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
output_directory: "../artifacts",
|
||||
output_name: "Ditto.ipa",
|
||||
clean: true,
|
||||
# Override the Xcode project's Automatic signing for this build only.
|
||||
# Match has already installed the AppStore cert + profile into the
|
||||
# ephemeral keychain; tell xcodebuild to use them explicitly so it
|
||||
# doesn't also try to find an iOS Development cert (which we never
|
||||
# provision in CI).
|
||||
xcargs: [
|
||||
"CODE_SIGN_STYLE=Manual",
|
||||
"CODE_SIGN_IDENTITY='Apple Distribution'",
|
||||
"PROVISIONING_PROFILE_SPECIFIER='match AppStore pub.ditto.app'",
|
||||
"DEVELOPMENT_TEAM=GZLTTH5DLM",
|
||||
].join(" "),
|
||||
export_options: {
|
||||
method: "app-store",
|
||||
signingStyle: "manual",
|
||||
teamID: "GZLTTH5DLM",
|
||||
provisioningProfiles: {
|
||||
"pub.ditto.app" => "match AppStore pub.ditto.app",
|
||||
},
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
# If ipa_path is nil, deliver picks up the latest processed build for the
|
||||
# configured app version (used by the submit_only lane).
|
||||
def submit_release_for_review!(ipa_path)
|
||||
api_key = build_api_key!
|
||||
|
||||
options = {
|
||||
api_key: api_key,
|
||||
submit_for_review: true,
|
||||
automatic_release: false,
|
||||
force: true,
|
||||
precheck_include_in_app_purchases: false,
|
||||
# Don't try to PATCH content rights on every submit — Apple's API
|
||||
# rejects updates to contentRightsDeclaration once the listing has
|
||||
# an established state. The values stay as set in the App Store
|
||||
# Connect UI / from a prior submission.
|
||||
submission_information: {
|
||||
export_compliance_uses_encryption: false,
|
||||
},
|
||||
skip_screenshots: true,
|
||||
# Keep skip_app_version_update=false: deliver needs to PATCH the
|
||||
# version's whatsNew (release notes) and platform-version metadata
|
||||
# before submit_for_review will accept the version.
|
||||
skip_app_version_update: false,
|
||||
skip_metadata: false,
|
||||
metadata_path: "./fastlane/metadata",
|
||||
run_precheck_before_submit: false,
|
||||
}
|
||||
options[:ipa] = ipa_path if ipa_path
|
||||
if ENV["BUILD_NUMBER"]
|
||||
options[:build_number] = ENV["BUILD_NUMBER"]
|
||||
options[:skip_binary_upload] = true
|
||||
end
|
||||
options[:app_version] = ENV["VERSION"] if ENV["VERSION"]
|
||||
|
||||
deliver(**options)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
git_url("https://gitlab.com/soapbox-pub/certificates.git")
|
||||
storage_mode("git")
|
||||
type("appstore")
|
||||
app_identifier(["pub.ditto.app"])
|
||||
team_id("GZLTTH5DLM")
|
||||
@@ -0,0 +1 @@
|
||||
Placeholder. CI overwrites this file with the release summary paragraph from CHANGELOG.md (the leading plaintext paragraph in the section for the current version).
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $vite_backend http://vite:8080;
|
||||
proxy_pass $vite_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
Generated
+173
-18
@@ -64,9 +64,9 @@
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@nostrify/nostrify": "^0.52.0",
|
||||
"@nostrify/react": "^0.6.0",
|
||||
"@nostrify/types": "^0.37.0",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
@@ -95,6 +95,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@samthomson/nostr-messaging": "^0.17.1",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
@@ -106,6 +107,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"d3-celestial": "^0.7.35",
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
@@ -196,6 +198,7 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -2564,6 +2567,7 @@
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
@@ -2577,6 +2581,7 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -2586,6 +2591,7 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
@@ -2596,11 +2602,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/nostrify": {
|
||||
"version": "0.51.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.1.tgz",
|
||||
"integrity": "sha512-oPJhUiO1TlV5sGYizqAP4GvLijib34Uwh48wxlFimR/2MoCuSmab4AppcztGPNwxQoTKkJbLJwsSpl42V+WIXA==",
|
||||
"version": "0.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.0.tgz",
|
||||
"integrity": "sha512-x+gc8rxJ4C+mnoFgd4Zzi0JnXUz0acQA69nKqR0fnWhpc/KiQosgIILfaNUTWkecTPJ92iazT4Es+TrUUSFcRg==",
|
||||
"dependencies": {
|
||||
"@nostrify/types": "0.36.9",
|
||||
"@nostrify/types": "0.37.0",
|
||||
"@scure/base": "^2.0.0",
|
||||
"@smithy/util-base64": "^4.3.0",
|
||||
"@smithy/util-hex-encoding": "^4.2.0",
|
||||
@@ -2637,12 +2643,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.1.tgz",
|
||||
"integrity": "sha512-gQUct8A7KLKvoLtv4bHpVDfmvzJlIHjZZI6DMui8vrSuzm8IqMRdAYADbR3ry1mlIQp8/c4EeR24piBpHK0WUw==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.0.tgz",
|
||||
"integrity": "sha512-6vjF5UagAW5QRpxAu/of9lyI7837wwoyX/NLGQbEs6fcMQXjTo/m7wUBPipoj0E460QvyNXff5O8Byn72enWbQ==",
|
||||
"dependencies": {
|
||||
"@nostrify/nostrify": "0.51.1",
|
||||
"@nostrify/types": "0.36.9"
|
||||
"@nostrify/nostrify": "0.52.0",
|
||||
"@nostrify/types": "0.37.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
@@ -2652,9 +2658,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/types": {
|
||||
"version": "0.36.9",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/types/-/types-0.36.9.tgz",
|
||||
"integrity": "sha512-tMx/r0W+QoVRRgs8d6ltaSgrftasOXuFsi33kW8WirswCy2b3UR1tqRgc0iBU9zRa9XR0nlej/wJZW+6wUFi+Q=="
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/types/-/types-0.37.0.tgz",
|
||||
"integrity": "sha512-P0AKR+qcNeBBTA5UDnJM6SxLAQbgud2+ZcdVyheoP37XGQvi7rUncQUDKwebG+Ui5kswp/IPEmvqNtCMQpwRoA=="
|
||||
},
|
||||
"node_modules/@ocavue/utils": {
|
||||
"version": "1.6.0",
|
||||
@@ -5822,6 +5828,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5835,6 +5842,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5848,6 +5856,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5861,6 +5870,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5874,6 +5884,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5887,6 +5898,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5900,6 +5912,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5913,6 +5926,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5926,6 +5940,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5939,6 +5954,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5952,6 +5968,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5965,6 +5982,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5978,6 +5996,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5991,6 +6010,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6004,6 +6024,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6017,6 +6038,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6030,6 +6052,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6043,6 +6066,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6056,6 +6080,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6069,6 +6094,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6082,6 +6108,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6095,6 +6122,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6108,6 +6136,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6121,6 +6150,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6134,12 +6164,46 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@samthomson/nostr-messaging": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@samthomson/nostr-messaging/-/nostr-messaging-0.17.1.tgz",
|
||||
"integrity": "sha512-TfgC3L/7sKnkLSqod1UyF9Bt/F36kH02nRffWjm5YEMfLvHLEYlT5ECgzyrnt9QVpYXG25rVAhEpXF9wxmPX0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"fuse.js": "^7.1.0",
|
||||
"idb": "^8.0.3",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"react-blurhash": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nostrify/nostrify": ">=0.47.0",
|
||||
"@nostrify/react": ">=0.2.0",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.0.0",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
|
||||
@@ -6750,6 +6814,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -6759,7 +6824,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -7450,12 +7515,14 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
@@ -7469,6 +7536,7 @@
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
@@ -7697,6 +7765,7 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7786,6 +7855,7 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@@ -7910,6 +7980,7 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -8043,6 +8114,7 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
@@ -8067,6 +8139,7 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -8194,6 +8267,7 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -8244,6 +8318,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
@@ -8272,6 +8347,12 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "3.5.17",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz",
|
||||
"integrity": "sha512-yFk/2idb8OHPKkbAL8QaOaqENNoMhIaSHZerk3oQsECwkObkCpJyjYwCe+OHiq6UEdhe1m8ZGARRRO3ljFjlKg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
@@ -8284,6 +8365,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-celestial": {
|
||||
"version": "0.7.35",
|
||||
"resolved": "https://registry.npmjs.org/d3-celestial/-/d3-celestial-0.7.35.tgz",
|
||||
"integrity": "sha512-cURxIl0E+FGWnYj6gTDt80SjuiM9lklcGykj/skVy7glDg5nj/QxTUoPPArU+bpEQ+1fLy5hi920OvJ/TgliRw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
@@ -8600,6 +8690,7 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
@@ -8612,6 +8703,7 @@
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
@@ -9044,6 +9136,7 @@
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
@@ -9060,6 +9153,7 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -9086,6 +9180,7 @@
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
@@ -9131,6 +9226,7 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -9217,6 +9313,7 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -9231,11 +9328,25 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz",
|
||||
"integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/krisk"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@@ -9278,6 +9389,7 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
@@ -9320,6 +9432,7 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -9663,6 +9776,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
@@ -9675,6 +9789,7 @@
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
@@ -9716,6 +9831,7 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -9734,6 +9850,7 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@@ -9804,6 +9921,7 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@@ -9862,6 +9980,7 @@
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
@@ -10302,6 +10421,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -10314,6 +10434,7 @@
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -10773,6 +10894,7 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -11364,6 +11486,7 @@
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
@@ -11471,6 +11594,7 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
@@ -11569,6 +11693,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -11688,6 +11813,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -11867,6 +11993,7 @@
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
@@ -11998,6 +12125,7 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -12010,6 +12138,7 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -12019,6 +12148,7 @@
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -12091,6 +12221,7 @@
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
||||
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-value-parser": "^4.0.0",
|
||||
@@ -12108,6 +12239,7 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
|
||||
"integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelcase-css": "^2.0.1"
|
||||
@@ -12127,6 +12259,7 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
|
||||
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -12162,6 +12295,7 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -12187,6 +12321,7 @@
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
@@ -12214,6 +12349,7 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
@@ -12630,6 +12766,7 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -13007,6 +13144,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pify": "^2.3.0"
|
||||
@@ -13031,6 +13169,7 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
@@ -13231,6 +13370,7 @@
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.0",
|
||||
@@ -13261,6 +13401,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
@@ -13416,7 +13557,7 @@
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -13703,6 +13844,7 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -14082,6 +14224,7 @@
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
@@ -14117,6 +14260,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -14146,6 +14290,7 @@
|
||||
"version": "3.4.17",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
@@ -14192,6 +14337,7 @@
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
@@ -14259,6 +14405,7 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
@@ -14268,6 +14415,7 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
@@ -14310,6 +14458,7 @@
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -14326,6 +14475,7 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -14343,6 +14493,7 @@
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -14405,6 +14556,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@@ -14486,6 +14638,7 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
@@ -14524,7 +14677,7 @@
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -14899,6 +15052,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
@@ -16673,6 +16827,7 @@
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
|
||||
+6
-4
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"test": "npm i --silent && tsc --noEmit && eslint --cache && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs",
|
||||
"keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload",
|
||||
"icons": "bash scripts/generate-icons.sh"
|
||||
@@ -71,9 +71,9 @@
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@nostrify/nostrify": "^0.52.0",
|
||||
"@nostrify/react": "^0.6.0",
|
||||
"@nostrify/types": "^0.37.0",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
@@ -102,6 +102,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@samthomson/nostr-messaging": "^0.17.1",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
@@ -113,6 +114,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"d3-celestial": "^0.7.35",
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
|
||||
+2
-445
@@ -1,450 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## [2.8.0] - 2026-04-16
|
||||
## [1.0.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
- Back up your secret key right from Profile settings -- reveal, copy, and save it to iCloud Keychain, Android Credential Manager, or a local file
|
||||
- Blobbi mission progress now persists across page refreshes, so your hatching and evolution journey picks up right where you left off
|
||||
|
||||
### Changed
|
||||
- AI chat has been overhauled with a cleaner layout, the Dork mascot across empty states, and a clear path to grab Shakespeare credits when you run out
|
||||
- Friendly error banners now explain when you've hit the rate limit or run out of AI credits, instead of cryptic failures
|
||||
|
||||
### Fixed
|
||||
- Avatar shape selection during signup now actually saves to your profile
|
||||
- Blobbi interaction missions now tally correctly the moment you start incubating or evolving
|
||||
- Blobbi task progress displays the right numbers immediately on page load instead of showing 0 until everything catches up
|
||||
|
||||
## [2.7.1] - 2026-04-16
|
||||
|
||||
### Added
|
||||
- Tap the Home tab while already on Home to scroll to the top and refresh your feed
|
||||
- Blobbi hatch and evolve missions now count your existing posts, themes, and color moments retroactively -- no need to start from scratch
|
||||
- New Blobbis begin incubating and evolving immediately after adoption, so every care action counts toward your next milestone
|
||||
|
||||
### Changed
|
||||
- Signup's save-key step is clearer: the button now reads "Save Key", shows a spinner while saving, and warns you before the key is revealed on screen
|
||||
- On de-Googled Android devices without a password manager, your key now safely falls back to a file in the app's Documents folder
|
||||
- Wallet connections and device keys are now stored in the iOS Keychain and Android KeyStore for stronger at-rest protection
|
||||
- Android's automatic cloud backup now excludes your wallet credentials
|
||||
|
||||
### Fixed
|
||||
- Scroll position is preserved when you navigate back from a post, profile, or any other page -- no more getting bounced to the top of your feed
|
||||
- Custom saved feeds now cache content and support infinite scroll like the Home, Ditto, and Global feeds
|
||||
- Various security hardening across themes, letters, profile banners, direct messages, and sandboxed apps to protect against malformed data
|
||||
|
||||
## [2.7.0] - 2026-04-14
|
||||
|
||||
### Added
|
||||
- Customizable widget sidebar -- drag, drop, and rearrange widgets on your feed including Trending, Hot Posts, Bluesky, AI Chat, Blobbi, Music, Photos, Wikipedia, and more
|
||||
- Blobbi rooms -- swipe between living spaces, clean up after your pet, and earn XP from daily care routines
|
||||
- Native push notifications on iOS with author names, content previews, and smart grouping by category
|
||||
- Haptic feedback throughout the app -- taps, buzzes, and pulses when you react, zap, repost, pull to refresh, play games, and interact with your Blobbi
|
||||
- Hot Posts widget showing the most popular posts from your feed at a glance
|
||||
|
||||
### Changed
|
||||
- Sidebar widgets are now clickable links that take you to their full pages
|
||||
- Blobbi widget shows live stats with circular ring indicators and quick action buttons
|
||||
|
||||
### Fixed
|
||||
- Zaps embedded in posts now render as proper inline cards instead of blank space
|
||||
- Quote posts display media and Blobbi companions correctly
|
||||
- Deep linking on Google Play works again
|
||||
- Game controller buttons no longer trigger text selection on long-press on iOS
|
||||
|
||||
## [2.6.6] - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
|
||||
- Emoji shortcodes now render as color emoji instead of plain text glyphs
|
||||
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
|
||||
- Signing requests on Android are more reliable and no longer silently fail after switching apps
|
||||
|
||||
## [2.6.5] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
|
||||
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
|
||||
|
||||
### Fixed
|
||||
- External API requests on Android no longer fail due to hostname restrictions
|
||||
- iOS App Store compliance issues resolved
|
||||
|
||||
## [2.6.4] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
|
||||
|
||||
### Changed
|
||||
- Empty feeds show a friendlier state with a discover button to help you find people to follow
|
||||
- Signup flow simplified -- cleaner profile step with a single Continue button
|
||||
|
||||
### Fixed
|
||||
- Avatar fallback now shows the user's initial instead of a question mark
|
||||
- Android 16+ devices no longer have content hidden behind system bars
|
||||
- Signup dialog background clears properly when switching between light and dark themes
|
||||
- Sticky compose button stays anchored to the bottom even on empty feeds
|
||||
|
||||
## [2.6.3] - 2026-04-10
|
||||
|
||||
### Added
|
||||
- Lightning invoices embedded in posts now render as tappable payment cards
|
||||
- Blobbi companions in the feed reflect their current condition and projected health
|
||||
|
||||
### Changed
|
||||
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
|
||||
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
|
||||
- "Request to Vanish" renamed to "Delete Account" for clarity
|
||||
|
||||
### Fixed
|
||||
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
|
||||
- Security hardening for URLs and styles sourced from the network
|
||||
|
||||
## [2.6.2] - 2026-04-08
|
||||
|
||||
### Added
|
||||
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
|
||||
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
|
||||
- "Write a letter" option on profile menus for a more personal way to reach out
|
||||
- Push vs persistent notification delivery option on Android
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps always open fullscreen for a more immersive experience
|
||||
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
|
||||
- Profile fields now appear inline instead of in a separate right sidebar
|
||||
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
|
||||
|
||||
### Fixed
|
||||
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
|
||||
- File downloads now save directly to Documents on iOS and Android instead of silently failing
|
||||
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
|
||||
- iOS swipe-back navigation works correctly throughout the app
|
||||
- Blobbi companions appear reliably on profiles instead of sometimes going missing
|
||||
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
- Manage your interest tabs (hashtags and locations) from the settings page
|
||||
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
|
||||
- Follow packs and follow sets now show author info and action headers in the feed
|
||||
- Posts now show whether they were created or updated, so you can tell when something's been edited
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
|
||||
- Nsite previews now use the same secure sandbox as webxdc apps
|
||||
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
|
||||
|
||||
### Fixed
|
||||
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
|
||||
- Mobile compose box no longer randomly collapses or becomes unclickable
|
||||
- Profile avatar and banner lightbox no longer hides behind the right sidebar
|
||||
- Infinite scroll on custom profile tab feeds no longer reloads the same content
|
||||
- Reaction emoji are now visible on each row in the interactions modal
|
||||
- Missing bottom border on collapsed thread expand button restored
|
||||
|
||||
## [2.6.0] - 2026-04-05
|
||||
|
||||
### Added
|
||||
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
|
||||
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
|
||||
|
||||
### Changed
|
||||
- Footer links redesigned as compact icon chips for a cleaner look
|
||||
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
|
||||
|
||||
### Fixed
|
||||
- Custom themes now apply correctly when logging in on a new device
|
||||
- Settings and preferences sync reliably across devices
|
||||
- Mobile sidebar links no longer clip into the safe area
|
||||
- Blobbi page background overlay now appears properly on custom themes
|
||||
- Blobbi companion state no longer resets unexpectedly from stale cache data
|
||||
- Letter compose picker no longer gets hidden behind the top navigation arc
|
||||
|
||||
## [2.5.2] - 2026-04-04
|
||||
|
||||
### Added
|
||||
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
|
||||
- Poll votes now appear as activity cards in feeds and on detail pages
|
||||
|
||||
### Fixed
|
||||
- Threads and replies load more reliably by following relay and author hints when fetching parent events
|
||||
|
||||
## [2.5.1] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Lightbox now reliably appears above all content, not just when opened from photo galleries
|
||||
|
||||
## [2.5.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
|
||||
- File uploads in the poll composer -- attach images and media to your polls
|
||||
- Blobbi posts now appear in the homepage feed
|
||||
|
||||
### Changed
|
||||
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
|
||||
- App cards now show banner images and improved layout
|
||||
|
||||
### Fixed
|
||||
- Lightbox no longer appears behind the right sidebar
|
||||
- Compose box corners are properly rounded
|
||||
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
|
||||
|
||||
## [2.4.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
|
||||
|
||||
### Fixed
|
||||
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
|
||||
|
||||
### Fixed
|
||||
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
|
||||
- Editing an existing article no longer incorrectly warns about a duplicate slug
|
||||
- Switching between rich text and markdown source mode no longer clears your content
|
||||
- Fix crash when editing in markdown source mode
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
|
||||
- Blobbi companions now appear in feeds and post detail pages
|
||||
|
||||
### Changed
|
||||
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
|
||||
- Emoji packs without any valid emojis are now hidden from feeds
|
||||
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
|
||||
|
||||
## [2.2.8] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
|
||||
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
|
||||
|
||||
### Changed
|
||||
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
|
||||
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
|
||||
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
|
||||
|
||||
### Fixed
|
||||
- Notification dot not clearing after marking notifications as read
|
||||
- Followers/following modal staying open after navigating to a profile
|
||||
|
||||
## [2.2.7] - 2026-03-31
|
||||
|
||||
### Fixed
|
||||
- Nushu script in encrypted letters now renders correctly on Android and iOS
|
||||
|
||||
## [2.2.6] - 2026-03-31
|
||||
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
- Badge detail page streamlined with unified tab bar
|
||||
|
||||
### Fixed
|
||||
- Hashtags now support accented and Unicode characters
|
||||
- Letter compose opens correctly from notifications and the letters page
|
||||
- Letter font picker loads fonts so each option previews in the correct typeface
|
||||
- Zap comment positioned inside the right column instead of floating with offset
|
||||
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
|
||||
|
||||
## [2.2.5] - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
- Crash when dragging profile tabs to reorder them
|
||||
|
||||
## [2.2.4] - 2026-03-30
|
||||
|
||||
### Changed
|
||||
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
|
||||
- Zap moved to the profile overflow menu so it's still one tap away
|
||||
|
||||
### Fixed
|
||||
- Crash on the notifications page caused by malformed badge award tags
|
||||
- Deleting a badge now also deletes all awards you issued for it
|
||||
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
|
||||
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
|
||||
- Profile reactions no longer collapse into a single grouped notification
|
||||
- Oversized reaction emoji in comment context headers
|
||||
|
||||
## [2.2.3] - 2026-03-30
|
||||
|
||||
### Added
|
||||
- Letters now have an overflow menu, reply button, and a grid layout for browsing
|
||||
- Independent feed toggles for comments and generic reposts in content settings
|
||||
- Sidebar items are now visible to logged-out users so newcomers can explore everything
|
||||
|
||||
### Changed
|
||||
- Compose textarea expands smoothly as you type instead of snapping to a new height
|
||||
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
|
||||
|
||||
### Fixed
|
||||
- Feed gaps when replies are disabled no longer cause missing posts
|
||||
- Avatar shape no longer flashes on load
|
||||
- Top bar arc no longer flickers during navigation transitions
|
||||
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
|
||||
- Notification rendering for badges and letters
|
||||
- Duplicate React keys in content settings
|
||||
- Layout rendering warning when switching views
|
||||
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
- Changelog link in the app footer
|
||||
|
||||
### Changed
|
||||
- "Vines" renamed to "Divines" everywhere in the app
|
||||
- Custom emojis appear first in the emoji picker, right after recent
|
||||
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
|
||||
|
||||
### Fixed
|
||||
- Delete post dialog no longer freezes the feed on desktop
|
||||
- Amber login on Android now properly retries when returning from the background
|
||||
- Key downloads on Android save to the correct location
|
||||
- Custom emoji SVGs render correctly in the emoji-mart picker
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Event links now route correctly for all event types
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
- Inconsistent use of "geocache" vs "treasures" terminology
|
||||
- Search page "N new posts" pill no longer shows unfiltered count
|
||||
- Stale-cache overwrites in replaceable event mutations
|
||||
- Click-through on delete confirmation and note menu items
|
||||
|
||||
## [2.2.1] - 2026-03-28
|
||||
|
||||
### Fixed
|
||||
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
|
||||
- Mobile header no longer shows double-layered backgrounds on notched devices
|
||||
- Pinned tabs stay properly positioned when scrolling on mobile
|
||||
- Signer approval toasts no longer fire in rapid succession on unstable connections
|
||||
- Toasts are easier to swipe away on mobile
|
||||
- Content warnings now blur thumbnails in the media grid
|
||||
|
||||
## [2.2.0] - 2026-03-28
|
||||
|
||||
### Added
|
||||
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
|
||||
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- Relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
### Changed
|
||||
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
|
||||
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
|
||||
- Upgraded from React 18 to React 19
|
||||
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
|
||||
|
||||
### Fixed
|
||||
- Zapping Primal users no longer produces an error
|
||||
- Hashtag feeds now match case-insensitively for parity with search results
|
||||
- Mobile top bar arc no longer lingers on pages without tabs
|
||||
- Give Badge dialog and profile menu action handlers
|
||||
|
||||
## [2.1.1] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- Emoji picker and shortcode autocomplete in zap comment box
|
||||
- Zap button on badge detail view
|
||||
- Theme descriptions now display on "updated their theme" posts and detail pages
|
||||
- Badge thumbnail previews in award notifications
|
||||
- Letter notifications with envelope card preview
|
||||
- Kind-specific labels in notification text instead of generic "post"
|
||||
|
||||
### Fixed
|
||||
- Compose modal no longer closes when dismissing emoji picker on mobile
|
||||
- Compose preview overflow is now scrollable in modal
|
||||
- Toast notifications swipe up to dismiss on mobile instead of sideways
|
||||
- File downloads and URL opening work correctly on iOS
|
||||
- Badges page no longer shows infinite skeleton when logged out
|
||||
|
||||
## [2.1.0] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal
|
||||
- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly
|
||||
- Stationery picker pulls from your color moments, followed users' themes, and built-in presets
|
||||
- Freehand drawing canvas for creating one-of-a-kind sticker doodles
|
||||
- Letters page added to the sidebar with a custom mailbox icon
|
||||
|
||||
## [2.0.1] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- Tap the version number in settings to see what's new
|
||||
|
||||
## [2.0.0] - 2026-03-26
|
||||
|
||||
Initial release of Ditto 2.0 -- a complete rewrite of Ditto.
|
||||
- Initial Agora 3 release.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
@@ -87,5 +87,18 @@
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
}
|
||||
]
|
||||
],
|
||||
"related_applications": [
|
||||
{
|
||||
"platform": "play",
|
||||
"url": "https://play.google.com/store/apps/details?id=pub.ditto.app",
|
||||
"id": "pub.ditto.app"
|
||||
},
|
||||
{
|
||||
"platform": "itunes",
|
||||
"url": "https://apps.apple.com/us/app/ditto-fun-social-media/id6761851821",
|
||||
"id": "6761851821"
|
||||
}
|
||||
],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extract release notes from CHANGELOG.md for a given version.
|
||||
*
|
||||
* The CHANGELOG follows Keep a Changelog format with one extension: each release
|
||||
* section MAY begin with a single plaintext paragraph (the "summary") before any
|
||||
* `### Added` / `### Changed` / etc. heading. The summary is used as the release
|
||||
* blurb on the App Store, Play Store, and the in-app version-update toast. The
|
||||
* full section body is used as the GitLab Release description.
|
||||
*
|
||||
* Format:
|
||||
*
|
||||
* ## [X.Y.Z] - YYYY-MM-DD
|
||||
*
|
||||
* A short single-paragraph summary (max 500 characters by convention).
|
||||
*
|
||||
* ### Added
|
||||
* - bullet
|
||||
* - bullet
|
||||
*
|
||||
* ### Changed
|
||||
* - bullet
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/extract-release-notes.mjs <version> [--summary] [--changelog <path>]
|
||||
*
|
||||
* --summary Print only the summary paragraph (no headings, no bullets).
|
||||
* Falls back to "Ditto vX.Y.Z" if the section has no summary.
|
||||
* --changelog Path to the changelog file. Defaults to CHANGELOG.md.
|
||||
*
|
||||
* Exits 0 with the extracted text on stdout. Exits non-zero if the version is
|
||||
* not found in the changelog.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { argv, exit, stderr, stdout } from 'node:process';
|
||||
|
||||
function parseArgs(args) {
|
||||
let version;
|
||||
let summary = false;
|
||||
let changelog = 'CHANGELOG.md';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--summary') summary = true;
|
||||
else if (arg === '--changelog') changelog = args[++i];
|
||||
else if (!arg.startsWith('--') && !version) version = arg;
|
||||
else {
|
||||
stderr.write(`Unknown argument: ${arg}\n`);
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
if (!version) {
|
||||
stderr.write('Usage: extract-release-notes.mjs <version> [--summary] [--changelog <path>]\n');
|
||||
exit(2);
|
||||
}
|
||||
// Strip a leading "v" so callers can pass either "v2.14.3" or "2.14.3".
|
||||
if (version.startsWith('v')) version = version.slice(1);
|
||||
return { version, summary, changelog };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the lines belonging to a single version section from changelog text,
|
||||
* not including the version heading itself.
|
||||
*/
|
||||
function extractSection(markdown, version) {
|
||||
const lines = markdown.split('\n');
|
||||
const headingPattern = new RegExp(
|
||||
`^## \\[${version.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\]`,
|
||||
);
|
||||
const nextHeadingPattern = /^## \[/;
|
||||
let inSection = false;
|
||||
const out = [];
|
||||
for (const line of lines) {
|
||||
if (!inSection) {
|
||||
if (headingPattern.test(line)) {
|
||||
inSection = true;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (nextHeadingPattern.test(line)) break;
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
return inSection ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the leading non-blank paragraph from a section, stopping at the first
|
||||
* `###` category heading or `-` bullet. Returns null if no summary paragraph.
|
||||
*/
|
||||
function extractSummary(sectionLines) {
|
||||
const paragraph = [];
|
||||
let started = false;
|
||||
for (const line of sectionLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!started) {
|
||||
if (!trimmed) continue;
|
||||
// If the very first non-blank line is a heading or bullet, there's no summary.
|
||||
if (trimmed.startsWith('#') || trimmed.startsWith('- ')) return null;
|
||||
started = true;
|
||||
paragraph.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
// We're inside the paragraph. A blank line, a heading, or a bullet ends it.
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('- ')) break;
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
return paragraph.length ? paragraph.join(' ') : null;
|
||||
}
|
||||
|
||||
/** Trim leading and trailing blank lines from a list of lines. */
|
||||
function trimBlankEdges(lines) {
|
||||
let start = 0;
|
||||
let end = lines.length;
|
||||
while (start < end && !lines[start].trim()) start++;
|
||||
while (end > start && !lines[end - 1].trim()) end--;
|
||||
return lines.slice(start, end);
|
||||
}
|
||||
|
||||
const { version, summary, changelog } = parseArgs(argv.slice(2));
|
||||
const markdown = readFileSync(changelog, 'utf8');
|
||||
const section = extractSection(markdown, version);
|
||||
|
||||
if (!section) {
|
||||
stderr.write(`Version ${version} not found in ${changelog}\n`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
const text = extractSummary(section);
|
||||
stdout.write(text ?? `Ditto v${version}`);
|
||||
stdout.write('\n');
|
||||
} else {
|
||||
const body = trimBlankEdges(section).join('\n');
|
||||
if (body) {
|
||||
stdout.write(body);
|
||||
stdout.write('\n');
|
||||
} else {
|
||||
stdout.write(`Ditto v${version}\n`);
|
||||
}
|
||||
}
|
||||
+34
-41
@@ -1,14 +1,12 @@
|
||||
// NOTE: This file should normally not be modified unless you are adding a new provider.
|
||||
// To add new routes, edit the AppRouter.tsx file.
|
||||
|
||||
import { Capacitor, SystemBars, SystemBarsStyle } from "@capacitor/core";
|
||||
import { NostrLoginProvider } from "@nostrify/react/login";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { InferSeoMetaPlugin } from "@unhead/addons";
|
||||
import { createHead, UnheadProvider } from "@unhead/react/client";
|
||||
import { useEffect } from "react";
|
||||
import { AppProvider } from "@/components/AppProvider";
|
||||
import { DMProvider, type DMConfig } from "@/components/DMProvider";
|
||||
import { DMProviderWrapper } from "@/components/DMProviderWrapper";
|
||||
import { InitialSyncGate } from "@/components/InitialSyncGate";
|
||||
import { NativeNotifications } from "@/components/NativeNotifications";
|
||||
import NostrProvider from "@/components/NostrProvider";
|
||||
@@ -22,17 +20,11 @@ import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { SparkWalletProvider } from "@/contexts/SparkWalletContext";
|
||||
import { PROTOCOL_MODE } from "@/lib/dmConstants";
|
||||
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import { PROTOCOL_MODE } from "@samthomson/nostr-messaging/core";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
const dmConfig: DMConfig = {
|
||||
enabled: false,
|
||||
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
|
||||
};
|
||||
|
||||
const head = createHead({
|
||||
plugins: [InferSeoMetaPlugin()],
|
||||
});
|
||||
@@ -51,12 +43,13 @@ const queryClient = new QueryClient({
|
||||
const hardcodedConfig: AppConfig = {
|
||||
appName: "Agora",
|
||||
appId: "agora",
|
||||
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
|
||||
homePage: "feed",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
magicMouse: false,
|
||||
theme: "system",
|
||||
autoShareTheme: true,
|
||||
useAppRelays: true,
|
||||
useUserRelays: false,
|
||||
relayMetadata: {
|
||||
relays: [],
|
||||
updatedAt: 0,
|
||||
@@ -66,8 +59,12 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeComments: true,
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeReactions: false,
|
||||
feedIncludeZaps: false,
|
||||
feedIncludeArticles: true,
|
||||
showArticles: true,
|
||||
showHighlights: true,
|
||||
feedIncludeHighlights: false,
|
||||
showEvents: true,
|
||||
feedIncludeEvents: true,
|
||||
showVines: true,
|
||||
@@ -76,13 +73,13 @@ const hardcodedConfig: AppConfig = {
|
||||
showTreasureGeocaches: true,
|
||||
showTreasureFoundLogs: true,
|
||||
showColors: true,
|
||||
showPacks: true,
|
||||
showPeopleLists: true,
|
||||
feedIncludeVines: true,
|
||||
feedIncludePolls: true,
|
||||
feedIncludeTreasureGeocaches: true,
|
||||
feedIncludeTreasureFoundLogs: true,
|
||||
feedIncludeColors: true,
|
||||
feedIncludePacks: true,
|
||||
feedIncludePeopleLists: true,
|
||||
showDecks: true,
|
||||
feedIncludeDecks: true,
|
||||
showWebxdc: true,
|
||||
@@ -92,13 +89,6 @@ const hardcodedConfig: AppConfig = {
|
||||
showVideos: true,
|
||||
feedIncludeNormalVideos: true,
|
||||
feedIncludeShortVideos: true,
|
||||
showProfileThemes: false,
|
||||
feedIncludeProfileThemes: true,
|
||||
showThemeDefinitions: true,
|
||||
feedIncludeThemeDefinitions: true,
|
||||
showProfileThemeUpdates: true,
|
||||
feedIncludeProfileThemeUpdates: true,
|
||||
showCustomProfileThemes: true,
|
||||
feedIncludeVoiceMessages: true,
|
||||
showEmojiPacks: true,
|
||||
feedIncludeEmojiPacks: true,
|
||||
@@ -117,10 +107,15 @@ const hardcodedConfig: AppConfig = {
|
||||
showBadges: true,
|
||||
showBadgeDefinitions: true,
|
||||
showProfileBadges: true,
|
||||
showBadgeAwards: true,
|
||||
feedIncludeBadgeDefinitions: true,
|
||||
feedIncludeProfileBadges: true,
|
||||
feedIncludeBadgeAwards: true,
|
||||
feedIncludeVanish: true,
|
||||
feedIncludeBlobbi: true,
|
||||
showBirdstar: true,
|
||||
feedIncludeBirdDetections: true,
|
||||
feedIncludeBirdex: true,
|
||||
feedIncludeConstellations: true,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
@@ -132,6 +127,7 @@ const hardcodedConfig: AppConfig = {
|
||||
"badges",
|
||||
"feed",
|
||||
"notifications",
|
||||
"messages",
|
||||
"communities",
|
||||
"profile",
|
||||
"settings",
|
||||
@@ -152,13 +148,23 @@ const hardcodedConfig: AppConfig = {
|
||||
plausibleDomain: import.meta.env.VITE_PLAUSIBLE_DOMAIN || "",
|
||||
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
|
||||
savedFeeds: [],
|
||||
autoplayVideos: false,
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
esploraBaseUrl: 'https://mempool.space/api',
|
||||
sidebarWidgets: [
|
||||
{ id: 'trends' },
|
||||
{ id: 'hot-posts' },
|
||||
],
|
||||
messaging: {
|
||||
enabled: true,
|
||||
relayMode: 'hybrid',
|
||||
protocolMode: PROTOCOL_MODE.NIP17_ONLY,
|
||||
renderInlineMedia: true,
|
||||
soundEnabled: false,
|
||||
devMode: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -191,17 +197,6 @@ const defaultConfig: AppConfig = {
|
||||
export function App() {
|
||||
useNsecPasteGuard();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize system bars for mobile apps.
|
||||
// On Android 16+ (API 36), edge-to-edge is enforced by the OS so
|
||||
// setOverlaysWebView / setBackgroundColor no longer work. The new
|
||||
// SystemBars API (bundled with @capacitor/core 8+) is the replacement.
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
|
||||
// SystemBars may not be available on all platforms
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UnheadProvider head={head}>
|
||||
@@ -216,15 +211,13 @@ export function App() {
|
||||
|
||||
<NWCProvider>
|
||||
<SparkWalletProvider>
|
||||
<DMProvider config={dmConfig}>
|
||||
<EmotionDevProvider>
|
||||
<TooltipProvider>
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
</TooltipProvider>
|
||||
</EmotionDevProvider>
|
||||
</DMProvider>
|
||||
<DMProviderWrapper>
|
||||
<TooltipProvider>
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
</TooltipProvider>
|
||||
</DMProviderWrapper>
|
||||
</SparkWalletProvider>
|
||||
</NWCProvider>
|
||||
</NostrProvider>
|
||||
|
||||
+125
-14
@@ -4,7 +4,6 @@ import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
|
||||
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
|
||||
import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
|
||||
import { sidebarItemIcon } from "@/lib/sidebarItems";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
@@ -17,24 +16,30 @@ import { getExtraKindDef } from "./lib/extraKinds";
|
||||
// Critical-path pages: eagerly loaded (landing + fallback)
|
||||
import Index from "./pages/Index";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
// Lazy-loaded companion layer (~450K code-split)
|
||||
const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => ({ default: m.BlobbiCompanionLayer })));
|
||||
import MessagesPage from "./pages/Messages";
|
||||
|
||||
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
|
||||
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
|
||||
|
||||
// Lazy-loaded emoji pack dialog
|
||||
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
// HomePage eagerly imported all page components; now lazy-loaded
|
||||
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
|
||||
|
||||
// All other pages: code-split via React.lazy
|
||||
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
|
||||
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
|
||||
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
|
||||
const AppearanceSettingsPage = lazy(() => import("./pages/AppearanceSettingsPage").then(m => ({ default: m.AppearanceSettingsPage })));
|
||||
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
|
||||
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
|
||||
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
|
||||
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
|
||||
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
|
||||
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
@@ -49,27 +54,42 @@ const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m
|
||||
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
|
||||
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
|
||||
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
|
||||
const MessagingSettingsPage = lazy(() => import("./pages/MessagingSettingsPage").then(m => ({ default: m.MessagingSettingsPage })));
|
||||
const MusicPage = lazy(() => import("./pages/MusicPage").then(m => ({ default: m.MusicPage })));
|
||||
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
|
||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
|
||||
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
|
||||
const OrganizersPage = lazy(() => import("./pages/OrganizersPage").then(m => ({ default: m.OrganizersPage })));
|
||||
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
|
||||
const PodcastsFeedPage = lazy(() => import("./pages/PodcastsFeedPage").then(m => ({ default: m.PodcastsFeedPage })));
|
||||
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
|
||||
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
|
||||
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
|
||||
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
|
||||
const ThemesPage = lazy(() => import("./pages/ThemesPage").then(m => ({ default: m.ThemesPage })));
|
||||
const TreasuresPage = lazy(() => import("./pages/TreasuresPage").then(m => ({ default: m.TreasuresPage })));
|
||||
const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: m.TrendsPage })));
|
||||
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
|
||||
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const VerifiedPage = lazy(() => import("./pages/VerifiedPage").then(m => ({ default: m.VerifiedPage })));
|
||||
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
|
||||
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
|
||||
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
|
||||
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
const colorsDef = getExtraKindDef("colors")!;
|
||||
const packsDef = getExtraKindDef("packs")!;
|
||||
const articlesDef = getExtraKindDef("articles")!;
|
||||
const decksDef = getExtraKindDef("decks")!;
|
||||
const emojisDef = getExtraKindDef("emojis")!;
|
||||
const developmentDef = getExtraKindDef("development")!;
|
||||
const highlightsDef = getExtraKindDef("highlights")!;
|
||||
|
||||
/** Polls feed page with a FAB that opens the compose modal (poll mode via + menu). */
|
||||
function PollsFeedPage() {
|
||||
@@ -91,6 +111,26 @@ function PollsFeedPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Emoji feed page with a FAB that opens the emoji pack creation dialog. */
|
||||
function EmojiFeedPage() {
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<KindFeedPage
|
||||
kind={emojisDef.kind}
|
||||
title={emojisDef.label}
|
||||
icon={sidebarItemIcon("emojis", "size-5")}
|
||||
onFabClick={() => setComposeOpen(true)}
|
||||
/>
|
||||
{composeOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<EmojiPackDialog open={composeOpen} onOpenChange={setComposeOpen} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
|
||||
function ProfileRedirect() {
|
||||
const { user, metadata } = useCurrentUser();
|
||||
@@ -109,11 +149,6 @@ export function AppRouter() {
|
||||
<AudioNavigationGuard />
|
||||
<DeepLinkHandler />
|
||||
<ScrollToTop />
|
||||
<BlobbiActionsProvider>
|
||||
<Suspense fallback={null}>
|
||||
<BlobbiCompanionLayer />
|
||||
</Suspense>
|
||||
</BlobbiActionsProvider>
|
||||
<Routes>
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
@@ -123,12 +158,15 @@ export function AppRouter() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/messages" element={<MessagesPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/trends" element={<TrendsPage />} />
|
||||
<Route path="/profile" element={<ProfileRedirect />} />
|
||||
<Route path="/t/:tag" element={<HashtagPage />} />
|
||||
<Route path="/g/:geohash" element={<GeotagPage />} />
|
||||
<Route path="/feed/:domain" element={<DomainFeedPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
|
||||
<Route path="/settings/profile" element={<ProfileSettings />} />
|
||||
<Route path="/settings/feed" element={<ContentSettingsPage />} />
|
||||
<Route path="/settings/content" element={<ContentPage />} />
|
||||
@@ -137,6 +175,7 @@ export function AppRouter() {
|
||||
path="/settings/notifications"
|
||||
element={<NotificationSettings />}
|
||||
/>
|
||||
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
|
||||
<Route
|
||||
path="/settings/advanced"
|
||||
element={<AdvancedSettingsPage />}
|
||||
@@ -146,7 +185,38 @@ export function AppRouter() {
|
||||
<Route path="/lists" element={<UserListsPage />} />
|
||||
<Route path="/events" element={<EventsFeedPage />} />
|
||||
<Route path="/photos" element={<PhotosFeedPage />} />
|
||||
<Route path="/videos" element={<VideosFeedPage />} />
|
||||
{/* /streams redirects to /videos for backward compatibility */}
|
||||
<Route
|
||||
path="/streams"
|
||||
element={<Navigate to="/videos" replace />}
|
||||
/>
|
||||
<Route path="/vines" element={<VinesFeedPage />} />
|
||||
<Route path="/music" element={<MusicPage />} />
|
||||
<Route path="/podcasts" element={<PodcastsFeedPage />} />
|
||||
<Route path="/polls" element={<PollsFeedPage />} />
|
||||
<Route path="/treasures" element={<TreasuresPage />} />
|
||||
<Route
|
||||
path="/colors"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={colorsDef.kind}
|
||||
title={colorsDef.label}
|
||||
icon={sidebarItemIcon("colors", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packs"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={packsDef.kind}
|
||||
title={packsDef.label}
|
||||
icon={sidebarItemIcon("packs", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/webxdc" element={<WebxdcFeedPage />} />
|
||||
<Route path="/articles/new" element={<ArticleEditorPage />} />
|
||||
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
|
||||
<Route
|
||||
@@ -160,11 +230,52 @@ export function AppRouter() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/themes" element={<ThemesPage />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route
|
||||
path="/highlights"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={highlightsDef.kind}
|
||||
title={highlightsDef.label}
|
||||
icon={sidebarItemIcon("highlights", "size-5")}
|
||||
showFAB={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/decks"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={decksDef.kind}
|
||||
title={decksDef.label}
|
||||
icon={sidebarItemIcon("decks", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/emojis" element={<EmojiFeedPage />} />
|
||||
<Route
|
||||
path="/development"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={[
|
||||
developmentDef.kind,
|
||||
...(developmentDef.extraFeedKinds ?? []),
|
||||
]}
|
||||
title={developmentDef.label}
|
||||
icon={sidebarItemIcon("development", "size-5")}
|
||||
showFAB={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
<Route path="/verified" element={<VerifiedPage />} />
|
||||
<Route path="/world" element={<WorldPage />} />
|
||||
<Route path="/badges" element={<BadgesPage />} />
|
||||
<Route path="/books" element={<BooksPage />} />
|
||||
<Route path="/archive" element={<ArchivePage />} />
|
||||
<Route path="/bluesky" element={<BlueskyPage />} />
|
||||
<Route path="/wikipedia" element={<WikipediaPage />} />
|
||||
<Route path="/communities" element={<CommunitiesPage />} />
|
||||
<Route path="/letters" element={<LettersPage />} />
|
||||
<Route path="/letters/compose" element={<LetterComposePage />} />
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
filterInventoryByAction,
|
||||
previewStatChanges,
|
||||
previewMedicineForEgg,
|
||||
previewCleanForEgg,
|
||||
canUseAction,
|
||||
getStageRestrictionMessage,
|
||||
ACTION_METADATA,
|
||||
type InventoryAction,
|
||||
type ResolvedInventoryItem,
|
||||
type EggStatPreview,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
|
||||
interface BlobbiActionInventoryModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
action: InventoryAction;
|
||||
companion: BlobbiCompanion;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called when user taps Use on an item. Always uses once. */
|
||||
onUseItem: (itemId: string) => void;
|
||||
onOpenShop: () => void;
|
||||
isUsingItem: boolean;
|
||||
usingItemId: string | null;
|
||||
}
|
||||
|
||||
export function BlobbiActionInventoryModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
action,
|
||||
companion,
|
||||
profile: _profile,
|
||||
onUseItem,
|
||||
onOpenShop: _onOpenShop,
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
}: BlobbiActionInventoryModalProps) {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
|
||||
// Get all available items for this action from the catalog (not inventory).
|
||||
// Items are abilities/tools — no ownership required.
|
||||
const availableItems = useMemo(() => {
|
||||
return filterInventoryByAction([], action, { stage: companion.stage });
|
||||
}, [action, companion.stage]);
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
const canUse = canUseAction(companion, action);
|
||||
const stageMessage = getStageRestrictionMessage(companion, action);
|
||||
|
||||
const isEmpty = availableItems.length === 0;
|
||||
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (isUsingItem) return;
|
||||
onUseItem(item.itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center text-xl sm:text-2xl shrink-0">
|
||||
{actionMeta.icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-lg sm:text-xl">{actionMeta.label}</DialogTitle>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground truncate">
|
||||
{actionMeta.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
{/* Stage Restriction Message */}
|
||||
{!canUse && stageMessage && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="size-16 rounded-2xl bg-amber-500/10 flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">🥚</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Not Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
{stageMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{canUse && isEmpty && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">{actionMeta.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
No items are available for this action at your Blobbi's current stage.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Item List */}
|
||||
{canUse && !isEmpty && (
|
||||
<div className="grid gap-3">
|
||||
{availableItems.map((item) => (
|
||||
<BlobbiInventoryUseRow
|
||||
key={item.itemId}
|
||||
item={item}
|
||||
companion={companion}
|
||||
action={action}
|
||||
onUse={() => handleUseItem(item)}
|
||||
isUsing={isUsingItem && usingItemId === item.itemId}
|
||||
disabled={isUsingItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inventory Use Row ────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiInventoryUseRowProps {
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
action: InventoryAction;
|
||||
onUse: () => void;
|
||||
isUsing: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function BlobbiInventoryUseRow({
|
||||
item,
|
||||
companion,
|
||||
action,
|
||||
onUse,
|
||||
isUsing,
|
||||
disabled,
|
||||
}: BlobbiInventoryUseRowProps) {
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isMedicine = action === 'medicine';
|
||||
const isClean = action === 'clean';
|
||||
|
||||
// Preview stat changes - handle egg-specific preview for medicine and clean
|
||||
const { normalStatChanges, eggStatChanges } = useMemo(() => {
|
||||
if (isEgg && isMedicine) {
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
|
||||
};
|
||||
}
|
||||
if (isEgg && isClean) {
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewCleanForEgg(
|
||||
{ hygiene: companion.stats.hygiene, happiness: companion.stats.happiness },
|
||||
item.effect
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
normalStatChanges: previewStatChanges(companion.stats, item.effect),
|
||||
eggStatChanges: [] as EggStatPreview[],
|
||||
};
|
||||
}, [companion.stats, item.effect, isEgg, isMedicine, isClean]);
|
||||
|
||||
const hasChanges = normalStatChanges.length > 0 || eggStatChanges.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm hover:border-primary/30 transition-colors">
|
||||
{/* Top row on mobile: Icon + Info + Button */}
|
||||
<div className="flex items-center gap-3 sm:contents">
|
||||
{/* Item Icon */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
|
||||
<div className="relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl">
|
||||
{item.icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* Effect Preview - shown inline on desktop */}
|
||||
<div className="hidden sm:block">
|
||||
{hasChanges && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onUse}
|
||||
disabled={disabled}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isUsing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
'Use'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Effect Preview - shown below on mobile */}
|
||||
{hasChanges && (
|
||||
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
// src/blobbi/actions/components/BlobbiActionsModal.tsx
|
||||
|
||||
import { Loader2, Moon, Sun, Utensils, Gamepad2, Sparkles as SparklesIcon, Pill, Music, Mic, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
|
||||
|
||||
interface BlobbiActionsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companion: BlobbiCompanion;
|
||||
onRest: () => void;
|
||||
onInventoryAction: (action: InventoryAction) => void;
|
||||
onDirectAction: (action: DirectAction) => void;
|
||||
actionInProgress: string | null;
|
||||
isPublishing: boolean;
|
||||
}
|
||||
|
||||
export function BlobbiActionsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
onRest,
|
||||
onInventoryAction,
|
||||
onDirectAction,
|
||||
actionInProgress,
|
||||
isPublishing,
|
||||
}: BlobbiActionsModalProps) {
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
const isDisabled = isPublishing || actionInProgress !== null;
|
||||
const isEgg = companion.stage === 'egg';
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle>Blobbi Actions</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">{companion.name}</p>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="grid gap-3">
|
||||
{/* Feed Action - hidden for eggs */}
|
||||
{!isEgg && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('feed'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Utensils className="size-5 text-orange-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Feed</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Give your Blobbi something to eat
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Play Action - hidden for eggs */}
|
||||
{!isEgg && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('play'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Gamepad2 className="size-5 text-yellow-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Play</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Play with toys to make your Blobbi happy
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Clean Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('clean'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SparklesIcon className="size-5 text-blue-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Clean</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Keep your egg clean and fresh'
|
||||
: 'Keep your Blobbi clean and fresh'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Medicine Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onInventoryAction('medicine'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Pill className="size-5 text-green-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Medicine</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Keep your egg healthy'
|
||||
: 'Heal your Blobbi'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Play Music Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onDirectAction('play_music'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Music className="size-5 text-pink-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Play Music</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Play soothing music for your egg'
|
||||
: 'Play music for your Blobbi'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Sing Action - available for all stages */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(() => onDirectAction('sing'))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Mic className="size-5 text-purple-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Sing</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEgg
|
||||
? 'Sing a lullaby to your egg'
|
||||
: 'Sing to your Blobbi'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Sleep/Wake Action - hidden for eggs */}
|
||||
{!isEgg && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-14"
|
||||
onClick={() => handleAction(onRest)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{actionInProgress === 'rest' ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : isSleeping ? (
|
||||
<Sun className="size-5 text-amber-500" />
|
||||
) : (
|
||||
<Moon className="size-5 text-violet-500" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="font-medium">{isSleeping ? 'Wake Up' : 'Sleep'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSleeping ? 'Wake your Blobbi up' : 'Put your Blobbi to sleep'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
|
||||
|
||||
/**
|
||||
* Missions modal for Blobbi — card-grid quest board.
|
||||
*
|
||||
* Layout:
|
||||
* 1. Sticky header with title, subtitle, legend help button, close
|
||||
* 2. Current Focus section (hatch / evolve) — collapsible, default open
|
||||
* 3. Daily Bounties section — collapsible, default open
|
||||
* 4. Settings row — low emphasis toggle (not collapsible)
|
||||
*
|
||||
* Both main sections use lightweight Radix Collapsible wrappers.
|
||||
* Collapsed headers still show summary info (progress / coins).
|
||||
*/
|
||||
|
||||
import {
|
||||
Loader2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
X,
|
||||
Eye,
|
||||
Scroll,
|
||||
Compass,
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogClose } from '@/components/ui/dialog';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { HatchTasksResult } from '../hooks/useHatchTasks';
|
||||
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
|
||||
import { TasksPanel } from './TasksPanel';
|
||||
import { DailyMissionsPanel } from './DailyMissionsPanel';
|
||||
import { useDailyMissions } from '../hooks/useDailyMissions';
|
||||
import { useRerollMission } from '../hooks/useRerollMission';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiMissionsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companion: BlobbiCompanion;
|
||||
hatchTasks: HatchTasksResult;
|
||||
evolveTasks: EvolveTasksResult;
|
||||
onOpenPostModal: () => void;
|
||||
onHatch: () => void;
|
||||
isHatching: boolean;
|
||||
onEvolve: () => void;
|
||||
isEvolving: boolean;
|
||||
onStopIncubation: () => Promise<void>;
|
||||
isStoppingIncubation: boolean;
|
||||
onStopEvolution: () => Promise<void>;
|
||||
isStoppingEvolution: boolean;
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
showMissionCard?: boolean;
|
||||
onToggleMissionCard?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
// ─── Section Chevron ─────────────────────────────────────────────────────────
|
||||
|
||||
function SectionChevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground/60 transition-transform duration-200',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mission Type Legend ──────────────────────────────────────────────────────
|
||||
|
||||
function MissionTypeLegend() {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 opacity-50 hover:opacity-100 hover:bg-muted transition-all"
|
||||
aria-label="Mission types legend"
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="end" className="w-56 p-3">
|
||||
<p className="text-xs font-semibold mb-2">Mission Types</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
|
||||
<Scroll className="size-3 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium">Daily Bounty</p>
|
||||
<p className="text-[10px] text-muted-foreground">Resets every day</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded-full bg-sky-500/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs">🥚</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium">Hatch Task</p>
|
||||
<p className="text-[10px] text-muted-foreground">Egg progression</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs">🐣</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium">Evolve Task</p>
|
||||
<p className="text-[10px] text-muted-foreground">Baby progression</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily Missions Section ───────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsSectionProps {
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
disabled?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function DailyMissionsSection({
|
||||
availableStages,
|
||||
disabled,
|
||||
defaultOpen = true,
|
||||
}: DailyMissionsSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const {
|
||||
missions,
|
||||
todayXp,
|
||||
allComplete,
|
||||
bonusUnlocked,
|
||||
bonusXp,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
} = useDailyMissions({ availableStages });
|
||||
|
||||
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
|
||||
|
||||
const completedCount = missions.filter((m) => m.complete).length;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
{/* Section header — tappable */}
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between py-1 group">
|
||||
<div className="flex items-center gap-2">
|
||||
<Scroll className="size-4 text-amber-500 dark:text-amber-400 shrink-0" />
|
||||
<h3 className="font-semibold text-sm">Daily Bounties</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Summary pill — always visible */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
{completedCount} / {missions.length}
|
||||
</span>
|
||||
{allComplete && (
|
||||
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SectionChevron open={isOpen} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
|
||||
<div className="pt-3">
|
||||
<DailyMissionsPanel
|
||||
missions={missions}
|
||||
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
|
||||
todayXp={todayXp}
|
||||
disabled={disabled || isRerolling}
|
||||
bonusUnlocked={bonusUnlocked}
|
||||
bonusXp={bonusXp}
|
||||
noMissionsAvailable={noMissionsAvailable}
|
||||
rerollsRemaining={rerollsRemaining}
|
||||
isRerolling={isRerolling}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stop Process Confirmation Dialog ─────────────────────────────────────────
|
||||
|
||||
interface StopConfirmationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companionName: string;
|
||||
processType: 'incubation' | 'evolution';
|
||||
onConfirm: () => Promise<void>;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
function StopConfirmationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
companionName,
|
||||
processType,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: StopConfirmationDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const label = processType === 'incubation' ? 'Incubation' : 'Evolution';
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-amber-500" />
|
||||
Stop {label}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<p>
|
||||
Are you sure you want to stop {processType === 'incubation' ? 'incubating' : 'evolving'}{' '}
|
||||
<strong>{companionName}</strong>?
|
||||
</p>
|
||||
<p>
|
||||
This will interrupt the {processType} process and clear all task progress.
|
||||
You can restart {processType} later, but you'll need to complete the tasks again.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
disabled={isPending}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
`Stop ${label}`
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Current Focus Section (Hatch / Evolve) ──────────────────────────────────
|
||||
|
||||
interface CurrentFocusSectionProps {
|
||||
companion: BlobbiCompanion;
|
||||
tasks: HatchTasksResult | EvolveTasksResult;
|
||||
processType: 'incubation' | 'evolution';
|
||||
onOpenPostModal: () => void;
|
||||
onComplete: () => void;
|
||||
isCompleting: boolean;
|
||||
onStop: () => Promise<void>;
|
||||
isStopping: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function CurrentFocusSection({
|
||||
companion,
|
||||
tasks,
|
||||
processType,
|
||||
onOpenPostModal,
|
||||
onComplete,
|
||||
isCompleting,
|
||||
onStop,
|
||||
isStopping,
|
||||
defaultOpen = true,
|
||||
}: CurrentFocusSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
|
||||
|
||||
const isIncubation = processType === 'incubation';
|
||||
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
|
||||
const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!';
|
||||
const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...';
|
||||
const completeEmoji = isIncubation ? '🐣' : '✨';
|
||||
const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution';
|
||||
const badgeLabel = isIncubation ? 'Hatch' : 'Evolve';
|
||||
const category = isIncubation ? ('hatch' as const) : ('evolve' as const);
|
||||
|
||||
const completedCount = tasks.tasks.filter((t) => t.completed).length;
|
||||
const totalTasks = tasks.tasks.length;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
{/* Section header — tappable */}
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between py-1 group">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs font-semibold px-2 py-0.5',
|
||||
isIncubation
|
||||
? 'bg-sky-500/15 text-sky-600 dark:text-sky-400'
|
||||
: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
|
||||
)}
|
||||
>
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium tabular-nums',
|
||||
tasks.allCompleted
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{completedCount} / {totalTasks}
|
||||
</span>
|
||||
<SectionChevron open={isOpen} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
|
||||
<div className="pt-3">
|
||||
{/* Task card grid */}
|
||||
<TasksPanel
|
||||
tasks={tasks.tasks}
|
||||
allCompleted={tasks.allCompleted}
|
||||
isLoading={tasks.isLoading}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onComplete}
|
||||
isCompleting={isCompleting}
|
||||
completeLabel={completeLabel}
|
||||
completingLabel={completingLabel}
|
||||
completeEmoji={completeEmoji}
|
||||
category={category}
|
||||
/>
|
||||
|
||||
{/* Stop process — low emphasis */}
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowStopConfirmation(true)}
|
||||
disabled={isStopping || isCompleting}
|
||||
className="text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 h-8 px-3"
|
||||
>
|
||||
{isStopping ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="size-3.5 mr-1.5" />
|
||||
{stopLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
<StopConfirmationDialog
|
||||
open={showStopConfirmation}
|
||||
onOpenChange={setShowStopConfirmation}
|
||||
companionName={companion.name}
|
||||
processType={processType}
|
||||
onConfirm={onStop}
|
||||
isPending={isStopping}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty Focus State ────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyFocusState() {
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<Compass className="size-5 text-muted-foreground/50 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No active progression right now</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Modal ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiMissionsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
hatchTasks,
|
||||
evolveTasks,
|
||||
onOpenPostModal,
|
||||
onHatch,
|
||||
isHatching,
|
||||
onEvolve,
|
||||
isEvolving,
|
||||
onStopIncubation,
|
||||
isStoppingIncubation,
|
||||
onStopEvolution,
|
||||
isStoppingEvolution,
|
||||
availableStages,
|
||||
showMissionCard,
|
||||
onToggleMissionCard,
|
||||
}: BlobbiMissionsModalProps) {
|
||||
const isIncubating = companion.state === 'incubating';
|
||||
const isEvolvingState = companion.state === 'evolving';
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isBaby = companion.stage === 'baby';
|
||||
|
||||
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
|
||||
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden [&>button:last-child]:hidden">
|
||||
{/* ── Sticky Header ── */}
|
||||
<div className="sticky top-0 z-10 bg-background px-4 sm:px-5 pt-4 pb-3 border-b border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-bold tracking-tight">Missions</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Quests & bounties for {companion.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<MissionTypeLegend />
|
||||
<DialogClose className="rounded-full p-1.5 opacity-60 hover:opacity-100 hover:bg-muted transition-all">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable Content ── */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-5 py-4 space-y-5">
|
||||
{/* 1. Current Focus */}
|
||||
{hasActiveProcess ? (
|
||||
<>
|
||||
{isIncubating && isEgg ? (
|
||||
<CurrentFocusSection
|
||||
companion={companion}
|
||||
tasks={hatchTasks}
|
||||
processType="incubation"
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onHatch}
|
||||
isCompleting={isHatching}
|
||||
onStop={onStopIncubation}
|
||||
isStopping={isStoppingIncubation}
|
||||
/>
|
||||
) : isEvolvingState && isBaby ? (
|
||||
<CurrentFocusSection
|
||||
companion={companion}
|
||||
tasks={evolveTasks}
|
||||
processType="evolution"
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onEvolve}
|
||||
isCompleting={isEvolving}
|
||||
onStop={onStopEvolution}
|
||||
isStopping={isStoppingEvolution}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyFocusState />
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
{/* 2. Daily Bounties */}
|
||||
<DailyMissionsSection
|
||||
availableStages={availableStages}
|
||||
disabled={isProcessBusy}
|
||||
/>
|
||||
|
||||
{/* 3. Settings */}
|
||||
{onToggleMissionCard !== undefined && showMissionCard !== undefined && (
|
||||
<>
|
||||
<div className="h-px bg-border/40" />
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<Label
|
||||
htmlFor="mission-card-toggle"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
Show mission card on main page
|
||||
</Label>
|
||||
<Switch
|
||||
id="mission-card-toggle"
|
||||
checked={showMissionCard}
|
||||
onCheckedChange={onToggleMissionCard}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
// src/blobbi/actions/components/BlobbiPostModal.tsx
|
||||
|
||||
/**
|
||||
* Modal for creating a Blobbi post (hatch or evolve).
|
||||
*
|
||||
* Requirements:
|
||||
* - Prefilled with stage-aware text:
|
||||
* - Hatch: "Hello Nostr! Posting to hatch #<blobbiName> #blobbi #ditto #nostr"
|
||||
* - Evolve: "Hello Nostr! Posting to evolve #<blobbiName> #blobbi #ditto #nostr"
|
||||
* - User can ADD text but CANNOT delete the prefix or required hashtags
|
||||
* - Blobbi name is sanitized into a valid hashtag format
|
||||
* - Enforced programmatically
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { X, Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
buildHatchPhrase,
|
||||
} from '../hooks/useHatchTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The process type for the post */
|
||||
export type BlobbiPostProcess = 'hatch' | 'evolve';
|
||||
|
||||
interface BlobbiPostModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi's name (will be converted to hashtag) */
|
||||
blobbiName: string;
|
||||
/** The process type - 'hatch' for incubation, 'evolve' for evolution */
|
||||
process?: BlobbiPostProcess;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the required prefix text based on process type.
|
||||
*/
|
||||
function buildPrefix(process: BlobbiPostProcess): string {
|
||||
return process === 'evolve'
|
||||
? 'Posting to evolve'
|
||||
: 'Posting to hatch';
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiPostModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
blobbiName,
|
||||
process = 'hatch',
|
||||
onSuccess,
|
||||
}: BlobbiPostModalProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: createEvent, isPending } = useNostrPublish();
|
||||
|
||||
// Compute the required elements based on props
|
||||
const prefix = useMemo(() => buildPrefix(process), [process]);
|
||||
const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]);
|
||||
|
||||
// The required phrase that must appear in the post
|
||||
const requiredPhrase = useMemo(() =>
|
||||
process === 'hatch'
|
||||
? buildHatchPhrase(blobbiName)
|
||||
: `${prefix} ${capitalizedName} #blobbi`,
|
||||
[process, blobbiName, prefix, capitalizedName]
|
||||
);
|
||||
|
||||
// Build default content (the phrase itself is enough)
|
||||
const defaultContent = useMemo(() => requiredPhrase, [requiredPhrase]);
|
||||
|
||||
const [content, setContent] = useState(defaultContent);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
// Reset content when modal opens or props change
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setContent(defaultContent);
|
||||
setValidationError(null);
|
||||
}
|
||||
}, [open, defaultContent]);
|
||||
|
||||
/**
|
||||
* Validate that the content contains the required phrase.
|
||||
*/
|
||||
const validateContent = useCallback((text: string): string | null => {
|
||||
if (!text.includes(requiredPhrase)) {
|
||||
return `The post must contain: "${requiredPhrase}"`;
|
||||
}
|
||||
return null;
|
||||
}, [requiredPhrase]);
|
||||
|
||||
/**
|
||||
* Handle content change with validation.
|
||||
* Prevents deletion of required content.
|
||||
*/
|
||||
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newContent = e.target.value;
|
||||
|
||||
// Allow content changes only if it preserves the required elements
|
||||
const error = validateContent(newContent);
|
||||
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
// Still update content but show error
|
||||
// This allows the user to see what they're trying to do
|
||||
// but the post button will be disabled
|
||||
} else {
|
||||
setValidationError(null);
|
||||
}
|
||||
|
||||
setContent(newContent);
|
||||
}, [validateContent]);
|
||||
|
||||
/**
|
||||
* Handle post creation.
|
||||
*/
|
||||
const handlePost = useCallback(async () => {
|
||||
if (!user?.pubkey) {
|
||||
toast({
|
||||
title: 'Not logged in',
|
||||
description: 'Please log in to create a post',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Final validation
|
||||
const error = validateContent(content);
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build tags for the post: extract all hashtags from content
|
||||
const tags: string[][] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Always include BLOBBI_POST_REQUIRED_HASHTAGS as t tags
|
||||
for (const hashtag of BLOBBI_POST_REQUIRED_HASHTAGS) {
|
||||
const lower = hashtag.toLowerCase();
|
||||
if (!seen.has(lower)) {
|
||||
tags.push(['t', lower]);
|
||||
seen.add(lower);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract any additional hashtags from the content
|
||||
const contentHashtags = content.match(/#(\w+)/g) || [];
|
||||
for (const tag of contentHashtags) {
|
||||
const tagValue = tag.slice(1).toLowerCase();
|
||||
if (!seen.has(tagValue)) {
|
||||
tags.push(['t', tagValue]);
|
||||
seen.add(tagValue);
|
||||
}
|
||||
}
|
||||
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content,
|
||||
tags,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Post created!',
|
||||
description: process === 'evolve'
|
||||
? 'Your Blobbi evolution post has been published.'
|
||||
: 'Your Blobbi hatch post has been published.',
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to create post',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]);
|
||||
|
||||
const canPost = !validationError && content.trim().length > 0;
|
||||
|
||||
const dialogTitle = process === 'evolve' ? 'Blobbi Evolution Post' : 'Blobbi Hatch Post';
|
||||
const alertText = process === 'evolve'
|
||||
? "This special post announces your Blobbi's evolution! The highlighted text must remain in your post."
|
||||
: "This special post announces your Blobbi's hatching journey! The highlighted text must remain in your post.";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg p-0 gap-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-14 border-b">
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{dialogTitle}
|
||||
</DialogTitle>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Info alert */}
|
||||
<Alert className="border-primary/20 bg-primary/5">
|
||||
<AlertDescription className="text-sm">
|
||||
{alertText}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Textarea */}
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder="Write your post..."
|
||||
className="min-h-[150px] resize-none"
|
||||
disabled={isPending}
|
||||
/>
|
||||
|
||||
{/* Character count and validation */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
{validationError && (
|
||||
<span className="text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="size-3.5" />
|
||||
{validationError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview of required content */}
|
||||
<div className="p-3 rounded-lg bg-muted/50 border border-dashed">
|
||||
<p className="text-xs text-muted-foreground mb-1">Required phrase:</p>
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/30">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePost}
|
||||
disabled={!canPost || isPending}
|
||||
className="min-w-24"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Posting...
|
||||
</>
|
||||
) : (
|
||||
'Post'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
/**
|
||||
* DailyMissionsPanel — card-grid layout for daily bounties.
|
||||
*
|
||||
* Each mission is a compact card in a 2-col grid.
|
||||
* Tapping a card expands it to show progress and reroll.
|
||||
* Only one card expanded at a time.
|
||||
* Completion is implicit (derived from progress vs target).
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Sparkles,
|
||||
Gift,
|
||||
Egg,
|
||||
Trophy,
|
||||
RefreshCw,
|
||||
Heart,
|
||||
Utensils,
|
||||
Droplets,
|
||||
Moon,
|
||||
Camera,
|
||||
Mic,
|
||||
Music,
|
||||
Pill,
|
||||
CircleDot,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
import type { DailyMissionAction } from '../lib/daily-missions';
|
||||
import type { DailyMissionView } from '../hooks/useDailyMissions';
|
||||
import {
|
||||
ExpandableMissionCard,
|
||||
MissionDescription,
|
||||
MissionProgress,
|
||||
} from './ExpandableMissionCard';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsPanelProps {
|
||||
missions: DailyMissionView[];
|
||||
onRerollMission?: (missionId: string) => void;
|
||||
todayXp: number;
|
||||
disabled?: boolean;
|
||||
bonusUnlocked?: boolean;
|
||||
bonusXp?: number;
|
||||
noMissionsAvailable?: boolean;
|
||||
rerollsRemaining?: number;
|
||||
isRerolling?: boolean;
|
||||
}
|
||||
|
||||
// ─── Daily Mission Icon Mapping ───────────────────────────────────────────────
|
||||
|
||||
function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
|
||||
const cls = 'size-5';
|
||||
switch (action) {
|
||||
case 'interact':
|
||||
return <Heart className={cls} />;
|
||||
case 'feed':
|
||||
return <Utensils className={cls} />;
|
||||
case 'clean':
|
||||
return <Droplets className={cls} />;
|
||||
case 'sleep':
|
||||
return <Moon className={cls} />;
|
||||
case 'take_photo':
|
||||
return <Camera className={cls} />;
|
||||
case 'sing':
|
||||
return <Mic className={cls} />;
|
||||
case 'play_music':
|
||||
return <Music className={cls} />;
|
||||
case 'medicine':
|
||||
return <Pill className={cls} />;
|
||||
default:
|
||||
return <CircleDot className={cls} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bonus Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface BonusCardProps {
|
||||
isUnlocked: boolean;
|
||||
xp: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
function BonusCard({ isUnlocked, xp, isExpanded, onToggle }: BonusCardProps) {
|
||||
return (
|
||||
<ExpandableMissionCard
|
||||
id="bonus"
|
||||
category="daily"
|
||||
icon={<Trophy className="size-5" />}
|
||||
title="Daily Champion"
|
||||
completed={isUnlocked}
|
||||
progress={isUnlocked ? 1 : 0}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<MissionDescription>
|
||||
{isUnlocked
|
||||
? 'Bonus XP for completing all daily missions!'
|
||||
: 'Complete all missions to unlock this bonus'}
|
||||
</MissionDescription>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||
<Zap className="size-3" />
|
||||
+{formatCompactNumber(xp)} XP
|
||||
</div>
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty / Done States ──────────────────────────────────────────────────────
|
||||
|
||||
function NoMissionsState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Egg className="size-5 text-muted-foreground/50" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Hatch your Blobbi first</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Daily missions unlock after hatching
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AllCompleteState({ todayXp }: { todayXp: number }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Sparkles className="size-5 text-primary/60" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">All done for today</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Earned{' '}
|
||||
<span className="font-medium text-violet-600 dark:text-violet-400">
|
||||
{formatCompactNumber(todayXp)} XP
|
||||
</span>{' '}
|
||||
— come back tomorrow!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reroll Counter ───────────────────────────────────────────────────────────
|
||||
|
||||
function RerollCounter({ remaining }: { remaining: number }) {
|
||||
const text =
|
||||
remaining === 0
|
||||
? 'No rerolls left'
|
||||
: remaining === 1
|
||||
? '1 reroll left'
|
||||
: `${remaining} rerolls left`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1 text-[11px] text-muted-foreground col-span-full">
|
||||
<RefreshCw className="size-2.5" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function DailyMissionsPanel({
|
||||
missions,
|
||||
onRerollMission,
|
||||
todayXp,
|
||||
disabled,
|
||||
bonusUnlocked = false,
|
||||
bonusXp = 50,
|
||||
noMissionsAvailable = false,
|
||||
rerollsRemaining = 0,
|
||||
isRerolling = false,
|
||||
}: DailyMissionsPanelProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
if (noMissionsAvailable) return <NoMissionsState />;
|
||||
|
||||
const allComplete = missions.every((m) => m.complete);
|
||||
if (allComplete && bonusUnlocked) return <AllCompleteState todayXp={todayXp} />;
|
||||
|
||||
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{/* Reroll counter */}
|
||||
{onRerollMission && <RerollCounter remaining={rerollsRemaining} />}
|
||||
|
||||
{/* Regular mission cards */}
|
||||
{missions.map((mission) => {
|
||||
const progressFrac = mission.target > 0 ? mission.progress / mission.target : 0;
|
||||
const showReroll = onRerollMission && !mission.complete && canReroll;
|
||||
|
||||
return (
|
||||
<ExpandableMissionCard
|
||||
key={mission.id}
|
||||
id={mission.id}
|
||||
category="daily"
|
||||
icon={<DailyMissionIcon action={mission.action} />}
|
||||
title={mission.title}
|
||||
completed={mission.complete}
|
||||
progress={Math.min(progressFrac, 1)}
|
||||
isExpanded={expandedId === mission.id}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{/* Description */}
|
||||
<MissionDescription>{mission.description}</MissionDescription>
|
||||
|
||||
{/* Progress */}
|
||||
{!mission.complete && (
|
||||
<MissionProgress
|
||||
current={mission.progress}
|
||||
required={mission.target}
|
||||
completed={mission.complete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* XP + reroll row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||
<Zap className="size-3" />
|
||||
{formatCompactNumber(mission.xp)} XP
|
||||
</span>
|
||||
|
||||
{showReroll && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRerollMission(mission.id);
|
||||
}}
|
||||
disabled={disabled || isRerolling}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={cn('size-3', isRerolling && 'animate-spin')} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Replace mission</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{mission.complete && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
|
||||
<Check className="size-2.5" />
|
||||
Done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Complete indicator */}
|
||||
{mission.complete && (
|
||||
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<Gift className="size-3.5" />
|
||||
+{formatCompactNumber(mission.xp)} XP earned
|
||||
</div>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bonus card */}
|
||||
<BonusCard
|
||||
isUnlocked={bonusUnlocked}
|
||||
xp={bonusXp}
|
||||
isExpanded={expandedId === 'bonus'}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
// src/blobbi/actions/components/ExpandableMissionCard.tsx
|
||||
|
||||
/**
|
||||
* Expandable mission card for the quest-board grid.
|
||||
*
|
||||
* Collapsed: compact square-ish card showing icon, title, and a tiny
|
||||
* progress ring / checkmark.
|
||||
* Expanded: full-width row that reveals description, progress bar,
|
||||
* action link, claim button, dynamic hints, etc.
|
||||
*
|
||||
* Only one card is expanded at a time per section (controlled by parent).
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, ChevronRight, ExternalLink, AlertCircle } from 'lucide-react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type MissionCategory = 'daily' | 'hatch' | 'evolve';
|
||||
|
||||
export interface ExpandableMissionCardProps {
|
||||
/** Unique id used to track which card is expanded */
|
||||
id: string;
|
||||
/** Mission category for visual styling */
|
||||
category: MissionCategory;
|
||||
/** Icon rendered in the compact card (ReactNode — usually a lucide icon or emoji span) */
|
||||
icon: ReactNode;
|
||||
/** Short title */
|
||||
title: string;
|
||||
/** Whether the mission is complete */
|
||||
completed: boolean;
|
||||
/** Progress fraction 0-1 (used for the tiny ring in compact mode) */
|
||||
progress: number;
|
||||
/** Whether this card is currently expanded */
|
||||
isExpanded: boolean;
|
||||
/** Parent calls this to toggle expansion */
|
||||
onToggle: (id: string) => void;
|
||||
/** Content rendered only when expanded */
|
||||
children: ReactNode;
|
||||
/** Optional extra className on the outer wrapper */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Tiny Progress Ring ───────────────────────────────────────────────────────
|
||||
|
||||
function ProgressRing({ progress, completed, category }: { progress: number; completed: boolean; category: MissionCategory }) {
|
||||
const size = 28;
|
||||
const stroke = 2.5;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
if (completed) {
|
||||
return (
|
||||
<div className="size-7 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ringColor =
|
||||
category === 'hatch'
|
||||
? 'text-sky-500'
|
||||
: category === 'evolve'
|
||||
? 'text-violet-500'
|
||||
: 'text-amber-500';
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className={cn('shrink-0 -rotate-90', ringColor)}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
opacity={0.15}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Accent colors per category ───────────────────────────────────────────────
|
||||
|
||||
const CATEGORY_STYLES: Record<MissionCategory, { bg: string; expandedBg: string; border: string }> = {
|
||||
daily: {
|
||||
bg: 'bg-amber-500/[0.06] hover:bg-amber-500/10',
|
||||
expandedBg: 'bg-amber-500/[0.06]',
|
||||
border: 'ring-amber-500/20',
|
||||
},
|
||||
hatch: {
|
||||
bg: 'bg-sky-500/[0.06] hover:bg-sky-500/10',
|
||||
expandedBg: 'bg-sky-500/[0.06]',
|
||||
border: 'ring-sky-500/20',
|
||||
},
|
||||
evolve: {
|
||||
bg: 'bg-violet-500/[0.06] hover:bg-violet-500/10',
|
||||
expandedBg: 'bg-violet-500/[0.06]',
|
||||
border: 'ring-violet-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ExpandableMissionCard({
|
||||
id,
|
||||
category,
|
||||
icon,
|
||||
title,
|
||||
completed,
|
||||
progress,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
children,
|
||||
className,
|
||||
}: ExpandableMissionCardProps) {
|
||||
const styles = CATEGORY_STYLES[category];
|
||||
|
||||
// ── Collapsed card ──
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(id)}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 rounded-xl p-3 transition-all text-center cursor-pointer select-none',
|
||||
'ring-1 ring-transparent',
|
||||
completed ? 'bg-emerald-500/[0.06] hover:bg-emerald-500/10' : styles.bg,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="text-lg leading-none">{icon}</div>
|
||||
|
||||
{/* Title — 2 lines max */}
|
||||
<span className={cn(
|
||||
'text-[11px] font-medium leading-tight line-clamp-2 min-h-[2lh]',
|
||||
completed && 'text-emerald-600 dark:text-emerald-400',
|
||||
)}>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{/* Progress ring / check */}
|
||||
<ProgressRing progress={progress} completed={completed} category={category} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Expanded card (spans full row) ──
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-full rounded-xl ring-1 transition-all overflow-hidden',
|
||||
completed ? 'bg-emerald-500/[0.06] ring-emerald-500/20' : cn(styles.expandedBg, styles.border),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Compact header — click to collapse */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(id)}
|
||||
className="w-full flex items-center gap-3 p-3 text-left cursor-pointer select-none"
|
||||
>
|
||||
<div className="text-lg leading-none shrink-0">{icon}</div>
|
||||
<span className={cn(
|
||||
'text-sm font-medium flex-1 min-w-0',
|
||||
completed && 'text-emerald-600 dark:text-emerald-400',
|
||||
)}>
|
||||
{title}
|
||||
</span>
|
||||
<ProgressRing progress={progress} completed={completed} category={category} />
|
||||
</button>
|
||||
|
||||
{/* Expanded details */}
|
||||
<div className="px-3 pb-3 pt-0 space-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared detail sub-components ─────────────────────────────────────────────
|
||||
|
||||
/** Description text */
|
||||
export function MissionDescription({ children }: { children: ReactNode }) {
|
||||
return <p className="text-xs text-muted-foreground leading-snug">{children}</p>;
|
||||
}
|
||||
|
||||
/** Progress bar with fraction label */
|
||||
export function MissionProgress({ current, required, completed }: { current: number; required: number; completed: boolean }) {
|
||||
const pct = required > 0 ? Math.round((current / required) * 100) : 0;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
|
||||
<span className="tabular-nums">{current} / {required}</span>
|
||||
<span className="tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
<Progress value={pct} className={cn('h-1.5', completed && '[&>div]:bg-emerald-500')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline action link (navigate, external, modal) */
|
||||
export function MissionAction({
|
||||
label,
|
||||
type,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
type: 'navigate' | 'external_link' | 'open_modal';
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{label}
|
||||
{type === 'external_link' ? (
|
||||
<ExternalLink className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dynamic / live task hint */
|
||||
export function DynamicHint({ current, required }: { current: number; required: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-amber-600/80 dark:text-amber-400/80">
|
||||
<AlertCircle className="size-3 shrink-0" />
|
||||
<span>Lowest stat: {current}% (need {required}%+)</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
// src/blobbi/actions/components/HatchTasksPanel.tsx
|
||||
|
||||
/**
|
||||
* UI component for displaying hatch task progress.
|
||||
* Shows a list of tasks with progress indicators and action buttons.
|
||||
*/
|
||||
|
||||
import { ExternalLink, Check, Loader2, ChevronRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HatchTask } from '../hooks/useHatchTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HatchTasksPanelProps {
|
||||
tasks: HatchTask[];
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
/** Called when user clicks "Create Post" action */
|
||||
onOpenPostModal: () => void;
|
||||
/** Called when all tasks are complete and user clicks "Hatch" */
|
||||
onHatch: () => void;
|
||||
/** Whether hatching is in progress */
|
||||
isHatching?: boolean;
|
||||
}
|
||||
|
||||
// ─── Task Row Component ───────────────────────────────────────────────────────
|
||||
|
||||
interface TaskRowProps {
|
||||
task: HatchTask;
|
||||
onOpenPostModal: () => void;
|
||||
}
|
||||
|
||||
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
|
||||
switch (task.action) {
|
||||
case 'navigate':
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
openUrl(task.actionTarget);
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') {
|
||||
onOpenPostModal();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const progress = task.required > 1
|
||||
? Math.round((task.current / task.required) * 100)
|
||||
: task.completed ? 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-4 p-4 rounded-xl border transition-all",
|
||||
task.completed
|
||||
? "bg-emerald-500/5 border-emerald-500/20"
|
||||
: "bg-card/60 border-border hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div className={cn(
|
||||
"size-10 rounded-full flex items-center justify-center shrink-0",
|
||||
task.completed
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{task.completed ? (
|
||||
<Check className="size-5" />
|
||||
) : task.required > 1 ? (
|
||||
<span className="text-sm font-medium">{task.current}/{task.required}</span>
|
||||
) : (
|
||||
<span className="text-lg">○</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className={cn(
|
||||
"font-medium",
|
||||
task.completed && "text-emerald-600 dark:text-emerald-400"
|
||||
)}>
|
||||
{task.name}
|
||||
</h4>
|
||||
{task.completed && (
|
||||
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs">
|
||||
Complete
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
|
||||
{/* Progress bar for multi-step tasks */}
|
||||
{task.required > 1 && !task.completed && (
|
||||
<Progress value={progress} className="h-1.5 mt-2" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{task.action && task.actionLabel && !task.completed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAction}
|
||||
className="shrink-0 gap-2"
|
||||
>
|
||||
{task.actionLabel}
|
||||
{task.action === 'external_link' ? (
|
||||
<ExternalLink className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function HatchTasksPanel({
|
||||
tasks,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
onOpenPostModal,
|
||||
onHatch,
|
||||
isHatching = false,
|
||||
}: HatchTasksPanelProps) {
|
||||
const completedCount = tasks.filter(t => t.completed).length;
|
||||
const totalTasks = tasks.length;
|
||||
const overallProgress = Math.round((completedCount / totalTasks) * 100);
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🥚</span>
|
||||
Hatch Tasks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Complete these tasks to hatch your Blobbi
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-base px-3 py-1">
|
||||
{completedCount}/{totalTasks}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Overall progress */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-muted-foreground">Overall progress</span>
|
||||
<span className="font-medium">{overallProgress}%</span>
|
||||
</div>
|
||||
<Progress value={overallProgress} className="h-2" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tasks.map(task => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hatch button - only visible when all tasks complete */}
|
||||
{allCompleted && (
|
||||
<div className="pt-4 border-t border-border mt-4">
|
||||
<Button
|
||||
onClick={onHatch}
|
||||
disabled={isHatching}
|
||||
size="lg"
|
||||
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
|
||||
>
|
||||
{isHatching ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
Hatching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xl">🐣</span>
|
||||
Hatch Your Blobbi!
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
// src/blobbi/actions/components/InlineMusicPlayer.tsx
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Music, Play, Pause, RotateCcw, MoreHorizontal, Loader2, AlertCircle, X, Volume2, VolumeX } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useAudioPlayback } from '../hooks/useAudioPlayback';
|
||||
import type { SelectedTrack } from './PlayMusicModal';
|
||||
|
||||
// Re-export for external use
|
||||
export type { SelectedTrack } from './PlayMusicModal';
|
||||
|
||||
interface InlineMusicPlayerProps {
|
||||
/** The selected track */
|
||||
selection: SelectedTrack;
|
||||
/** Called when user wants to change the track */
|
||||
onChangeTrack: () => void;
|
||||
/** Called when user closes the player */
|
||||
onClose: () => void;
|
||||
/** Called when playback starts (for Blobbi reaction state) */
|
||||
onPlaybackStart?: () => void;
|
||||
/** Called when playback stops/pauses (for Blobbi reaction state) */
|
||||
onPlaybackStop?: () => void;
|
||||
/** Whether the action has been published (playback only starts after publish) */
|
||||
isPublished: boolean;
|
||||
/** Whether publishing is in progress */
|
||||
isPublishing: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function InlineMusicPlayer({
|
||||
selection,
|
||||
onChangeTrack,
|
||||
onClose,
|
||||
onPlaybackStart,
|
||||
onPlaybackStop,
|
||||
isPublished,
|
||||
isPublishing,
|
||||
}: InlineMusicPlayerProps) {
|
||||
const {
|
||||
state: playbackState,
|
||||
error: playbackError,
|
||||
load,
|
||||
toggle,
|
||||
restart,
|
||||
stop,
|
||||
isPlaying,
|
||||
volume,
|
||||
setVolume,
|
||||
cleanup,
|
||||
} = useAudioPlayback({
|
||||
onEnded: () => {
|
||||
onPlaybackStop?.();
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-start playback when first published (idle -> playing)
|
||||
// Note: 'stopped' state is NOT included here - stop is a terminal state
|
||||
// that requires explicit user action (play button) to restart
|
||||
useEffect(() => {
|
||||
if (isPublished && playbackState === 'idle') {
|
||||
load(selection.url, true);
|
||||
onPlaybackStart?.();
|
||||
}
|
||||
}, [isPublished, playbackState, selection.url, load, onPlaybackStart]);
|
||||
|
||||
// Force reload when source URL changes while already playing/paused
|
||||
useEffect(() => {
|
||||
// Only trigger reload if we're in an active playback state with a different URL
|
||||
if (isPublished && (playbackState === 'playing' || playbackState === 'paused')) {
|
||||
// The load function will check if URL changed and reload if needed
|
||||
load(selection.url, true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to selection.url changes
|
||||
}, [selection.url]);
|
||||
|
||||
// Notify on playback state changes
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
onPlaybackStart?.();
|
||||
} else if (playbackState === 'paused' || playbackState === 'stopped') {
|
||||
onPlaybackStop?.();
|
||||
}
|
||||
}, [isPlaying, playbackState, onPlaybackStart, onPlaybackStop]);
|
||||
|
||||
// Cleanup on close
|
||||
const handleClose = useCallback(() => {
|
||||
stop();
|
||||
cleanup();
|
||||
onPlaybackStop?.();
|
||||
onClose();
|
||||
}, [stop, cleanup, onPlaybackStop, onClose]);
|
||||
|
||||
// Handle play/pause toggle
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (playbackState === 'idle' || playbackState === 'stopped') {
|
||||
load(selection.url, true);
|
||||
} else {
|
||||
await toggle();
|
||||
}
|
||||
}, [playbackState, selection.url, load, toggle]);
|
||||
|
||||
// Track info
|
||||
const trackTitle = selection.track.title;
|
||||
const trackArtist = selection.track.artist;
|
||||
|
||||
const isLoading = playbackState === 'loading' || isPublishing;
|
||||
const hasError = playbackState === 'error';
|
||||
|
||||
return (
|
||||
<div className="mx-4 sm:mx-6 mb-4">
|
||||
<div className={cn(
|
||||
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
|
||||
"shadow-sm transition-all",
|
||||
isPlaying && "ring-2 ring-pink-500/30"
|
||||
)}>
|
||||
{/* Main content row */}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
{/* Music icon / Now Playing indicator */}
|
||||
<div className={cn(
|
||||
"size-10 rounded-lg flex items-center justify-center shrink-0",
|
||||
isPlaying
|
||||
? "bg-pink-500/20"
|
||||
: "bg-muted"
|
||||
)}>
|
||||
<Music className={cn(
|
||||
"size-5",
|
||||
isPlaying ? "text-pink-500 animate-pulse" : "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{trackTitle}</p>
|
||||
{trackArtist && (
|
||||
<p className="text-xs text-muted-foreground truncate">{trackArtist}</p>
|
||||
)}
|
||||
{!trackArtist && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isPlaying ? 'Now playing...' : isPublishing ? 'Starting...' : 'Ready to play'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* Play/Pause button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || !isPublished}
|
||||
className="size-9 rounded-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Restart button - only show when actively playing or paused */}
|
||||
{isPublished && (playbackState === 'playing' || playbackState === 'paused') && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
restart();
|
||||
}}
|
||||
className="size-9 rounded-full"
|
||||
title="Restart from beginning"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Volume control */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-9 rounded-full"
|
||||
title={volume === 0 ? 'Unmute' : 'Volume'}
|
||||
>
|
||||
{volume === 0 ? (
|
||||
<VolumeX className="size-4" />
|
||||
) : (
|
||||
<Volume2 className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="w-32 p-3"
|
||||
>
|
||||
<Slider
|
||||
value={[volume * 100]}
|
||||
onValueChange={([val]) => setVolume(val / 100)}
|
||||
max={100}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Change track button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onChangeTrack}
|
||||
disabled={isPublishing}
|
||||
className="size-9 rounded-full"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Close button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isPublishing}
|
||||
className="size-9 rounded-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{hasError && playbackError && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="size-4 mt-0.5 shrink-0" />
|
||||
<p className="text-xs">{playbackError.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
// src/blobbi/actions/components/InlineSingCard.tsx
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Mic,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
FileText,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useAudioPlayback } from '../hooks/useAudioPlayback';
|
||||
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'error';
|
||||
|
||||
interface InlineSingCardProps {
|
||||
/** Called when user confirms the singing action (publish the action) */
|
||||
onConfirm: () => Promise<void>;
|
||||
/** Called when user closes the sing card */
|
||||
onClose: () => void;
|
||||
/** Called when recording starts (for Blobbi reaction) */
|
||||
onRecordingStart?: () => void;
|
||||
/** Called when recording stops (for Blobbi reaction) */
|
||||
onRecordingStop?: () => void;
|
||||
/** Whether publishing is in progress */
|
||||
isPublishing: boolean;
|
||||
}
|
||||
|
||||
// ─── MIME Type Selection ──────────────────────────────────────────────────────
|
||||
|
||||
const AUDIO_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/ogg',
|
||||
] as const;
|
||||
|
||||
function getSupportedAudioMimeType(): string | undefined {
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const mimeType of AUDIO_MIME_CANDIDATES) {
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) {
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function InlineSingCard({
|
||||
onConfirm,
|
||||
onClose,
|
||||
onRecordingStart,
|
||||
onRecordingStop,
|
||||
isPublishing,
|
||||
}: InlineSingCardProps) {
|
||||
// Recording state
|
||||
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
|
||||
const [recordingError, setRecordingError] = useState<string | null>(null);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
|
||||
// Lyrics state
|
||||
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
// Refs
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const actualMimeTypeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Audio playback for preview
|
||||
const {
|
||||
state: playbackState,
|
||||
error: playbackError,
|
||||
load: loadAudio,
|
||||
toggle: togglePlayback,
|
||||
stop: stopPlayback,
|
||||
isPlaying,
|
||||
cleanup: cleanupPlayback,
|
||||
} = useAudioPlayback();
|
||||
|
||||
// Cleanup all resources
|
||||
const cleanupAll = useCallback(() => {
|
||||
// Stop timer
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Stop media recorder
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorderRef.current.stop();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
|
||||
// Stop stream tracks
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Cleanup playback
|
||||
cleanupPlayback();
|
||||
|
||||
// Revoke URL
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
}, [audioUrl, cleanupPlayback]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAll();
|
||||
};
|
||||
}, [cleanupAll]);
|
||||
|
||||
// Reset recording
|
||||
const resetRecording = useCallback(() => {
|
||||
cleanupAll();
|
||||
setRecordingState('idle');
|
||||
setRecordingError(null);
|
||||
setRecordingDuration(0);
|
||||
setAudioUrl(null);
|
||||
chunksRef.current = [];
|
||||
actualMimeTypeRef.current = undefined;
|
||||
// Keep lyrics
|
||||
}, [cleanupAll]);
|
||||
|
||||
// Check browser support
|
||||
const checkRecordingSupport = (): boolean => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
if (!navigator.mediaDevices) return false;
|
||||
if (!navigator.mediaDevices.getUserMedia) return false;
|
||||
if (typeof MediaRecorder === 'undefined') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!checkRecordingSupport()) {
|
||||
setRecordingError('Audio recording is not supported in this browser.');
|
||||
setRecordingState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingState('requesting');
|
||||
setRecordingError(null);
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
}
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
|
||||
// Get supported MIME type
|
||||
const supportedMimeType = getSupportedAudioMimeType();
|
||||
|
||||
// Create MediaRecorder
|
||||
let mediaRecorder: MediaRecorder;
|
||||
if (supportedMimeType) {
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
|
||||
} else {
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
}
|
||||
|
||||
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
|
||||
const blob = new Blob(chunksRef.current, { type: blobMimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
setRecordingState('recorded');
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = () => {
|
||||
setRecordingError('Recording failed. Please try again.');
|
||||
setRecordingState('error');
|
||||
};
|
||||
|
||||
mediaRecorder.start(100);
|
||||
setRecordingState('recording');
|
||||
setRecordingDuration(0);
|
||||
|
||||
// Notify parent that recording started (for Blobbi reaction)
|
||||
onRecordingStart?.();
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||
setRecordingError('Microphone access was denied.');
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
setRecordingError('No microphone found.');
|
||||
} else {
|
||||
setRecordingError(err.message);
|
||||
}
|
||||
} else {
|
||||
setRecordingError('Failed to access microphone.');
|
||||
}
|
||||
setRecordingState('error');
|
||||
}
|
||||
}, [onRecordingStart]);
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
// Notify parent that recording stopped (for Blobbi reaction)
|
||||
onRecordingStop?.();
|
||||
}, [onRecordingStop]);
|
||||
|
||||
// Handle preview playback
|
||||
const handlePreview = useCallback(() => {
|
||||
if (!audioUrl) return;
|
||||
|
||||
if (playbackState === 'idle') {
|
||||
loadAudio(audioUrl, true);
|
||||
} else {
|
||||
togglePlayback();
|
||||
}
|
||||
}, [audioUrl, playbackState, loadAudio, togglePlayback]);
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = useCallback(async () => {
|
||||
stopPlayback();
|
||||
await onConfirm();
|
||||
// After successful publish, close the card
|
||||
onClose();
|
||||
}, [stopPlayback, onConfirm, onClose]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback(() => {
|
||||
cleanupAll();
|
||||
onClose();
|
||||
}, [cleanupAll, onClose]);
|
||||
|
||||
// Handle lyrics toggle
|
||||
const handleLyricsToggle = useCallback(() => {
|
||||
if (!currentLyrics && !showLyrics) {
|
||||
// Generate lyrics on first open
|
||||
setCurrentLyrics(getRandomLyrics());
|
||||
}
|
||||
setShowLyrics(!showLyrics);
|
||||
}, [currentLyrics, showLyrics]);
|
||||
|
||||
// Get new lyrics
|
||||
const handleNewLyrics = useCallback(() => {
|
||||
setCurrentLyrics(getRandomLyrics());
|
||||
}, []);
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const hasRecording = recordingState === 'recorded';
|
||||
const isRecording = recordingState === 'recording';
|
||||
const canConfirm = hasRecording && !isPublishing;
|
||||
|
||||
return (
|
||||
<div className="mx-4 sm:mx-6 mb-4">
|
||||
<div className={cn(
|
||||
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
|
||||
"shadow-sm transition-all",
|
||||
isRecording && "ring-2 ring-red-500/30"
|
||||
)}>
|
||||
{/* Lyrics panel (expands upward visually by being above controls) */}
|
||||
{showLyrics && currentLyrics && (
|
||||
<div className="px-3 pt-3 pb-2 border-b border-border/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{currentLyrics.title}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleNewLyrics}
|
||||
className="size-7 rounded-full"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50 text-sm leading-relaxed whitespace-pre-line max-h-32 overflow-y-auto">
|
||||
{currentLyrics.lines.join('\n')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status row (recording/recorded info) */}
|
||||
{(isRecording || hasRecording) && (
|
||||
<div className="px-3 pt-3 pb-2 border-b border-border/50">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isRecording && (
|
||||
<>
|
||||
<div className="size-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-sm font-mono font-medium text-red-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Recording...</span>
|
||||
</>
|
||||
)}
|
||||
{hasRecording && !isRecording && (
|
||||
<>
|
||||
<Check className="size-4 text-purple-500" />
|
||||
<span className="text-sm font-mono font-medium text-purple-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Recorded</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{(recordingError || playbackError) && (
|
||||
<div className="px-3 pt-2">
|
||||
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="size-4 mt-0.5 shrink-0" />
|
||||
<p className="text-xs">{recordingError || playbackError?.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main controls row */}
|
||||
<div className="flex items-center justify-between gap-2 p-3">
|
||||
{/* Left: Lyrics button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant={showLyrics ? "secondary" : "ghost"}
|
||||
onClick={handleLyricsToggle}
|
||||
className="size-10 rounded-full shrink-0"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Center: Record/Stop button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!isRecording && !hasRecording && (
|
||||
<Button
|
||||
onClick={startRecording}
|
||||
disabled={isPublishing}
|
||||
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
<Mic className="size-4 mr-2" />
|
||||
Sing
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<Button
|
||||
onClick={stopRecording}
|
||||
variant="destructive"
|
||||
className="rounded-full px-6"
|
||||
>
|
||||
<Square className="size-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasRecording && !isRecording && (
|
||||
<>
|
||||
<Button
|
||||
onClick={resetRecording}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-10 rounded-full"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm}
|
||||
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4 mr-2" />
|
||||
)}
|
||||
{isPublishing ? 'Singing...' : 'Sing for Blobbi'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Preview button (when recording exists) */}
|
||||
{hasRecording ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handlePreview}
|
||||
disabled={isPublishing}
|
||||
className="size-10 rounded-full shrink-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
/* Close button when no recording */
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
className="size-10 rounded-full shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close button row when recording exists */}
|
||||
{hasRecording && (
|
||||
<div className="px-3 pb-3 pt-0 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isPublishing}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
// src/blobbi/actions/components/PlayMusicModal.tsx
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Music, Play, Pause, Check, Loader2, Volume2, AlertCircle } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
getAllTracks,
|
||||
formatTrackDuration,
|
||||
type BlobbiTrack,
|
||||
} from '../lib/blobbi-track-catalog';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Selected track for the music player
|
||||
*/
|
||||
export interface SelectedTrack {
|
||||
track: BlobbiTrack;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PlayMusicModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called with the selected track when user confirms */
|
||||
onConfirm: (selection: SelectedTrack) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PlayMusicModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}: PlayMusicModalProps) {
|
||||
const [selectedTrack, setSelectedTrack] = useState<SelectedTrack | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// Track the current audio source URL to detect changes
|
||||
const currentAudioUrlRef = useRef<string | null>(null);
|
||||
|
||||
const tracks = getAllTracks();
|
||||
|
||||
// Cleanup audio on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedTrack(null);
|
||||
setIsPlaying(false);
|
||||
setError(null);
|
||||
currentAudioUrlRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Handle selecting a track
|
||||
const handleSelectTrack = useCallback((track: BlobbiTrack) => {
|
||||
// Stop current playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
setSelectedTrack({ track, url: track.url });
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Handle play/pause preview
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (!selectedTrack) return;
|
||||
|
||||
const audioUrl = selectedTrack.url;
|
||||
|
||||
// Check if we need to create a new Audio instance (source changed or first time)
|
||||
const needsNewAudio = !audioRef.current || currentAudioUrlRef.current !== audioUrl;
|
||||
|
||||
if (needsNewAudio) {
|
||||
// Stop and cleanup old audio if exists
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
}
|
||||
|
||||
// Create new Audio instance with the correct source
|
||||
audioRef.current = new Audio(audioUrl);
|
||||
currentAudioUrlRef.current = audioUrl;
|
||||
|
||||
audioRef.current.onended = () => setIsPlaying(false);
|
||||
audioRef.current.onerror = () => {
|
||||
setError('Failed to load this track. Please try another one.');
|
||||
setIsPlaying(false);
|
||||
};
|
||||
}
|
||||
|
||||
if (isPlaying && !needsNewAudio) {
|
||||
// Pause current playback
|
||||
audioRef.current?.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
// Start playback (either new source or resuming)
|
||||
audioRef.current?.play().catch(() => {
|
||||
setError('Failed to play this track. Please try another one.');
|
||||
setIsPlaying(false);
|
||||
});
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [selectedTrack, isPlaying]);
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!selectedTrack) return;
|
||||
|
||||
// Stop playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
onConfirm(selectedTrack);
|
||||
}, [selectedTrack, onConfirm]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen && audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-xl bg-gradient-to-br from-pink-500/20 to-pink-500/5 flex items-center justify-center">
|
||||
<Music className="size-5 text-pink-500" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl">Play Music</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a track to play for your Blobbi
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Track List */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="grid gap-2">
|
||||
{tracks.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
isSelected={selectedTrack?.track.id === track.id}
|
||||
onSelect={() => handleSelectTrack(track)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t bg-muted/30">
|
||||
{/* Preview Controls */}
|
||||
{selectedTrack && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-card border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={handleTogglePlay}
|
||||
className="size-10 rounded-full shrink-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate text-sm">{selectedTrack.track.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isPlaying ? 'Now playing...' : 'Click to preview'}
|
||||
</p>
|
||||
</div>
|
||||
{isPlaying && (
|
||||
<Volume2 className="size-4 text-primary animate-pulse shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleClose(false)}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedTrack || isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Playing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Music className="size-4 mr-2" />
|
||||
Play for Blobbi
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Track Row Component ──────────────────────────────────────────────────────
|
||||
|
||||
interface TrackRowProps {
|
||||
track: BlobbiTrack;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function TrackRow({ track, isSelected, onSelect }: TrackRowProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-xl text-left transition-all",
|
||||
"border hover:border-primary/30",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
: "border-border bg-card/60"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"size-10 rounded-lg flex items-center justify-center",
|
||||
isSelected ? "bg-primary/20" : "bg-muted"
|
||||
)}>
|
||||
<Music className={cn(
|
||||
"size-5",
|
||||
isSelected ? "text-primary" : "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{track.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{track.artist}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatTrackDuration(track.durationSeconds)}
|
||||
</span>
|
||||
{isSelected && <Check className="size-4 text-primary" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,601 +0,0 @@
|
||||
// src/blobbi/actions/components/SingModal.tsx
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Mic, MicOff, Play, Pause, Square, Loader2, AlertCircle, RotateCcw, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SingModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'playing' | 'error';
|
||||
|
||||
// ─── MIME Type Selection Helper ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ordered list of MIME types to try for audio recording.
|
||||
* The first supported type will be used.
|
||||
*/
|
||||
const AUDIO_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/ogg',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Get the first supported MIME type for MediaRecorder.
|
||||
* Returns undefined if no explicit MIME type is supported (let browser decide).
|
||||
*/
|
||||
function getSupportedAudioMimeType(): string | undefined {
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const mimeType of AUDIO_MIME_CANDIDATES) {
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) {
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
// No explicit MIME type supported, let browser use default
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SingModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}: SingModalProps) {
|
||||
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Track the actual MIME type used by the recorder
|
||||
const actualMimeTypeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
// Stop timer
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Stop media recorder
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
|
||||
// Stop stream tracks
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Stop audio playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
// Revoke URL
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
}, [audioUrl]);
|
||||
|
||||
const resetRecording = useCallback(() => {
|
||||
cleanup();
|
||||
setRecordingState('idle');
|
||||
setError(null);
|
||||
setPlaybackError(null);
|
||||
setRecordingDuration(0);
|
||||
setAudioUrl(null);
|
||||
chunksRef.current = [];
|
||||
currentPlaybackUrlRef.current = null;
|
||||
actualMimeTypeRef.current = undefined;
|
||||
// Keep lyrics when re-recording so user can sing the same song
|
||||
}, [cleanup]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetRecording();
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
}, [open, cleanup, resetRecording]);
|
||||
|
||||
// Handle getting random lyrics
|
||||
const handleRandomLyrics = useCallback(() => {
|
||||
const lyrics = getRandomLyrics();
|
||||
setCurrentLyrics(lyrics);
|
||||
setShowLyrics(true);
|
||||
}, []);
|
||||
|
||||
// Check if browser supports media recording
|
||||
const checkRecordingSupport = (): boolean => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
if (!navigator.mediaDevices) return false;
|
||||
if (!navigator.mediaDevices.getUserMedia) return false;
|
||||
if (typeof MediaRecorder === 'undefined') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!checkRecordingSupport()) {
|
||||
setError('Audio recording is not supported in this browser.');
|
||||
setRecordingState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingState('requesting');
|
||||
setError(null);
|
||||
setPlaybackError(null);
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
}
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
|
||||
// Get the first supported MIME type using our helper
|
||||
const supportedMimeType = getSupportedAudioMimeType();
|
||||
|
||||
// Create MediaRecorder with or without explicit MIME type
|
||||
let mediaRecorder: MediaRecorder;
|
||||
if (supportedMimeType) {
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
|
||||
} else {
|
||||
// Let browser choose default MIME type
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
}
|
||||
|
||||
// Store the actual MIME type being used (may differ from what we requested)
|
||||
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
// Create blob from chunks using the actual MIME type used by the recorder
|
||||
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
|
||||
const blob = new Blob(chunksRef.current, { type: blobMimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
setRecordingState('recorded');
|
||||
|
||||
// Stop stream tracks
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = () => {
|
||||
setError('Recording failed. Please try again.');
|
||||
setRecordingState('error');
|
||||
};
|
||||
|
||||
// Start recording
|
||||
mediaRecorder.start(100); // Collect data every 100ms
|
||||
setRecordingState('recording');
|
||||
setRecordingDuration(0);
|
||||
|
||||
// Start timer
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||
setError('Microphone access was denied. Please allow microphone access and try again.');
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
setError('No microphone found. Please connect a microphone and try again.');
|
||||
} else {
|
||||
setError(`Failed to access microphone: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
setError('Failed to access microphone. Please try again.');
|
||||
}
|
||||
setRecordingState('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Track the current audio URL to detect changes
|
||||
const currentPlaybackUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Play/pause preview
|
||||
const togglePlayback = useCallback(() => {
|
||||
if (!audioUrl) return;
|
||||
|
||||
// Clear previous playback error when attempting to play
|
||||
setPlaybackError(null);
|
||||
|
||||
if (recordingState === 'playing') {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
setRecordingState('recorded');
|
||||
} else {
|
||||
// Check if we need to create a new Audio instance (URL changed or first time)
|
||||
const needsNewAudio = !audioRef.current || currentPlaybackUrlRef.current !== audioUrl;
|
||||
|
||||
if (needsNewAudio) {
|
||||
// Cleanup old audio if exists
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
}
|
||||
|
||||
// Create new Audio instance with the recorded audio URL
|
||||
audioRef.current = new Audio(audioUrl);
|
||||
currentPlaybackUrlRef.current = audioUrl;
|
||||
audioRef.current.onended = () => setRecordingState('recorded');
|
||||
|
||||
// Handle playback errors with user-visible message
|
||||
audioRef.current.onerror = () => {
|
||||
setPlaybackError('This browser could not play the recorded audio preview. Your recording was still created successfully.');
|
||||
setRecordingState('recorded');
|
||||
};
|
||||
}
|
||||
|
||||
audioRef.current?.play()
|
||||
.then(() => {
|
||||
setRecordingState('playing');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to play recording:', err);
|
||||
// Provide user-friendly error message
|
||||
if (err.name === 'NotSupportedError') {
|
||||
setPlaybackError('Recording was created, but playback preview is not supported in this browser.');
|
||||
} else if (err.name === 'NotAllowedError') {
|
||||
setPlaybackError('Playback was blocked. Try interacting with the page first.');
|
||||
} else {
|
||||
setPlaybackError('Could not play the recording preview. Your recording was still created successfully.');
|
||||
}
|
||||
setRecordingState('recorded');
|
||||
});
|
||||
}
|
||||
}, [audioUrl, recordingState]);
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
onConfirm();
|
||||
}, [onConfirm]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
cleanup();
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange, cleanup]);
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const hasRecording = recordingState === 'recorded' || recordingState === 'playing';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 flex items-center justify-center">
|
||||
<Mic className="size-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl">Sing</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Record yourself singing for your Blobbi
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 px-6 py-8">
|
||||
<div className="flex flex-col items-center justify-center gap-6">
|
||||
{/* Recording Visualization */}
|
||||
<div className={cn(
|
||||
"relative size-40 rounded-full flex items-center justify-center transition-all",
|
||||
recordingState === 'recording' && "animate-pulse",
|
||||
recordingState === 'recording'
|
||||
? "bg-red-500/10 ring-4 ring-red-500/30"
|
||||
: hasRecording
|
||||
? "bg-purple-500/10 ring-4 ring-purple-500/30"
|
||||
: "bg-muted"
|
||||
)}>
|
||||
{/* Animated rings for recording */}
|
||||
{recordingState === 'recording' && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-full bg-red-500/10 animate-ping" />
|
||||
<div className="absolute inset-4 rounded-full bg-red-500/10 animate-ping animation-delay-150" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div className={cn(
|
||||
"relative size-20 rounded-full flex items-center justify-center",
|
||||
recordingState === 'recording'
|
||||
? "bg-red-500 text-white"
|
||||
: hasRecording
|
||||
? "bg-purple-500 text-white"
|
||||
: "bg-muted-foreground/20"
|
||||
)}>
|
||||
{recordingState === 'requesting' ? (
|
||||
<Loader2 className="size-8 animate-spin" />
|
||||
) : recordingState === 'recording' ? (
|
||||
<Mic className="size-8" />
|
||||
) : hasRecording ? (
|
||||
recordingState === 'playing' ? (
|
||||
<Pause className="size-8" />
|
||||
) : (
|
||||
<Play className="size-8 ml-1" />
|
||||
)
|
||||
) : (
|
||||
<MicOff className="size-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration / Status */}
|
||||
<div className="text-center">
|
||||
{recordingState === 'idle' && (
|
||||
<p className="text-muted-foreground">Tap the button below to start recording</p>
|
||||
)}
|
||||
{recordingState === 'requesting' && (
|
||||
<p className="text-muted-foreground">Requesting microphone access...</p>
|
||||
)}
|
||||
{recordingState === 'recording' && (
|
||||
<>
|
||||
<p className="text-3xl font-mono font-bold text-red-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Recording...</p>
|
||||
</>
|
||||
)}
|
||||
{hasRecording && (
|
||||
<>
|
||||
<p className="text-3xl font-mono font-bold text-purple-500">
|
||||
{formatDuration(recordingDuration)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{recordingState === 'playing' ? 'Playing...' : 'Tap to preview'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{recordingState === 'error' && (
|
||||
<p className="text-destructive">Recording failed</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="w-full p-3 rounded-lg bg-destructive/10 border border-destructive/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-destructive mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playback Error Message (non-fatal, recording still works) */}
|
||||
{playbackError && (
|
||||
<div className="w-full p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">{playbackError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lyrics Helper */}
|
||||
<div className="w-full">
|
||||
{!currentLyrics ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRandomLyrics}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
Need lyrics? Get random lyrics
|
||||
</Button>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLyrics(!showLyrics)}
|
||||
className="w-full flex items-center justify-between p-3 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="size-4 text-purple-500" />
|
||||
<span className="font-medium text-sm">{currentLyrics.title}</span>
|
||||
</div>
|
||||
{showLyrics ? (
|
||||
<ChevronUp className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{showLyrics && (
|
||||
<div className="px-3 pb-3 pt-0">
|
||||
<div className="p-3 rounded-md bg-muted/50 text-sm leading-relaxed whitespace-pre-line">
|
||||
{currentLyrics.lines.join('\n')}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRandomLyrics}
|
||||
className="w-full mt-2 gap-2 text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Get different lyrics
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recording Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{recordingState === 'idle' || recordingState === 'error' ? (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={startRecording}
|
||||
className="rounded-full px-8 bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
<Mic className="size-5 mr-2" />
|
||||
Start Recording
|
||||
</Button>
|
||||
) : recordingState === 'recording' ? (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="destructive"
|
||||
onClick={stopRecording}
|
||||
className="rounded-full px-8"
|
||||
>
|
||||
<Square className="size-5 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
) : hasRecording ? (
|
||||
<>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={togglePlayback}
|
||||
className="rounded-full"
|
||||
>
|
||||
{recordingState === 'playing' ? (
|
||||
<>
|
||||
<Pause className="size-5 mr-2" />
|
||||
Pause
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="size-5 mr-2" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={resetRecording}
|
||||
className="rounded-full"
|
||||
>
|
||||
<RotateCcw className="size-5 mr-2" />
|
||||
Re-record
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t bg-muted/30">
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleClose(false)}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!hasRecording || isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Singing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="size-4 mr-2" />
|
||||
Sing for Blobbi
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// src/blobbi/actions/components/StartEvolutionDialog.tsx
|
||||
|
||||
/**
|
||||
* Dialog for confirming start of evolution.
|
||||
*
|
||||
* Evolution is simpler than incubation:
|
||||
* - Only baby Blobbis can evolve
|
||||
* - Shows restart confirmation if already evolving
|
||||
* - Otherwise shows normal start confirmation
|
||||
*/
|
||||
|
||||
import { Loader2, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StartEvolutionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The companion to start evolving */
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called when confirmed */
|
||||
onConfirm: () => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function StartEvolutionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: StartEvolutionDialogProps) {
|
||||
// Check if the current Blobbi is already evolving
|
||||
const isAlreadyEvolving = companion?.state === 'evolving';
|
||||
|
||||
// Determine title and description based on state
|
||||
const getDialogContent = () => {
|
||||
if (isAlreadyEvolving) {
|
||||
return {
|
||||
title: 'Restart Evolution?',
|
||||
icon: <AlertTriangle className="size-5 text-amber-500" />,
|
||||
description: (
|
||||
<>
|
||||
<strong>{companion?.name}</strong> is already evolving. Starting over will{' '}
|
||||
<strong>reset all task progress</strong> and begin from the beginning.
|
||||
<br /><br />
|
||||
Are you sure you want to restart?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Restart Evolution',
|
||||
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Start Evolution',
|
||||
icon: <Sparkles className="size-5 text-primary" />,
|
||||
description: (
|
||||
<>
|
||||
Starting evolution begins <strong>{companion?.name}</strong>'s transformation journey.
|
||||
Complete all the tasks to evolve your baby Blobbi into an adult!
|
||||
<br /><br />
|
||||
Ready to begin?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Start Evolution',
|
||||
buttonClass: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white',
|
||||
};
|
||||
};
|
||||
|
||||
const content = getDialogContent();
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
{content.icon}
|
||||
{content.title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{content.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={content.buttonClass}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
content.buttonText
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
// src/blobbi/actions/components/StartIncubationDialog.tsx
|
||||
|
||||
/**
|
||||
* Dialog for confirming start of incubation.
|
||||
*
|
||||
* Determines the mode and passes it explicitly to the confirm callback:
|
||||
* - 'start': Normal start, no other Blobbi incubating
|
||||
* - 'restart': Restart same Blobbi (already incubating)
|
||||
* - 'switch': Stop another Blobbi first, then start this one
|
||||
*
|
||||
* The mode is determined by UI state, NOT auto-detected by the hook.
|
||||
* This makes the flow explicit and predictable.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Loader2, AlertTriangle, ArrowRightLeft } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StartIncubationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The companion to start incubating */
|
||||
companion: BlobbiCompanion | null;
|
||||
/** All companions in the collection (to check for other incubating Blobbis) */
|
||||
companions?: BlobbiCompanion[];
|
||||
/** Called with explicit mode and optional stopOtherD when confirmed */
|
||||
onConfirm: (mode: StartIncubationMode, stopOtherD?: string) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function StartIncubationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
companions = [],
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: StartIncubationDialogProps) {
|
||||
// Check if the current Blobbi is already in a task state
|
||||
const isAlreadyInTaskState = companion?.state === 'incubating' || companion?.state === 'evolving';
|
||||
|
||||
// Check if another Blobbi (not this one) is currently incubating
|
||||
const otherIncubatingBlobbi = useMemo(() => {
|
||||
if (!companion) return null;
|
||||
return companions.find(c =>
|
||||
c.d !== companion.d &&
|
||||
c.state === 'incubating' &&
|
||||
c.stage === 'egg'
|
||||
) ?? null;
|
||||
}, [companion, companions]);
|
||||
|
||||
// Determine the mode based on current state
|
||||
const mode: StartIncubationMode = useMemo(() => {
|
||||
if (isAlreadyInTaskState) return 'restart';
|
||||
if (otherIncubatingBlobbi) return 'switch';
|
||||
return 'start';
|
||||
}, [isAlreadyInTaskState, otherIncubatingBlobbi]);
|
||||
|
||||
// Handle confirm with explicit mode
|
||||
const handleConfirm = () => {
|
||||
if (mode === 'switch' && otherIncubatingBlobbi) {
|
||||
onConfirm(mode, otherIncubatingBlobbi.d);
|
||||
} else {
|
||||
onConfirm(mode);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine title and description based on mode
|
||||
const getDialogContent = () => {
|
||||
if (mode === 'restart') {
|
||||
return {
|
||||
title: 'Restart Incubation?',
|
||||
icon: <AlertTriangle className="size-5 text-amber-500" />,
|
||||
description: (
|
||||
<>
|
||||
Your Blobbi is already {companion?.state}. Starting over will{' '}
|
||||
<strong>reset all task progress</strong> and begin from the beginning.
|
||||
<br /><br />
|
||||
Are you sure you want to restart?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Restart Incubation',
|
||||
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'switch') {
|
||||
return {
|
||||
title: 'Switch Incubation?',
|
||||
icon: <ArrowRightLeft className="size-5 text-amber-500" />,
|
||||
description: (
|
||||
<>
|
||||
<strong>{otherIncubatingBlobbi?.name}</strong> is currently incubating.
|
||||
Only one Blobbi can incubate at a time.
|
||||
<br /><br />
|
||||
Starting incubation for <strong>{companion?.name}</strong> will{' '}
|
||||
<strong>stop {otherIncubatingBlobbi?.name}'s incubation</strong> and{' '}
|
||||
reset their task progress.
|
||||
<br /><br />
|
||||
Do you want to switch?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Switch & Start',
|
||||
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Start Incubation',
|
||||
icon: null,
|
||||
description: (
|
||||
<>
|
||||
Starting incubation begins your Blobbi's hatching journey.
|
||||
Complete all the tasks to hatch your egg into a baby Blobbi!
|
||||
<br /><br />
|
||||
Ready to begin?
|
||||
</>
|
||||
),
|
||||
buttonText: 'Start Incubation',
|
||||
buttonClass: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const content = getDialogContent();
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
{content.icon}
|
||||
{content.title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{content.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={content.buttonClass}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
content.buttonText
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
// src/blobbi/actions/components/TasksPanel.tsx
|
||||
|
||||
/**
|
||||
* Card-grid presentation for hatch / evolve tasks.
|
||||
*
|
||||
* Each task is a compact card in a 2-column grid.
|
||||
* Tapping a card expands it inline (full row) to reveal details.
|
||||
* Only one card is expanded at a time.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Palette,
|
||||
Droplets,
|
||||
MessageSquare,
|
||||
Heart,
|
||||
UserPen,
|
||||
Activity,
|
||||
Loader2,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { HatchTask } from '../hooks/useHatchTasks';
|
||||
import type { MissionCategory } from './ExpandableMissionCard';
|
||||
import {
|
||||
ExpandableMissionCard,
|
||||
MissionDescription,
|
||||
MissionProgress,
|
||||
MissionAction,
|
||||
DynamicHint,
|
||||
} from './ExpandableMissionCard';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TasksPanelProps {
|
||||
tasks: HatchTask[];
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
onOpenPostModal: () => void;
|
||||
onComplete: () => void;
|
||||
isCompleting?: boolean;
|
||||
completeLabel: string;
|
||||
completingLabel: string;
|
||||
completeEmoji: string;
|
||||
/** Mission category for styling the cards */
|
||||
category?: MissionCategory;
|
||||
}
|
||||
|
||||
// ─── Task Icon Mapping ────────────────────────────────────────────────────────
|
||||
|
||||
/** Map task ids to lucide icons. Falls back to a generic icon. */
|
||||
function TaskIcon({ taskId }: { taskId: string }) {
|
||||
const iconClass = 'size-5';
|
||||
|
||||
switch (taskId) {
|
||||
case 'create_themes':
|
||||
return <Palette className={iconClass} />;
|
||||
case 'color_moments':
|
||||
return <Droplets className={iconClass} />;
|
||||
case 'create_posts':
|
||||
return <MessageSquare className={iconClass} />;
|
||||
case 'interactions':
|
||||
return <Heart className={iconClass} />;
|
||||
case 'edit_profile':
|
||||
return <UserPen className={iconClass} />;
|
||||
case 'maintain_stats':
|
||||
return <Activity className={iconClass} />;
|
||||
default:
|
||||
return <HelpCircle className={iconClass} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function TasksPanel({
|
||||
tasks,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
onOpenPostModal,
|
||||
onComplete,
|
||||
isCompleting = false,
|
||||
completeLabel,
|
||||
completingLabel,
|
||||
completeEmoji,
|
||||
category = 'hatch',
|
||||
}: TasksPanelProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Card grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{tasks.map((task) => {
|
||||
const isDynamic = task.type === 'dynamic';
|
||||
const progress =
|
||||
task.required > 0 ? task.current / task.required : task.completed ? 1 : 0;
|
||||
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
switch (task.action) {
|
||||
case 'navigate':
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
openUrl(task.actionTarget);
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') onOpenPostModal();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ExpandableMissionCard
|
||||
key={task.id}
|
||||
id={task.id}
|
||||
category={category}
|
||||
icon={<TaskIcon taskId={task.id} />}
|
||||
title={task.name}
|
||||
completed={task.completed}
|
||||
progress={Math.min(progress, 1)}
|
||||
isExpanded={expandedId === task.id}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{/* Expanded content */}
|
||||
<MissionDescription>{task.description}</MissionDescription>
|
||||
|
||||
{/* Progress bar for multi-step tasks */}
|
||||
{task.required > 1 && !isDynamic && (
|
||||
<MissionProgress
|
||||
current={task.current}
|
||||
required={task.required}
|
||||
completed={task.completed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dynamic stat hint */}
|
||||
{isDynamic && !task.completed && (
|
||||
<DynamicHint current={task.current} required={task.required} />
|
||||
)}
|
||||
|
||||
{/* Action link */}
|
||||
{task.action && task.actionLabel && !task.completed && (
|
||||
<MissionAction
|
||||
label={task.actionLabel}
|
||||
type={task.action}
|
||||
onClick={handleAction}
|
||||
/>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* CTA button when all tasks are done */}
|
||||
{allCompleted && (
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
disabled={isCompleting}
|
||||
size="lg"
|
||||
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white shadow-sm"
|
||||
>
|
||||
{isCompleting ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{completingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-lg">{completeEmoji}</span>
|
||||
{completeLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
// src/blobbi/actions/hooks/useActiveTaskProcess.ts
|
||||
|
||||
/**
|
||||
* Central abstraction for the active task process (hatch or evolve).
|
||||
*
|
||||
* This hook consolidates all scattered if/else logic for determining:
|
||||
* - Which process is active (incubating vs evolving)
|
||||
* - Which tasks to use (hatch vs evolve)
|
||||
* - Thresholds and configuration
|
||||
* - Badge-related computed values
|
||||
*
|
||||
* ARCHITECTURE RULES:
|
||||
* - Computed tasks remain the source of truth
|
||||
* - Tags are cache only for PERSISTENT tasks
|
||||
* - Dynamic tasks are NEVER persisted
|
||||
* - Badge counts ALL incomplete tasks (persistent + dynamic)
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { HatchTask, HatchTasksResult } from './useHatchTasks';
|
||||
import type { EvolveTasksResult } from './useEvolveTasks';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The type of task process currently active */
|
||||
export type TaskProcessType = 'hatch' | 'evolve' | null;
|
||||
|
||||
/**
|
||||
* Configuration for the active task process.
|
||||
* This provides a unified interface regardless of whether
|
||||
* the process is hatch or evolve.
|
||||
*/
|
||||
export interface TaskProcessConfig {
|
||||
/** The type of process ('hatch' | 'evolve' | null) */
|
||||
type: TaskProcessType;
|
||||
/** Whether there is an active task process */
|
||||
isActive: boolean;
|
||||
/** Required interactions threshold for the current process */
|
||||
interactionThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the active task process hook.
|
||||
* Provides unified access to all task-related state.
|
||||
*/
|
||||
export interface ActiveTaskProcessResult {
|
||||
/** Configuration for the current process */
|
||||
config: TaskProcessConfig;
|
||||
|
||||
/** All tasks for the current process (empty if no active process) */
|
||||
tasks: HatchTask[];
|
||||
/** Whether tasks are still loading */
|
||||
isLoading: boolean;
|
||||
/** Whether all tasks (persistent + dynamic) are complete */
|
||||
allCompleted: boolean;
|
||||
/** Whether all persistent tasks are complete */
|
||||
persistentTasksComplete: boolean;
|
||||
/** Whether the dynamic task is complete */
|
||||
dynamicTaskComplete: boolean;
|
||||
|
||||
/** Refetch function for current tasks */
|
||||
refetch: () => void;
|
||||
|
||||
// ─── Badge-related computed values ───
|
||||
|
||||
/**
|
||||
* Count of ALL remaining incomplete tasks (persistent + dynamic).
|
||||
* This is used for the badge display.
|
||||
* Dynamic tasks ARE counted here but are NEVER synced to tags.
|
||||
*/
|
||||
remainingTasksCount: number;
|
||||
|
||||
/**
|
||||
* Only persistent tasks that are incomplete.
|
||||
* Used for sync logic - dynamic tasks must NEVER be synced.
|
||||
*/
|
||||
incompletePersistentTasks: HatchTask[];
|
||||
|
||||
/**
|
||||
* Only persistent tasks that are complete.
|
||||
* Used for sync logic.
|
||||
*/
|
||||
completedPersistentTasks: HatchTask[];
|
||||
|
||||
/**
|
||||
* Stable string key of completed persistent task IDs.
|
||||
* Used for sync anti-loop protection.
|
||||
*/
|
||||
completedPersistentTaskIds: string;
|
||||
|
||||
/**
|
||||
* Tasks to sync (persistent only, with completion status).
|
||||
* Dynamic tasks are excluded.
|
||||
*/
|
||||
tasksToSync: Array<{ taskId: string; completed: boolean }>;
|
||||
}
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter tasks to only persistent tasks.
|
||||
* Dynamic tasks must NEVER be synced to tags.
|
||||
*/
|
||||
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
|
||||
return tasks.filter(t => t.type === 'persistent');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tasks to only dynamic tasks.
|
||||
*/
|
||||
export function filterDynamicTasks(tasks: HatchTask[]): HatchTask[] {
|
||||
return tasks.filter(t => t.type === 'dynamic');
|
||||
}
|
||||
|
||||
// ─── Main Hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook that provides a unified interface for the active task process.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const taskProcess = useActiveTaskProcess(companion, hatchTasks, evolveTasks);
|
||||
*
|
||||
* // Access unified data
|
||||
* taskProcess.config.type // 'hatch' | 'evolve' | null
|
||||
* taskProcess.tasks // current tasks
|
||||
* taskProcess.remainingTasksCount // for badge (includes dynamic)
|
||||
* taskProcess.tasksToSync // for sync (excludes dynamic)
|
||||
* ```
|
||||
*/
|
||||
export function useActiveTaskProcess(
|
||||
companion: BlobbiCompanion | null,
|
||||
hatchTasks: HatchTasksResult,
|
||||
evolveTasks: EvolveTasksResult
|
||||
): ActiveTaskProcessResult {
|
||||
// Determine which process is active
|
||||
const processType = useMemo((): TaskProcessType => {
|
||||
if (!companion) return null;
|
||||
if (companion.state === 'incubating') return 'hatch';
|
||||
if (companion.state === 'evolving') return 'evolve';
|
||||
return null;
|
||||
}, [companion]);
|
||||
|
||||
// Build configuration
|
||||
const config = useMemo((): TaskProcessConfig => {
|
||||
const isActive = processType !== null;
|
||||
const interactionThreshold = processType === 'hatch'
|
||||
? HATCH_REQUIRED_INTERACTIONS
|
||||
: processType === 'evolve'
|
||||
? EVOLVE_REQUIRED_INTERACTIONS
|
||||
: 0;
|
||||
|
||||
return {
|
||||
type: processType,
|
||||
isActive,
|
||||
interactionThreshold,
|
||||
};
|
||||
}, [processType]);
|
||||
|
||||
// Get the active tasks result based on process type
|
||||
const activeResult = useMemo(() => {
|
||||
if (processType === 'hatch') return hatchTasks;
|
||||
if (processType === 'evolve') return evolveTasks;
|
||||
return null;
|
||||
}, [processType, hatchTasks, evolveTasks]);
|
||||
|
||||
// Extract tasks and state from active result
|
||||
const tasks = useMemo(() => activeResult?.tasks ?? [], [activeResult]);
|
||||
const isLoading = activeResult?.isLoading ?? false;
|
||||
const allCompleted = activeResult?.allCompleted ?? false;
|
||||
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
|
||||
const dynamicTaskComplete = activeResult?.dynamicTaskComplete ?? false;
|
||||
const refetch = activeResult?.refetch ?? (() => {});
|
||||
|
||||
// Compute persistent task list (dynamic tasks computed for badge count directly from tasks array)
|
||||
const persistentTasks = useMemo(() => filterPersistentTasks(tasks), [tasks]);
|
||||
|
||||
// Compute incomplete tasks (for badge - includes BOTH persistent and dynamic)
|
||||
const remainingTasksCount = useMemo(() => {
|
||||
// Count ALL incomplete tasks - persistent AND dynamic
|
||||
// Dynamic tasks are included in badge count but NEVER synced to tags
|
||||
return tasks.filter(t => !t.completed).length;
|
||||
}, [tasks]);
|
||||
|
||||
// Compute persistent task lists for sync
|
||||
const incompletePersistentTasks = useMemo(() =>
|
||||
persistentTasks.filter(t => !t.completed),
|
||||
[persistentTasks]
|
||||
);
|
||||
|
||||
const completedPersistentTasks = useMemo(() =>
|
||||
persistentTasks.filter(t => t.completed),
|
||||
[persistentTasks]
|
||||
);
|
||||
|
||||
// Compute stable string key for completed persistent tasks (anti-loop)
|
||||
const completedPersistentTaskIds = useMemo(() => {
|
||||
if (!completedPersistentTasks.length) return '';
|
||||
return completedPersistentTasks
|
||||
.map(t => t.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
}, [completedPersistentTasks]);
|
||||
|
||||
// Compute tasks to sync (persistent only)
|
||||
// CRITICAL: Dynamic tasks must NEVER be included here
|
||||
const tasksToSync = useMemo(() => {
|
||||
if (!persistentTasks.length) return [];
|
||||
return persistentTasks.map(t => ({
|
||||
taskId: t.id,
|
||||
completed: t.completed,
|
||||
}));
|
||||
}, [persistentTasks]);
|
||||
|
||||
return {
|
||||
config,
|
||||
tasks,
|
||||
isLoading,
|
||||
allCompleted,
|
||||
persistentTasksComplete,
|
||||
dynamicTaskComplete,
|
||||
refetch,
|
||||
remainingTasksCount,
|
||||
incompletePersistentTasks,
|
||||
completedPersistentTasks,
|
||||
completedPersistentTaskIds,
|
||||
tasksToSync,
|
||||
};
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
// src/blobbi/actions/hooks/useAudioPlayback.ts
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Audio playback state
|
||||
* - idle: No audio loaded
|
||||
* - loading: Audio is being loaded
|
||||
* - playing: Audio is playing
|
||||
* - paused: Audio is paused (can resume)
|
||||
* - stopped: Audio was stopped (must reload to play again)
|
||||
* - error: An error occurred
|
||||
*/
|
||||
export type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'stopped' | 'error';
|
||||
|
||||
/**
|
||||
* Audio playback error info
|
||||
*/
|
||||
export interface PlaybackError {
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** Default volume level (0-1) */
|
||||
const DEFAULT_VOLUME = 0.8;
|
||||
|
||||
/**
|
||||
* Options for the useAudioPlayback hook
|
||||
*/
|
||||
export interface UseAudioPlaybackOptions {
|
||||
/** Called when playback ends naturally */
|
||||
onEnded?: () => void;
|
||||
/** Called when an error occurs */
|
||||
onError?: (error: PlaybackError) => void;
|
||||
/** Initial volume level (0-1), defaults to 0.8 */
|
||||
initialVolume?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useAudioPlayback hook
|
||||
*/
|
||||
export interface UseAudioPlaybackReturn {
|
||||
/** Current playback state */
|
||||
state: PlaybackState;
|
||||
/** Current error (if any) */
|
||||
error: PlaybackError | null;
|
||||
/** Current audio URL being played */
|
||||
currentUrl: string | null;
|
||||
/** Load and optionally start playing an audio URL */
|
||||
load: (url: string, autoplay?: boolean) => void;
|
||||
/** Play the current audio */
|
||||
play: () => Promise<void>;
|
||||
/** Pause the current audio */
|
||||
pause: () => void;
|
||||
/** Stop playback and reset */
|
||||
stop: () => void;
|
||||
/** Restart playback from the beginning */
|
||||
restart: () => Promise<void>;
|
||||
/** Toggle play/pause */
|
||||
toggle: () => Promise<void>;
|
||||
/** Whether audio is currently playing */
|
||||
isPlaying: boolean;
|
||||
/** Current volume level (0-1) */
|
||||
volume: number;
|
||||
/** Set volume level (0-1) */
|
||||
setVolume: (volume: number) => void;
|
||||
/** Cleanup function to release resources */
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable hook for audio playback.
|
||||
* Handles Audio element lifecycle, error handling, and state management.
|
||||
*/
|
||||
export function useAudioPlayback(options: UseAudioPlaybackOptions = {}): UseAudioPlaybackReturn {
|
||||
const { onEnded, onError, initialVolume = DEFAULT_VOLUME } = options;
|
||||
|
||||
const [state, setState] = useState<PlaybackState>('idle');
|
||||
const [error, setError] = useState<PlaybackError | null>(null);
|
||||
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
|
||||
const [volume, setVolumeState] = useState<number>(initialVolume);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const currentUrlRef = useRef<string | null>(null);
|
||||
const volumeRef = useRef<number>(initialVolume);
|
||||
|
||||
// Cleanup audio element
|
||||
const cleanup = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
audioRef.current.oncanplay = null;
|
||||
audioRef.current.onplaying = null;
|
||||
audioRef.current = null;
|
||||
}
|
||||
currentUrlRef.current = null;
|
||||
setState('idle');
|
||||
setCurrentUrl(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
// Load audio from URL
|
||||
const load = useCallback((url: string, autoplay = false) => {
|
||||
// If same URL, don't reload
|
||||
if (currentUrlRef.current === url && audioRef.current) {
|
||||
if (autoplay) {
|
||||
audioRef.current.play().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleanup previous audio
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.onerror = null;
|
||||
audioRef.current.oncanplay = null;
|
||||
audioRef.current.onplaying = null;
|
||||
}
|
||||
|
||||
setState('loading');
|
||||
setError(null);
|
||||
setCurrentUrl(url);
|
||||
currentUrlRef.current = url;
|
||||
|
||||
const audio = new Audio(url);
|
||||
audio.volume = volumeRef.current; // Apply current volume to new audio
|
||||
audioRef.current = audio;
|
||||
|
||||
audio.oncanplay = () => {
|
||||
if (autoplay) {
|
||||
audio.play().catch((err) => {
|
||||
const playbackError: PlaybackError = {
|
||||
message: getPlaybackErrorMessage(err),
|
||||
code: err.name,
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
});
|
||||
} else {
|
||||
setState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
audio.onplaying = () => {
|
||||
setState('playing');
|
||||
};
|
||||
|
||||
audio.onpause = () => {
|
||||
if (state === 'playing') {
|
||||
setState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
audio.onended = () => {
|
||||
setState('paused');
|
||||
onEnded?.();
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
const playbackError: PlaybackError = {
|
||||
message: 'Failed to load audio. The format may not be supported.',
|
||||
code: 'MEDIA_ERR',
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
};
|
||||
|
||||
// Start loading
|
||||
audio.load();
|
||||
}, [onEnded, onError, state]);
|
||||
|
||||
// Play current audio
|
||||
const play = useCallback(async () => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await audioRef.current.play();
|
||||
setState('playing');
|
||||
} catch (err) {
|
||||
const playbackError: PlaybackError = {
|
||||
message: getPlaybackErrorMessage(err),
|
||||
code: err instanceof Error ? err.name : 'UNKNOWN',
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
// Pause current audio
|
||||
const pause = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.pause();
|
||||
setState('paused');
|
||||
}, []);
|
||||
|
||||
// Stop playback completely (requires reload to play again)
|
||||
const stop = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
// Clear URL ref so next load() will actually reload
|
||||
currentUrlRef.current = null;
|
||||
setState('stopped');
|
||||
}, []);
|
||||
|
||||
// Restart playback from the beginning
|
||||
const restart = useCallback(async () => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.currentTime = 0;
|
||||
try {
|
||||
await audioRef.current.play();
|
||||
setState('playing');
|
||||
} catch (err) {
|
||||
const playbackError: PlaybackError = {
|
||||
message: getPlaybackErrorMessage(err),
|
||||
code: err instanceof Error ? err.name : 'UNKNOWN',
|
||||
};
|
||||
setError(playbackError);
|
||||
setState('error');
|
||||
onError?.(playbackError);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
// Toggle play/pause
|
||||
const toggle = useCallback(async () => {
|
||||
if (state === 'playing') {
|
||||
pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}, [state, play, pause]);
|
||||
|
||||
// Set volume (0-1)
|
||||
const setVolume = useCallback((newVolume: number) => {
|
||||
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
||||
volumeRef.current = clampedVolume;
|
||||
setVolumeState(clampedVolume);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = clampedVolume;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
error,
|
||||
currentUrl,
|
||||
load,
|
||||
play,
|
||||
pause,
|
||||
stop,
|
||||
restart,
|
||||
toggle,
|
||||
isPlaying: state === 'playing',
|
||||
volume,
|
||||
setVolume,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message for playback errors
|
||||
*/
|
||||
function getPlaybackErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotSupportedError') {
|
||||
return 'This audio format is not supported by your browser.';
|
||||
}
|
||||
if (err.name === 'NotAllowedError') {
|
||||
return 'Playback was blocked. Try interacting with the page first.';
|
||||
}
|
||||
return err.message;
|
||||
}
|
||||
return 'An unknown error occurred during playback.';
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* useBlobbiCareActivity - Hook for registering care activity and updating streaks
|
||||
*
|
||||
* This hook provides a centralized way to register care activity for a Blobbi companion.
|
||||
* It handles:
|
||||
* - Calculating streak updates based on the last activity day
|
||||
* - Publishing updated Blobbi state to Nostr
|
||||
* - Updating local cache
|
||||
*
|
||||
* Use this hook whenever care activity should count toward the streak:
|
||||
* - Opening the Blobbi page (page check-in)
|
||||
* - Performing care actions (feed, clean, play, etc.)
|
||||
* - Any other care interaction
|
||||
*
|
||||
* The streak only increments once per calendar day, regardless of how many
|
||||
* activities are performed.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseBlobbiCareActivityParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
}
|
||||
|
||||
export interface CareActivityResult {
|
||||
/** Whether the streak was updated */
|
||||
wasUpdated: boolean;
|
||||
/** The new streak value */
|
||||
newStreak: number;
|
||||
/** Description of what happened */
|
||||
action: StreakUpdateResult['action'];
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to register care activity and update streaks.
|
||||
*
|
||||
* Returns a function to register activity and a mutation for the actual update.
|
||||
* The register function is idempotent - calling it multiple times on the same day
|
||||
* will only update once.
|
||||
*/
|
||||
export function useBlobbiCareActivity({
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
}: UseBlobbiCareActivityParams) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
// Track if we've already registered activity this session to avoid duplicate calls
|
||||
// This is a performance optimization - the actual idempotency is handled by day comparison
|
||||
const lastRegisteredDay = useRef<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (): Promise<CareActivityResult> => {
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to register care activity');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion available');
|
||||
}
|
||||
|
||||
// Fetch fresh companion from relays (read-modify-write pattern)
|
||||
const freshEvents = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [companion.d],
|
||||
}]);
|
||||
const freshCompanion = freshEvents
|
||||
.filter(isValidBlobbiEvent)
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.map(e => parseBlobbiEvent(e))
|
||||
.find(Boolean) ?? companion;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Calculate what the streak update should be using fresh data
|
||||
const result = calculateStreakUpdate(
|
||||
freshCompanion.careStreak,
|
||||
freshCompanion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
|
||||
// If no update needed (same day), return early without publishing
|
||||
if (!result.wasUpdated) {
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: result.newStreak,
|
||||
action: result.action,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the tag updates using fresh data
|
||||
const streakUpdates = getStreakTagUpdates(freshCompanion, now);
|
||||
|
||||
if (!streakUpdates) {
|
||||
// Shouldn't happen if wasUpdated is true, but handle gracefully
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: freshCompanion.careStreak ?? 0,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
// Build updated tags from fresh data
|
||||
const updatedTags = updateBlobbiTags(freshCompanion.allTags, streakUpdates);
|
||||
|
||||
// Publish the updated event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: freshCompanion.event.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update local cache (optimistic — no invalidation needed)
|
||||
updateCompanionEvent(event);
|
||||
|
||||
// Update session tracker
|
||||
lastRegisteredDay.current = result.newLastDay;
|
||||
|
||||
// Log for debugging (dev only)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CareActivity] Streak updated:', {
|
||||
action: result.action,
|
||||
previousStreak: freshCompanion.careStreak,
|
||||
newStreak: result.newStreak,
|
||||
lastDay: freshCompanion.careStreakLastDay,
|
||||
newDay: result.newLastDay,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: result.newStreak,
|
||||
action: result.action,
|
||||
};
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('[CareActivity] Failed to update streak:', error);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Register care activity. Call this when care-related activity happens.
|
||||
* Safe to call multiple times - only updates streak once per day.
|
||||
*
|
||||
* @returns Promise with the result of the activity registration
|
||||
*/
|
||||
const registerCareActivity = useCallback(async (): Promise<CareActivityResult | null> => {
|
||||
if (!companion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Quick check if we've already registered for this companion's last day (session cache)
|
||||
// This is an optimization to avoid unnecessary mutation calls
|
||||
if (lastRegisteredDay.current === companion.careStreakLastDay) {
|
||||
// Already processed this day in this session, skip
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: companion.careStreak ?? 0,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
return mutation.mutateAsync();
|
||||
}, [companion, mutation]);
|
||||
|
||||
return {
|
||||
/** Register care activity - call when page opens or care action happens */
|
||||
registerCareActivity,
|
||||
/** Whether an update is currently in progress */
|
||||
isUpdating: mutation.isPending,
|
||||
/** The last update result */
|
||||
lastResult: mutation.data,
|
||||
/** Any error from the last update attempt */
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiDirectAction.ts
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import {
|
||||
clampStat,
|
||||
applyStat,
|
||||
DIRECT_ACTION_METADATA,
|
||||
type DirectAction,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '../lib/daily-mission-tracker';
|
||||
import type { DailyMissionAction } from '../lib/daily-missions';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
|
||||
|
||||
// Import NostrEvent type
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Configuration for direct action happiness effects.
|
||||
* These are the happiness deltas for each direct action.
|
||||
*/
|
||||
export const DIRECT_ACTION_HAPPINESS_EFFECTS: Record<DirectAction, number> = {
|
||||
play_music: 15,
|
||||
sing: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
* Request payload for executing a direct action
|
||||
*/
|
||||
export interface DirectActionRequest {
|
||||
action: DirectAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of executing a direct action
|
||||
*/
|
||||
export interface DirectActionResult {
|
||||
action: DirectAction;
|
||||
happinessChange: number;
|
||||
xpGained: number;
|
||||
newXP: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the useBlobbiDirectAction hook
|
||||
*/
|
||||
export interface UseBlobbiDirectActionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called after ensuring companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to execute a direct action on a Blobbi companion.
|
||||
* Direct actions (play_music, sing) don't require selecting an item.
|
||||
* They directly affect happiness stat.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Validates the companion exists
|
||||
* 2. Ensures canonical format before action
|
||||
* 3. Applies accumulated decay
|
||||
* 4. Applies happiness boost
|
||||
* 5. Updates Blobbi state (kind 31124)
|
||||
* 6. Invalidates relevant queries
|
||||
*/
|
||||
export function useBlobbiDirectAction({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
}: UseBlobbiDirectActionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ action }: DirectActionRequest): Promise<DirectActionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to perform actions');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for action');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
const statsAfterDecay = decayResult.stats;
|
||||
|
||||
// ─── Apply Happiness Effect ───
|
||||
const happinessDelta = DIRECT_ACTION_HAPPINESS_EFFECTS[action];
|
||||
const newHappiness = applyStat(statsAfterDecay.happiness, happinessDelta);
|
||||
|
||||
// Track if happiness actually changed
|
||||
const happinessChanged = newHappiness !== statsAfterDecay.happiness;
|
||||
|
||||
// Build stats update
|
||||
const isEgg = canonical.companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {
|
||||
happiness: newHappiness.toString(),
|
||||
health: statsAfterDecay.health.toString(),
|
||||
hygiene: statsAfterDecay.hygiene.toString(),
|
||||
};
|
||||
|
||||
if (isEgg) {
|
||||
// Eggs have fixed hunger and energy
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
statsUpdate.hunger = clampStat(statsAfterDecay.hunger).toString();
|
||||
statsUpdate.energy = clampStat(statsAfterDecay.energy).toString();
|
||||
}
|
||||
|
||||
// ─── Update Blobbi State Event (kind 31124) ───
|
||||
const nowStr = now.toString();
|
||||
|
||||
// If incubating or evolving, increment the interaction counter in evolution missions
|
||||
const companionState = canonical.companion.state;
|
||||
const updatedTags = canonical.allTags;
|
||||
if (companionState === 'incubating' || companionState === 'evolving') {
|
||||
trackEvolutionMissionTally('interactions', 1, user.pubkey);
|
||||
}
|
||||
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// ─── Apply XP Gain (ONLY if happiness actually changed) ───
|
||||
// Direct actions modify happiness. Only grant XP if happiness actually increased.
|
||||
const xpGained = happinessChanged ? calculateActionXP(action) : 0;
|
||||
const currentXP = canonical.companion.experience ?? 0;
|
||||
const newXP = applyXPGain(currentXP, xpGained);
|
||||
|
||||
const blobbiTags = updateBlobbiTags(updatedTags, {
|
||||
...statsUpdate,
|
||||
...streakUpdates,
|
||||
experience: newXP.toString(),
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const blobbiEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: blobbiTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
return {
|
||||
action,
|
||||
happinessChange: happinessDelta,
|
||||
xpGained,
|
||||
newXP,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ action, happinessChange, xpGained }) => {
|
||||
const actionMeta = DIRECT_ACTION_METADATA[action];
|
||||
const xpText = formatXPGain(xpGained);
|
||||
toast({
|
||||
title: `${actionMeta.label} complete!`,
|
||||
description: `Your Blobbi's happiness increased by ${happinessChange}! ${xpText}`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
// 'interact' is always tracked, plus the specific action
|
||||
const dailyActions: DailyMissionAction[] = ['interact'];
|
||||
if (action === 'sing') dailyActions.push('sing');
|
||||
if (action === 'play_music') dailyActions.push('play_music');
|
||||
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Action failed',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,742 +0,0 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiIncubation.ts
|
||||
|
||||
/**
|
||||
* Hooks for Blobbi incubation task system.
|
||||
*
|
||||
* When a user starts incubation:
|
||||
* 1. Apply accumulated decay from last_decay_at to now
|
||||
* 2. Set state to 'incubating'
|
||||
* 3. Add state_started_at timestamp
|
||||
* 4. Update last_decay_at to the same timestamp
|
||||
* 5. Clear any previous task progress
|
||||
*
|
||||
* Tasks are computed from Nostr events with created_at >= state_started_at
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { createHatchMissions, createEvolveMissions } from '../lib/evolution-missions';
|
||||
import {
|
||||
ensureSessionStore,
|
||||
writeMissionsToStorage,
|
||||
} from '../lib/daily-mission-tracker';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mode for starting incubation.
|
||||
* This makes the intent explicit rather than auto-detecting behavior.
|
||||
*/
|
||||
export type StartIncubationMode =
|
||||
| 'start' // Normal start (no other Blobbi incubating)
|
||||
| 'restart' // Restart same Blobbi (already incubating)
|
||||
| 'switch'; // Switch from another incubating Blobbi
|
||||
|
||||
/**
|
||||
* Request to start incubation with explicit mode.
|
||||
*/
|
||||
export interface StartIncubationRequest {
|
||||
/** Explicit mode for this operation */
|
||||
mode: StartIncubationMode;
|
||||
/** The d-tag of the other Blobbi to stop (required when mode === 'switch') */
|
||||
stopOtherD?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for start incubation hook.
|
||||
*/
|
||||
export interface UseStartIncubationParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of starting incubation.
|
||||
*/
|
||||
export interface StartIncubationResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
/** Timestamp when incubation started */
|
||||
stateStartedAt: number;
|
||||
/** Mode that was used */
|
||||
mode: StartIncubationMode;
|
||||
/** Name of other Blobbi that was stopped (if mode === 'switch') */
|
||||
stoppedOtherName?: string;
|
||||
}
|
||||
|
||||
// ─── Start Incubation Hook ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to start the incubation process for an egg.
|
||||
*
|
||||
* This sets the Blobbi state to 'incubating' and records the start timestamp.
|
||||
* Tasks will be computed based on events created after this timestamp.
|
||||
*
|
||||
* IMPORTANT: The mode must be explicitly specified by the caller (UI).
|
||||
* This hook does NOT auto-detect whether to switch or restart.
|
||||
* The UI dialog determines the mode and passes it explicitly.
|
||||
*
|
||||
* Modes:
|
||||
* - 'start': Normal start, no other Blobbi incubating
|
||||
* - 'restart': Restart same Blobbi (already incubating), resets task progress
|
||||
* - 'switch': Stop another Blobbi first, then start this one
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in egg stage
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStartIncubation({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
}: UseStartIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (request: StartIncubationRequest): Promise<StartIncubationResult> => {
|
||||
const { mode, stopOtherD } = request;
|
||||
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to start incubation');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'egg') {
|
||||
throw new Error('Only eggs can be incubated');
|
||||
}
|
||||
|
||||
// Validate switch mode requires stopOtherD
|
||||
if (mode === 'switch' && !stopOtherD) {
|
||||
throw new Error('Switch mode requires stopOtherD parameter');
|
||||
}
|
||||
|
||||
let stoppedOtherName: string | undefined;
|
||||
|
||||
// ─── Stop Other Incubating Blobbi (switch mode only) ───
|
||||
if (mode === 'switch' && stopOtherD) {
|
||||
// Fetch the current event for the other Blobbi
|
||||
const [otherEvent] = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [stopOtherD],
|
||||
limit: 1,
|
||||
}]);
|
||||
|
||||
if (otherEvent) {
|
||||
// Get name from the event for the result
|
||||
const nameTag = otherEvent.tags.find(t => t[0] === 'name');
|
||||
stoppedOtherName = nameTag?.[1] ?? stopOtherD;
|
||||
|
||||
// Stop the other Blobbi's incubation
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Parse stats from the event
|
||||
const getTagValue = (tags: string[][], name: string): number =>
|
||||
parseInt(tags.find(t => t[0] === name)?.[1] ?? '50', 10);
|
||||
|
||||
const otherStats = {
|
||||
hunger: getTagValue(otherEvent.tags, 'hunger'),
|
||||
happiness: getTagValue(otherEvent.tags, 'happiness'),
|
||||
health: getTagValue(otherEvent.tags, 'health'),
|
||||
hygiene: getTagValue(otherEvent.tags, 'hygiene'),
|
||||
energy: getTagValue(otherEvent.tags, 'energy'),
|
||||
};
|
||||
const otherLastDecayAt = getTagValue(otherEvent.tags, 'last_decay_at') || now;
|
||||
|
||||
// Apply decay to the other Blobbi
|
||||
const otherDecayResult = applyBlobbiDecay({
|
||||
stage: 'egg',
|
||||
state: 'incubating',
|
||||
stats: otherStats,
|
||||
lastDecayAt: otherLastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// Remove task tags and state_started_at from the other Blobbi
|
||||
const otherCleanedTags = otherEvent.tags.filter(tag =>
|
||||
tag[0] !== 'task' &&
|
||||
tag[0] !== 'task_completed' &&
|
||||
tag[0] !== 'state_started_at'
|
||||
);
|
||||
|
||||
const otherNewTags = updateBlobbiTags(otherCleanedTags, {
|
||||
health: otherDecayResult.stats.health.toString(),
|
||||
hygiene: otherDecayResult.stats.hygiene.toString(),
|
||||
happiness: otherDecayResult.stats.happiness.toString(),
|
||||
hunger: '100',
|
||||
energy: '100',
|
||||
state: 'active',
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// Publish the stop event for the other Blobbi
|
||||
const stopEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: otherEvent.content,
|
||||
tags: otherNewTags,
|
||||
});
|
||||
|
||||
// Update the cache for the stopped Blobbi
|
||||
updateCompanionEvent(stopEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for incubation');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
// CRITICAL: Apply decay from last_decay_at to now before changing state
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove any existing task tags when starting fresh (for all modes)
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' && tag[0] !== 'task_completed'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
// Eggs have fixed hunger and energy at 100
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: '100',
|
||||
energy: '100',
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'incubating',
|
||||
state_started_at: nowStr,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
|
||||
// ─── Populate evolution missions in session store ───
|
||||
const currentMissions = ensureSessionStore(user.pubkey);
|
||||
writeMissionsToStorage(
|
||||
{ ...currentMissions, evolution: createHatchMissions() },
|
||||
user.pubkey,
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
stateStartedAt: now,
|
||||
mode,
|
||||
stoppedOtherName,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name, mode, stoppedOtherName }) => {
|
||||
if (mode === 'switch' && stoppedOtherName) {
|
||||
toast({
|
||||
title: 'Switched incubation!',
|
||||
description: `Stopped ${stoppedOtherName}, now incubating ${name}.`,
|
||||
});
|
||||
} else if (mode === 'restart') {
|
||||
toast({
|
||||
title: 'Incubation restarted!',
|
||||
description: `${name}'s task progress has been reset.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Incubation started!',
|
||||
description: `${name} is now incubating. Complete the tasks to hatch!`,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to start incubation',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Stop Incubation Hook ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parameters for stop incubation hook.
|
||||
*/
|
||||
export interface UseStopIncubationParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of stopping incubation.
|
||||
*/
|
||||
export interface StopIncubationResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to stop/cancel the incubation process for a Blobbi.
|
||||
*
|
||||
* This resets the Blobbi state to 'active' and clears all task progress tags.
|
||||
* The user can restart incubation later, but will need to complete tasks again.
|
||||
*
|
||||
* When stopping incubation:
|
||||
* - Apply accumulated decay first
|
||||
* - Set state back to 'active'
|
||||
* - Remove state_started_at tag
|
||||
* - Remove all task and task_completed tags
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in incubating state
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStopIncubation({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
}: UseStopIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StopIncubationResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to stop incubation');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (companion.state !== 'incubating') {
|
||||
throw new Error('This Blobbi is not incubating');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove task tags and state_started_at
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' &&
|
||||
tag[0] !== 'task_completed' &&
|
||||
tag[0] !== 'state_started_at'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
// Eggs have fixed hunger and energy at 100
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: '100',
|
||||
energy: '100',
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'active',
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
|
||||
// ─── Clear evolution missions in session store ───
|
||||
const currentMissions = ensureSessionStore(user.pubkey);
|
||||
writeMissionsToStorage(
|
||||
{ ...currentMissions, evolution: [] },
|
||||
user.pubkey,
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Start Evolution Hook ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parameters for start evolution hook.
|
||||
*/
|
||||
export interface UseStartEvolutionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of starting evolution.
|
||||
*/
|
||||
export interface StartEvolutionResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
/** Timestamp when evolution started */
|
||||
stateStartedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to start the evolution process for a baby Blobbi.
|
||||
*
|
||||
* This sets the Blobbi state to 'evolving' and records the start timestamp.
|
||||
* Tasks will be computed based on events created after this timestamp.
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in baby stage
|
||||
* - Blobbi must not already be evolving
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStartEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
}: UseStartEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StartEvolutionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to start evolution');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'baby') {
|
||||
throw new Error('Only baby Blobbis can evolve');
|
||||
}
|
||||
|
||||
if (companion.state === 'evolving') {
|
||||
throw new Error('This Blobbi is already evolving');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for evolution');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove any existing task tags when starting fresh
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' && tag[0] !== 'task_completed'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: decayResult.stats.hunger.toString(),
|
||||
energy: decayResult.stats.energy.toString(),
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'evolving',
|
||||
state_started_at: nowStr,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
|
||||
// ─── Populate evolution missions in session store ───
|
||||
const currentMissions = ensureSessionStore(user.pubkey);
|
||||
writeMissionsToStorage(
|
||||
{ ...currentMissions, evolution: createEvolveMissions() },
|
||||
user.pubkey,
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
stateStartedAt: now,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Evolution started!',
|
||||
description: `${name} is now working towards evolution. Complete the tasks to evolve!`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to start evolution',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Stop Evolution Hook ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parameters for stop evolution hook.
|
||||
*/
|
||||
export interface UseStopEvolutionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of stopping evolution.
|
||||
*/
|
||||
export interface StopEvolutionResult {
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to stop/cancel the evolution process for a Blobbi.
|
||||
*
|
||||
* This resets the Blobbi state to 'active' and clears all task progress tags.
|
||||
* The user can restart evolution later, but will need to complete tasks again.
|
||||
*
|
||||
* When stopping evolution:
|
||||
* - Apply accumulated decay first
|
||||
* - Set state back to 'active'
|
||||
* - Remove state_started_at tag
|
||||
* - Remove all task and task_completed tags
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in evolving state
|
||||
* - User must be logged in
|
||||
*/
|
||||
export function useStopEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
}: UseStopEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StopEvolutionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to stop evolution');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (companion.state !== 'evolving') {
|
||||
throw new Error('This Blobbi is not evolving');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay ───
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Remove task tags and state_started_at
|
||||
const cleanedTags = canonical.allTags.filter(tag =>
|
||||
tag[0] !== 'task' &&
|
||||
tag[0] !== 'task_completed' &&
|
||||
tag[0] !== 'state_started_at'
|
||||
);
|
||||
|
||||
// Build stats update with decayed values
|
||||
const statsUpdate: Record<string, string> = {
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
hunger: decayResult.stats.hunger.toString(),
|
||||
energy: decayResult.stats.energy.toString(),
|
||||
};
|
||||
|
||||
const newTags = updateBlobbiTags(cleanedTags, {
|
||||
...statsUpdate,
|
||||
state: 'active',
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
|
||||
// ─── Clear evolution missions in session store ───
|
||||
const currentMissions = ensureSessionStore(user.pubkey);
|
||||
writeMissionsToStorage(
|
||||
{ ...currentMissions, evolution: [] },
|
||||
user.pubkey,
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Evolution stopped',
|
||||
description: `${name} is no longer evolving. Task progress has been reset.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to stop evolution',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiStageTransition.ts
|
||||
|
||||
/**
|
||||
* Hooks for Blobbi stage transitions (hatch, evolve).
|
||||
*
|
||||
* Both transitions follow the same decay pattern:
|
||||
* 1. Apply accumulated decay from `last_decay_at` to `now`
|
||||
* 2. Use decayed stats as the source of truth for the transition
|
||||
* 3. Publish new event with decayed stats + new stage
|
||||
* 4. Reset `last_decay_at` to current timestamp
|
||||
*
|
||||
* @see docs/blobbi/decay-system.md
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
STAT_MAX,
|
||||
updateBlobbiTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { validateAndRepairBlobbiTags } from '@/blobbi/core/lib/blobbi-tag-schema';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
|
||||
// ─── Content Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate the content string for a Blobbi at a given stage.
|
||||
* Format: "{name} is a {stage} Blobbi."
|
||||
*
|
||||
* Uses correct grammar: "an egg" vs "a baby/adult"
|
||||
*/
|
||||
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
|
||||
const article = stage === 'egg' ? 'an' : 'a';
|
||||
return `${name} is ${article} ${stage} Blobbi.`;
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of ensuring canonical companion before action.
|
||||
* This is the same interface used by useBlobbiUseInventoryItem.
|
||||
*/
|
||||
export interface CanonicalActionResult {
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
/** Latest profile tags after migration */
|
||||
profileAllTags: string[][];
|
||||
/** Latest profile storage after migration */
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for stage transition hooks.
|
||||
*/
|
||||
export interface UseBlobbiStageTransitionParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called to ensure companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a stage transition.
|
||||
*/
|
||||
export interface StageTransitionResult {
|
||||
/** Previous stage before transition */
|
||||
previousStage: BlobbiStage;
|
||||
/** New stage after transition */
|
||||
newStage: BlobbiStage;
|
||||
/** The Blobbi's name */
|
||||
name: string;
|
||||
/** Stats after decay was applied (before any transition bonuses) */
|
||||
decayedStats: {
|
||||
hunger: number;
|
||||
happiness: number;
|
||||
health: number;
|
||||
hygiene: number;
|
||||
energy: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Hatch Hook ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to hatch an egg into a baby Blobbi.
|
||||
*
|
||||
* Transition: egg -> baby
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in egg stage
|
||||
* - Applies accumulated decay before transition
|
||||
* - Resets stats to healthy baby defaults (inherits health from egg)
|
||||
* - Sets last_decay_at to current timestamp
|
||||
*/
|
||||
export function useBlobbiHatch({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StageTransitionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to hatch');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'egg') {
|
||||
throw new Error('Only eggs can be hatched');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for hatching');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// Per decay-system.md: Always apply accumulated decay from persisted state
|
||||
// before any stage transition.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Calculate Baby Stats ───
|
||||
// All stats reset to 100 when hatching — the baby starts fresh
|
||||
const babyStats = {
|
||||
hunger: STAT_MAX,
|
||||
happiness: STAT_MAX,
|
||||
health: STAT_MAX,
|
||||
hygiene: STAT_MAX,
|
||||
energy: STAT_MAX,
|
||||
};
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
|
||||
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Build the updated tags using the central merge function
|
||||
// Get streak updates (hatching counts as care activity!)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
const mergedTags = updateBlobbiTags(canonical.allTags, {
|
||||
stage: 'baby',
|
||||
state: 'active', // Newly hatched babies are awake
|
||||
hunger: babyStats.hunger.toString(),
|
||||
happiness: babyStats.happiness.toString(),
|
||||
health: babyStats.health.toString(),
|
||||
hygiene: babyStats.hygiene.toString(),
|
||||
energy: babyStats.energy.toString(),
|
||||
...streakUpdates,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Validate and Repair Tags ───
|
||||
// Use the tag integrity guard to ensure all persistent tags are preserved
|
||||
// and task-related tags are properly cleaned up for stage transitions
|
||||
const repairResult = validateAndRepairBlobbiTags(
|
||||
mergedTags,
|
||||
canonical.allTags,
|
||||
{ cleanupTaskTags: true }
|
||||
);
|
||||
|
||||
if (repairResult.errors.length > 0) {
|
||||
console.error('[Hatch] Tag validation errors:', repairResult.errors);
|
||||
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
if (repairResult.repaired && import.meta.env.DEV) {
|
||||
console.log('[Hatch] Tag repairs applied:', repairResult.repairs);
|
||||
}
|
||||
|
||||
// ─── Auto-start evolution for newly hatched babies ───
|
||||
// Applied AFTER tag validation because cleanupTaskTags repairs
|
||||
// task-process states to 'active'. We intentionally set 'evolving'
|
||||
// here so the baby starts its evolution journey immediately.
|
||||
const newTags = updateBlobbiTags(repairResult.tags, {
|
||||
state: 'evolving',
|
||||
state_started_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Generate New Content for Baby Stage ───
|
||||
// CRITICAL: Content must reflect the new stage
|
||||
const newContent = generateBlobbiContent(canonical.companion.name, 'baby');
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: newContent,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
|
||||
return {
|
||||
previousStage: 'egg',
|
||||
newStage: 'baby',
|
||||
name: canonical.companion.name,
|
||||
decayedStats: decayResult.stats,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Your egg hatched!',
|
||||
description: `${name} is now a baby Blobbi! Take good care of them.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to hatch',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Evolve Hook ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to evolve a baby Blobbi into an adult.
|
||||
*
|
||||
* Transition: baby -> adult
|
||||
*
|
||||
* Requirements:
|
||||
* - Blobbi must be in baby stage
|
||||
* - Applies accumulated decay before transition
|
||||
* - Preserves all stats (decay already applied)
|
||||
* - Sets last_decay_at to current timestamp
|
||||
*/
|
||||
export function useBlobbiEvolve({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<StageTransitionResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to evolve');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
if (companion.stage !== 'baby') {
|
||||
if (companion.stage === 'egg') {
|
||||
throw new Error('Eggs must hatch before they can evolve');
|
||||
}
|
||||
if (companion.stage === 'adult') {
|
||||
throw new Error('This Blobbi is already fully evolved');
|
||||
}
|
||||
throw new Error('Only baby Blobbis can evolve');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for evolution');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// Per decay-system.md: Always apply accumulated decay from persisted state
|
||||
// before any stage transition.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// ─── Adult Stats ───
|
||||
// Adult inherits all decayed stats from baby
|
||||
// No stat reset - evolution preserves current condition
|
||||
const adultStats = decayResult.stats;
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
|
||||
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Get streak updates (evolving counts as care activity!)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// Build the updated tags using the central merge function
|
||||
const mergedTags = updateBlobbiTags(canonical.allTags, {
|
||||
stage: 'adult',
|
||||
state: 'active', // Evolution completes with active state
|
||||
hunger: adultStats.hunger.toString(),
|
||||
happiness: adultStats.happiness.toString(),
|
||||
health: adultStats.health.toString(),
|
||||
hygiene: adultStats.hygiene.toString(),
|
||||
energy: adultStats.energy.toString(),
|
||||
...streakUpdates,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
// ─── Validate and Repair Tags ───
|
||||
// Use the tag integrity guard to ensure all persistent tags are preserved
|
||||
// and task-related tags are properly cleaned up for stage transitions
|
||||
const repairResult = validateAndRepairBlobbiTags(
|
||||
mergedTags,
|
||||
canonical.allTags,
|
||||
{ cleanupTaskTags: true }
|
||||
);
|
||||
|
||||
if (repairResult.errors.length > 0) {
|
||||
console.error('[Evolve] Tag validation errors:', repairResult.errors);
|
||||
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
if (repairResult.repaired && import.meta.env.DEV) {
|
||||
console.log('[Evolve] Tag repairs applied:', repairResult.repairs);
|
||||
}
|
||||
|
||||
const newTags = repairResult.tags;
|
||||
|
||||
// ─── Generate New Content for Adult Stage ───
|
||||
// CRITICAL: Content must reflect the new stage
|
||||
const newContent = generateBlobbiContent(canonical.companion.name, 'adult');
|
||||
|
||||
// ─── Publish Event ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: newContent,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
|
||||
return {
|
||||
previousStage: 'baby',
|
||||
newStage: 'adult',
|
||||
name: canonical.companion.name,
|
||||
decayedStats: decayResult.stats,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ name }) => {
|
||||
toast({
|
||||
title: 'Evolution complete!',
|
||||
description: `${name} has evolved into an adult Blobbi!`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to evolve',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
// src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
canUseAction,
|
||||
getStageRestrictionMessage,
|
||||
clampStat,
|
||||
applyStat,
|
||||
hasMedicineEffectForEgg,
|
||||
hasHygieneEffectForEgg,
|
||||
type InventoryAction,
|
||||
ACTION_METADATA,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '../lib/daily-mission-tracker';
|
||||
import type { DailyMissionAction } from '../lib/daily-missions';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
|
||||
|
||||
/**
|
||||
* Request payload for using an item on a Blobbi companion
|
||||
*/
|
||||
export interface UseItemRequest {
|
||||
itemId: string;
|
||||
action: InventoryAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of using an item on a Blobbi companion
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
itemName: string;
|
||||
action: InventoryAction;
|
||||
statsChanged: Record<string, number>;
|
||||
xpGained: number;
|
||||
newXP: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the useBlobbiUseInventoryItem hook
|
||||
*/
|
||||
export interface UseBlobbiUseInventoryItemParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called after ensuring companion is canonical (from migration helper) */
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
/** Latest profile tags after migration */
|
||||
profileAllTags: string[][];
|
||||
/** Latest profile storage after migration */
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Update profile event in local cache */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
}
|
||||
|
||||
// Import NostrEvent type
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Hook to use an item on a Blobbi companion.
|
||||
*
|
||||
* Items are reusable abilities sourced from the shop catalog — no
|
||||
* inventory ownership or quantity is required.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Validates the companion and item compatibility
|
||||
* 2. Ensures canonical format before action
|
||||
* 3. Applies accumulated decay, then item effects to Blobbi stats
|
||||
* 4. Updates Blobbi state (kind 31124)
|
||||
*/
|
||||
export function useBlobbiUseInventoryItem({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
updateProfileEvent: _updateProfileEvent,
|
||||
}: UseBlobbiUseInventoryItemParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to use items');
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
if (!canUseAction(companion, action)) {
|
||||
const message = getStageRestrictionMessage(companion, action);
|
||||
throw new Error(message ?? 'This companion cannot use this item');
|
||||
}
|
||||
|
||||
// Validate item exists in shop catalog
|
||||
const shopItem = getShopItemById(itemId);
|
||||
if (!shopItem) {
|
||||
throw new Error('Item not found in catalog');
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
}
|
||||
|
||||
// For eggs, validate that items have applicable effects
|
||||
const isEgg = companion.stage === 'egg';
|
||||
if (isEgg && action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
|
||||
throw new Error('This medicine has no effect on eggs');
|
||||
}
|
||||
if (isEgg && action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect)) {
|
||||
throw new Error('This item has no cleaning effect on eggs');
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical Before Action ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
throw new Error('Failed to prepare companion for action');
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// Per decay-system.md: Always apply accumulated decay from persisted state
|
||||
// before any user interaction updates stats.
|
||||
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// Start with decayed stats as the base
|
||||
const statsAfterDecay = decayResult.stats;
|
||||
|
||||
// ─── Validate Play Energy Requirements ───
|
||||
// For play actions, validate the Blobbi has enough energy AFTER decay
|
||||
if (action === 'play') {
|
||||
const energyCost = Math.abs(shopItem.effect.energy ?? 0);
|
||||
const currentEnergy = statsAfterDecay.energy;
|
||||
|
||||
if (energyCost > 0 && currentEnergy < energyCost) {
|
||||
throw new Error(
|
||||
`Your Blobbi needs at least ${energyCost} energy to play with this toy (current: ${currentEnergy})`
|
||||
);
|
||||
}
|
||||
|
||||
// Also check if playing would have any effect at all
|
||||
// If happiness is maxed AND we can't spend energy, playing is pointless
|
||||
const happinessGain = shopItem.effect.happiness ?? 0;
|
||||
const currentHappiness = statsAfterDecay.happiness;
|
||||
const wouldGainHappiness = happinessGain > 0 && currentHappiness < 100;
|
||||
const wouldSpendEnergy = energyCost > 0 && currentEnergy >= energyCost;
|
||||
|
||||
if (!wouldGainHappiness && !wouldSpendEnergy) {
|
||||
throw new Error(
|
||||
'Playing would have no effect - your Blobbi is already at maximum happiness and has no energy to spend'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Apply Item Effects (single use) ───
|
||||
const isEggCompanion = canonical.companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {};
|
||||
const statsChanged: Record<string, number> = {};
|
||||
|
||||
if (isEggCompanion && action === 'medicine') {
|
||||
const healthDelta = shopItem.effect.health ?? 0;
|
||||
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
|
||||
|
||||
statsUpdate.health = currentHealth.toString();
|
||||
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
|
||||
|
||||
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
|
||||
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else if (isEggCompanion && action === 'clean') {
|
||||
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
|
||||
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
|
||||
|
||||
statsUpdate.hygiene = currentHygiene.toString();
|
||||
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
|
||||
|
||||
statsUpdate.happiness = currentHappiness.toString();
|
||||
const totalHappinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
|
||||
if (totalHappinessChange !== 0) {
|
||||
statsChanged.happiness = totalHappinessChange;
|
||||
}
|
||||
|
||||
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
// Normal stats application for baby/adult — apply once
|
||||
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
|
||||
|
||||
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
|
||||
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
|
||||
|
||||
statsUpdate.happiness = clampStat(currentStats.happiness).toString();
|
||||
statsChanged.happiness = (currentStats.happiness ?? 0) - (statsAfterDecay.happiness ?? 0);
|
||||
|
||||
statsUpdate.energy = clampStat(currentStats.energy).toString();
|
||||
statsChanged.energy = (currentStats.energy ?? 0) - (statsAfterDecay.energy ?? 0);
|
||||
|
||||
statsUpdate.hygiene = clampStat(currentStats.hygiene).toString();
|
||||
statsChanged.hygiene = (currentStats.hygiene ?? 0) - (statsAfterDecay.hygiene ?? 0);
|
||||
|
||||
statsUpdate.health = clampStat(currentStats.health).toString();
|
||||
statsChanged.health = (currentStats.health ?? 0) - (statsAfterDecay.health ?? 0);
|
||||
}
|
||||
|
||||
// ─── Update Blobbi State Event (kind 31124) ───
|
||||
const nowStr = now.toString();
|
||||
|
||||
// If incubating or evolving, increment the interaction counter in evolution missions
|
||||
const companionState = canonical.companion.state;
|
||||
const updatedTags = canonical.allTags;
|
||||
if (companionState === 'incubating' || companionState === 'evolving') {
|
||||
trackEvolutionMissionTally('interactions', 1, user?.pubkey);
|
||||
}
|
||||
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// ─── Apply XP Gain ───
|
||||
const xpGained = calculateInventoryActionXP(action, 1);
|
||||
const currentXP = canonical.companion.experience ?? 0;
|
||||
const newXP = applyXPGain(currentXP, xpGained);
|
||||
|
||||
const blobbiTags = updateBlobbiTags(updatedTags, {
|
||||
...statsUpdate,
|
||||
...streakUpdates,
|
||||
experience: newXP.toString(),
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const blobbiEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: blobbiTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// Items are free to use — no storage decrement needed.
|
||||
// No query invalidation needed — the optimistic update above keeps the
|
||||
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
|
||||
// before every mutation (read-modify-write pattern).
|
||||
|
||||
return {
|
||||
itemName: shopItem.name,
|
||||
action,
|
||||
statsChanged,
|
||||
xpGained,
|
||||
newXP,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ itemName, action, xpGained }) => {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const xpText = formatXPGain(xpGained);
|
||||
toast({
|
||||
title: `${actionMeta.label} successful!`,
|
||||
description: `Used ${itemName} on your Blobbi. ${xpText}`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
// 'interact' is always tracked, plus the specific action if it maps to a daily mission
|
||||
const dailyActions: DailyMissionAction[] = ['interact'];
|
||||
if (action === 'feed') dailyActions.push('feed');
|
||||
if (action === 'clean') dailyActions.push('clean');
|
||||
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to use item',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* useAwardDailyXp - Award XP for completed daily missions
|
||||
*
|
||||
* Completion is implicit (derived from progress vs target).
|
||||
* This hook calculates the total XP earned today and persists
|
||||
* the updated XP total to kind 11125 tags.
|
||||
*
|
||||
* Uses fetchFreshEvent to avoid stale-read overwrites when
|
||||
* multiple mutations race (e.g. item use XP + daily XP).
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbonautTags,
|
||||
parseBlobbonautEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { buildXpTagUpdates } from '@/blobbi/core/lib/progression';
|
||||
import { serializeProfileContent } from '@/blobbi/core/lib/missions';
|
||||
import type { MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
import { totalDailyXp } from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AwardDailyXpRequest {
|
||||
/** Current missions state to calculate XP from */
|
||||
missions: MissionsContent;
|
||||
}
|
||||
|
||||
export interface AwardDailyXpResult {
|
||||
xpAwarded: number;
|
||||
newTotalXp: number;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to award XP for completed daily missions.
|
||||
*
|
||||
* @param updateProfileEvent - Callback to update profile in query cache
|
||||
*/
|
||||
export function useAwardDailyXp(
|
||||
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void,
|
||||
) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ missions }: AwardDailyXpRequest): Promise<AwardDailyXpResult> => {
|
||||
if (!user?.pubkey) throw new Error('Must be logged in');
|
||||
|
||||
const xpToAward = totalDailyXp(missions);
|
||||
if (xpToAward <= 0) return { xpAwarded: 0, newTotalXp: 0 };
|
||||
|
||||
// Fetch fresh profile from relays to avoid stale-read overwrites
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [KIND_BLOBBONAUT_PROFILE],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
|
||||
const freshProfile = prev ? parseBlobbonautEvent(prev) : undefined;
|
||||
const currentXp = freshProfile?.xp ?? 0;
|
||||
const newTotalXp = currentXp + xpToAward;
|
||||
|
||||
// Update XP and level tags on the fresh event's tags
|
||||
const updatedTags = updateBlobbonautTags(
|
||||
prev?.tags ?? [],
|
||||
buildXpTagUpdates(newTotalXp),
|
||||
);
|
||||
|
||||
// Persist missions state to content field
|
||||
const content = serializeProfileContent(
|
||||
prev?.content ?? '',
|
||||
{ missions },
|
||||
);
|
||||
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content,
|
||||
tags: updatedTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
updateProfileEvent(event);
|
||||
|
||||
return { xpAwarded: xpToAward, newTotalXp };
|
||||
},
|
||||
onSuccess: ({ xpAwarded }) => {
|
||||
if (user?.pubkey) {
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
|
||||
}
|
||||
if (xpAwarded > 0) {
|
||||
toast({
|
||||
title: 'XP Earned!',
|
||||
description: `You earned ${xpAwarded} XP from daily missions.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to Award XP',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy export name for backward compatibility during migration
|
||||
export const useClaimMissionReward = useAwardDailyXp;
|
||||
export type ClaimMissionRequest = AwardDailyXpRequest;
|
||||
export type ClaimMissionResult = AwardDailyXpResult;
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* useDailyMissions - Hook for reading daily mission state
|
||||
*
|
||||
* Provides reactive access to the current day's missions.
|
||||
* Progress tracking is done via the tracker module (non-React).
|
||||
* Completion is implicit (derived from count/events vs target).
|
||||
* XP is awarded automatically when missions complete.
|
||||
*
|
||||
* State lives in a pubkey-scoped in-memory Map. On mount or account
|
||||
* switch, hydrates from kind 11125 content JSON if the session store
|
||||
* is empty. Completed missions are persisted by `useAwardDailyXp`;
|
||||
* intermediate progress resets on page refresh.
|
||||
*/
|
||||
|
||||
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
import type { MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
import { isMissionComplete, missionProgress } from '@/blobbi/core/lib/missions';
|
||||
import { parseProfileContent } from '@/blobbi/core/lib/missions';
|
||||
|
||||
import {
|
||||
type BlobbiStage,
|
||||
type DailyMissionAction,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsContent,
|
||||
areAllDailyComplete,
|
||||
totalDailyXp,
|
||||
getDefinition,
|
||||
MAX_DAILY_REROLLS,
|
||||
DAILY_BONUS_XP,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
import {
|
||||
readMissionsFromStorage,
|
||||
writeMissionsToStorage,
|
||||
hydrateFromPersisted,
|
||||
} from '../lib/daily-mission-tracker';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DailyMissionView {
|
||||
/** Mission ID (matches pool definition) */
|
||||
id: string;
|
||||
/** Display title */
|
||||
title: string;
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Action type */
|
||||
action: DailyMissionAction;
|
||||
/** Required count */
|
||||
target: number;
|
||||
/** Current progress */
|
||||
progress: number;
|
||||
/** Whether mission is complete */
|
||||
complete: boolean;
|
||||
/** XP reward */
|
||||
xp: number;
|
||||
}
|
||||
|
||||
export interface UseDailyMissionsOptions {
|
||||
/** Available Blobbi stages the user has (filters eligible missions) */
|
||||
availableStages?: BlobbiStage[];
|
||||
/**
|
||||
* Raw content string from the kind 11125 profile event.
|
||||
* Pass `profile.content` here. The hook parses it to extract
|
||||
* persisted missions and hydrates the session store on first load.
|
||||
*/
|
||||
profileContent?: string;
|
||||
}
|
||||
|
||||
export interface UseDailyMissionsResult {
|
||||
/** Today's daily missions with computed progress */
|
||||
missions: DailyMissionView[];
|
||||
/** The raw missions content (for persistence/mutation hooks) */
|
||||
raw: MissionsContent | undefined;
|
||||
/** Whether all daily missions are complete */
|
||||
allComplete: boolean;
|
||||
/** Total XP earned today (completed missions + bonus) */
|
||||
todayXp: number;
|
||||
/** Whether the daily bonus is unlocked (all missions complete) */
|
||||
bonusUnlocked: boolean;
|
||||
/** Bonus XP amount */
|
||||
bonusXp: number;
|
||||
/** Whether user has no eligible missions */
|
||||
noMissionsAvailable: boolean;
|
||||
/** Rerolls remaining today */
|
||||
rerollsRemaining: number;
|
||||
/** Max rerolls per day */
|
||||
maxRerolls: number;
|
||||
/** Force refresh missions (testing) */
|
||||
forceReset: () => void;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
|
||||
const { availableStages, profileContent } = options;
|
||||
const { user } = useCurrentUser();
|
||||
const pubkey = user?.pubkey;
|
||||
|
||||
// Version counter to trigger re-reads from session store
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// Track whether we've hydrated for this pubkey
|
||||
const hydratedRef = useRef<string | null>(null);
|
||||
|
||||
// Hydrate session store from kind 11125 content on mount / account switch
|
||||
useEffect(() => {
|
||||
if (!pubkey || !profileContent) return;
|
||||
if (hydratedRef.current === pubkey) return; // already hydrated this session
|
||||
|
||||
// Check if session store already has data for this pubkey
|
||||
const existing = readMissionsFromStorage(pubkey);
|
||||
if (existing) {
|
||||
hydratedRef.current = pubkey;
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse persisted missions from profile content
|
||||
const parsed = parseProfileContent(profileContent);
|
||||
if (parsed.missions && !needsDailyReset(parsed.missions)) {
|
||||
// Daily missions are still current — hydrate the full object
|
||||
hydrateFromPersisted(parsed.missions, pubkey);
|
||||
} else if (parsed.missions?.evolution?.length) {
|
||||
// Daily missions need a reset, but evolution missions survive across days.
|
||||
// Seed the store with fresh dailies + persisted evolution so the raw memo
|
||||
// picks them up instead of creating missions with evolution: [].
|
||||
const fresh = createDailyMissionsContent(
|
||||
getTodayDateString(),
|
||||
parsed.missions.evolution,
|
||||
pubkey,
|
||||
availableStages,
|
||||
);
|
||||
writeMissionsToStorage(fresh, pubkey);
|
||||
}
|
||||
hydratedRef.current = pubkey;
|
||||
setVersion((v) => v + 1);
|
||||
}, [pubkey, profileContent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Listen for tracker events
|
||||
useEffect(() => {
|
||||
const handler = () => setVersion((v) => v + 1);
|
||||
window.addEventListener('daily-missions-updated', handler);
|
||||
return () => window.removeEventListener('daily-missions-updated', handler);
|
||||
}, []);
|
||||
|
||||
// Stable stages key for deps
|
||||
const stagesKey = availableStages?.sort().join(',') ?? '';
|
||||
|
||||
// Read and ensure current state.
|
||||
// CRITICAL: Don't create a fresh store entry until hydration is complete.
|
||||
// Creating one prematurely would overwrite persisted evolution missions
|
||||
// because `hydrateFromPersisted` no-ops when the store already has data.
|
||||
const hydrated = hydratedRef.current === pubkey;
|
||||
const raw = useMemo((): MissionsContent | undefined => {
|
||||
const stored = readMissionsFromStorage(pubkey);
|
||||
|
||||
if (!needsDailyReset(stored)) return stored;
|
||||
|
||||
// If the store is empty and we haven't hydrated yet, wait for the
|
||||
// hydration effect to seed persisted data before creating fresh missions.
|
||||
if (!stored && !hydrated) return undefined;
|
||||
|
||||
// Reset for new day, preserve evolution missions
|
||||
const fresh = createDailyMissionsContent(
|
||||
getTodayDateString(),
|
||||
stored?.evolution ?? [],
|
||||
pubkey,
|
||||
availableStages,
|
||||
);
|
||||
writeMissionsToStorage(fresh, pubkey);
|
||||
return fresh;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [version, pubkey, stagesKey, hydrated]);
|
||||
|
||||
// Build view models
|
||||
const missions: DailyMissionView[] = useMemo(() => {
|
||||
if (!raw?.daily) return [];
|
||||
return raw.daily.map((m) => {
|
||||
const def = getDefinition(m.id);
|
||||
return {
|
||||
id: m.id,
|
||||
title: def?.title ?? m.id,
|
||||
description: def?.description ?? '',
|
||||
action: def?.action ?? 'interact',
|
||||
target: m.target,
|
||||
progress: missionProgress(m),
|
||||
complete: isMissionComplete(m),
|
||||
xp: def?.xp ?? 0,
|
||||
};
|
||||
});
|
||||
}, [raw]);
|
||||
|
||||
const allComplete = raw ? areAllDailyComplete(raw) : false;
|
||||
const todayXp = raw ? totalDailyXp(raw) : 0;
|
||||
const bonusUnlocked = allComplete;
|
||||
const noMissionsAvailable = missions.length === 0;
|
||||
const rerollsRemaining = raw?.rerolls ?? MAX_DAILY_REROLLS;
|
||||
|
||||
const forceReset = useCallback(() => {
|
||||
const fresh = createDailyMissionsContent(
|
||||
getTodayDateString(),
|
||||
raw?.evolution ?? [],
|
||||
pubkey,
|
||||
availableStages,
|
||||
);
|
||||
writeMissionsToStorage(fresh, pubkey);
|
||||
setVersion((v) => v + 1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pubkey, stagesKey, raw?.evolution]);
|
||||
|
||||
return {
|
||||
missions,
|
||||
raw,
|
||||
allComplete,
|
||||
todayXp,
|
||||
bonusUnlocked,
|
||||
bonusXp: DAILY_BONUS_XP,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
maxRerolls: MAX_DAILY_REROLLS,
|
||||
forceReset,
|
||||
};
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
// src/blobbi/actions/hooks/useEvolveTasks.ts
|
||||
|
||||
/**
|
||||
* Hook to compute evolve task progress.
|
||||
*
|
||||
* Progress is stored in `MissionsContent.evolution[]` on kind 11125.
|
||||
* - Interactions: TallyMission tracked via `trackEvolutionMissionTally`
|
||||
* - Event-based tasks: EventMission, backfilled from retroactive Nostr queries
|
||||
* - Dynamic task (maintain_stats): computed from current companion stats, NEVER stored
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
import { missionProgress, isEventMission } from '@/blobbi/core/lib/missions';
|
||||
import { trackEvolutionMissionEvent, readMissionsFromStorage, ensureSessionStore, writeMissionsToStorage } from '../lib/daily-mission-tracker';
|
||||
import {
|
||||
EVOLVE_MISSIONS,
|
||||
EVOLVE_REQUIRED_INTERACTIONS,
|
||||
EVOLVE_REQUIRED_THEMES,
|
||||
EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
EVOLVE_STAT_THRESHOLD,
|
||||
findEvolutionMission,
|
||||
createEvolveMissions,
|
||||
} from '../lib/evolution-missions';
|
||||
|
||||
import {
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
KIND_PROFILE_METADATA,
|
||||
type HatchTask,
|
||||
type TaskType,
|
||||
} from './useHatchTasks';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Kind for custom profile tabs event */
|
||||
export const KIND_PROFILE_TABS = 16769;
|
||||
|
||||
// Re-export for backward compat
|
||||
export {
|
||||
EVOLVE_REQUIRED_INTERACTIONS,
|
||||
EVOLVE_REQUIRED_THEMES,
|
||||
EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
EVOLVE_STAT_THRESHOLD,
|
||||
};
|
||||
|
||||
// Re-export task types for convenience
|
||||
export type { HatchTask as EvolveTask, TaskType };
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of computing evolve tasks.
|
||||
*/
|
||||
export interface EvolveTasksResult {
|
||||
tasks: HatchTask[];
|
||||
/** All persistent tasks are complete */
|
||||
persistentTasksComplete: boolean;
|
||||
/** Dynamic stat task is complete */
|
||||
dynamicTaskComplete: boolean;
|
||||
/** All tasks (persistent + dynamic) are complete - required to evolve */
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Refetch task progress */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
// ─── Main Hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to compute evolve task progress from evolution missions + Nostr event backfill.
|
||||
*
|
||||
* @param companion - The Blobbi companion (must be in evolving state)
|
||||
* @param missions - Current MissionsContent from the session store
|
||||
*/
|
||||
export function useEvolveTasks(
|
||||
companion: BlobbiCompanion | null,
|
||||
missions: MissionsContent | undefined,
|
||||
): EvolveTasksResult {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
const isEvolving = companion?.state === 'evolving';
|
||||
const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]);
|
||||
|
||||
// ─── Ensure evolution missions exist in session store ───
|
||||
// Safety net: if the companion is evolving but evolution[] is empty
|
||||
// (e.g. persist didn't fire, hydration lost them), re-populate from
|
||||
// the static definitions so tally tracking works immediately.
|
||||
const ensuredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isEvolving || !pubkey || ensuredRef.current) return;
|
||||
if (evolution.length > 0) { ensuredRef.current = true; return; }
|
||||
|
||||
const store = ensureSessionStore(pubkey);
|
||||
if (store.evolution.length === 0) {
|
||||
writeMissionsToStorage({ ...store, evolution: createEvolveMissions() }, pubkey);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
|
||||
}
|
||||
ensuredRef.current = true;
|
||||
}, [isEvolving, pubkey, evolution]);
|
||||
|
||||
// ─── Retroactive Nostr Queries (discover event IDs to backfill) ───
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['evolve-tasks', pubkey],
|
||||
queryFn: async () => {
|
||||
if (!pubkey) return null;
|
||||
|
||||
const filters: NostrFilter[] = [
|
||||
{ kinds: [KIND_THEME_DEFINITION], authors: [pubkey], limit: EVOLVE_REQUIRED_THEMES },
|
||||
{ kinds: [KIND_COLOR_MOMENT], authors: [pubkey], limit: EVOLVE_REQUIRED_COLOR_MOMENTS },
|
||||
{ kinds: [KIND_PROFILE_TABS], authors: [pubkey], limit: 1 },
|
||||
{ kinds: [KIND_PROFILE_METADATA], authors: [pubkey], limit: 1 },
|
||||
];
|
||||
|
||||
const events = await nostr.query(filters);
|
||||
|
||||
return {
|
||||
themeEvents: events.filter(e => e.kind === KIND_THEME_DEFINITION),
|
||||
colorMomentEvents: events.filter(e => e.kind === KIND_COLOR_MOMENT),
|
||||
profileTabsEvents: events.filter(e => e.kind === KIND_PROFILE_TABS),
|
||||
hasProfileMetadata: events.some(e => e.kind === KIND_PROFILE_METADATA),
|
||||
};
|
||||
},
|
||||
enabled: !!pubkey && isEvolving,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
// ─── Compute event counts directly from Nostr query results ───
|
||||
// These are the authoritative counts for event-based tasks.
|
||||
const queryCounts: Record<string, number> = useMemo(() => {
|
||||
if (!data) return {} as Record<string, number>;
|
||||
return {
|
||||
create_themes: data.themeEvents.length,
|
||||
color_moments: data.colorMomentEvents.length,
|
||||
edit_profile: (data.profileTabsEvents.length >= 1 || data.hasProfileMetadata) ? 1 : 0,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// ─── Backfill event IDs into evolution missions (for persistence only) ───
|
||||
const lastBackfilledDataRef = useRef<typeof data>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !pubkey || evolution.length === 0) return;
|
||||
if (data === lastBackfilledDataRef.current) return;
|
||||
lastBackfilledDataRef.current = data;
|
||||
|
||||
const current = readMissionsFromStorage(pubkey);
|
||||
if (!current || current.evolution.length === 0) return;
|
||||
const evo = current.evolution;
|
||||
|
||||
for (const event of data.themeEvents) {
|
||||
const m = findEvolutionMission(evo, 'create_themes');
|
||||
if (m && isEventMission(m) && !m.events.includes(event.id)) {
|
||||
trackEvolutionMissionEvent('create_themes', event.id, pubkey);
|
||||
}
|
||||
}
|
||||
for (const event of data.colorMomentEvents) {
|
||||
const m = findEvolutionMission(evo, 'color_moments');
|
||||
if (m && isEventMission(m) && !m.events.includes(event.id)) {
|
||||
trackEvolutionMissionEvent('color_moments', event.id, pubkey);
|
||||
}
|
||||
}
|
||||
const profileEditEvents = [
|
||||
...data.profileTabsEvents,
|
||||
...(data.hasProfileMetadata ? [{ id: 'profile-metadata' }] : []),
|
||||
];
|
||||
for (const event of profileEditEvents) {
|
||||
const m = findEvolutionMission(evo, 'edit_profile');
|
||||
if (m && isEventMission(m) && !m.events.includes(event.id)) {
|
||||
trackEvolutionMissionEvent('edit_profile', event.id, pubkey);
|
||||
}
|
||||
}
|
||||
}, [data, pubkey, evolution]);
|
||||
|
||||
// ─── Build task view models ───
|
||||
// For event-based tasks, use the MAX of the Nostr query count and the
|
||||
// evolution mission progress. The query is authoritative but the mission
|
||||
// store may have progress from a previous session that hasn't been
|
||||
// re-queried yet.
|
||||
const tasks: HatchTask[] = EVOLVE_MISSIONS.map((def) => {
|
||||
const mission = findEvolutionMission(evolution, def.id);
|
||||
const missionCount = mission ? missionProgress(mission) : 0;
|
||||
const queryCount = queryCounts[def.id] ?? 0;
|
||||
const current = Math.max(missionCount, queryCount);
|
||||
const completed = current >= def.target;
|
||||
|
||||
return {
|
||||
id: def.id,
|
||||
name: def.title,
|
||||
description: def.description,
|
||||
current: Math.min(current, def.target),
|
||||
required: def.target,
|
||||
completed,
|
||||
type: 'persistent' as TaskType,
|
||||
action: def.action,
|
||||
actionTarget: def.actionTarget,
|
||||
actionLabel: def.actionLabel,
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Dynamic Task: Maintain All Stats >= 80 ───
|
||||
const stats = companion?.stats ?? {};
|
||||
const hunger = stats.hunger ?? 0;
|
||||
const happiness = stats.happiness ?? 0;
|
||||
const health = stats.health ?? 0;
|
||||
const hygiene = stats.hygiene ?? 0;
|
||||
const energy = stats.energy ?? 0;
|
||||
|
||||
const statsOk =
|
||||
hunger >= EVOLVE_STAT_THRESHOLD &&
|
||||
happiness >= EVOLVE_STAT_THRESHOLD &&
|
||||
health >= EVOLVE_STAT_THRESHOLD &&
|
||||
hygiene >= EVOLVE_STAT_THRESHOLD &&
|
||||
energy >= EVOLVE_STAT_THRESHOLD;
|
||||
|
||||
const minStat = Math.min(hunger, happiness, health, hygiene, energy);
|
||||
|
||||
tasks.push({
|
||||
id: 'maintain_stats',
|
||||
name: 'Peak Condition',
|
||||
description: `Keep all stats above ${EVOLVE_STAT_THRESHOLD}`,
|
||||
current: statsOk ? EVOLVE_STAT_THRESHOLD : minStat,
|
||||
required: EVOLVE_STAT_THRESHOLD,
|
||||
completed: statsOk,
|
||||
type: 'dynamic',
|
||||
});
|
||||
|
||||
// ─── Completion ───
|
||||
const persistentTasks = tasks.filter(t => t.type === 'persistent');
|
||||
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
|
||||
|
||||
const persistentTasksComplete = persistentTasks.every(t => t.completed);
|
||||
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
|
||||
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
|
||||
|
||||
return {
|
||||
tasks,
|
||||
persistentTasksComplete,
|
||||
dynamicTaskComplete,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
error: error as Error | null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
// src/blobbi/actions/hooks/useHatchTasks.ts
|
||||
|
||||
/**
|
||||
* Hook to compute hatch task progress.
|
||||
*
|
||||
* Progress is stored in `MissionsContent.evolution[]` on kind 11125.
|
||||
* - Interactions: TallyMission tracked via `trackEvolutionMissionTally`
|
||||
* - Event-based tasks: EventMission, backfilled from retroactive Nostr queries
|
||||
*
|
||||
* The Nostr queries discover event IDs that satisfy event-based tasks and
|
||||
* feed them into the evolution tracker. The evolution array is the source of
|
||||
* truth for completion state.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
import { missionProgress, isEventMission } from '@/blobbi/core/lib/missions';
|
||||
import { trackEvolutionMissionEvent, readMissionsFromStorage, ensureSessionStore, writeMissionsToStorage } from '../lib/daily-mission-tracker';
|
||||
import {
|
||||
HATCH_MISSIONS,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
findEvolutionMission,
|
||||
createHatchMissions,
|
||||
} from '../lib/evolution-missions';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Kind for theme definition events */
|
||||
export const KIND_THEME_DEFINITION = 36767;
|
||||
/** Kind for color moment events (espy.you) */
|
||||
export const KIND_COLOR_MOMENT = 3367;
|
||||
/** Kind for profile metadata */
|
||||
export const KIND_PROFILE_METADATA = 0;
|
||||
/** Kind for short text notes */
|
||||
export const KIND_SHORT_TEXT_NOTE = 1;
|
||||
|
||||
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
|
||||
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
|
||||
|
||||
/** Prefix text for Blobbi hatch post (the Blobbi name is appended after this) */
|
||||
export const BLOBBI_POST_PREFIX = 'Posting to hatch';
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export { HATCH_REQUIRED_INTERACTIONS };
|
||||
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Task type classification.
|
||||
* - persistent: Based on Nostr events or tallies, stored in evolution[]
|
||||
* - dynamic: Based on current stats, NEVER stored
|
||||
*/
|
||||
export type TaskType = 'persistent' | 'dynamic';
|
||||
|
||||
/**
|
||||
* Individual task view model for the UI.
|
||||
*/
|
||||
export interface HatchTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Current progress value */
|
||||
current: number;
|
||||
/** Required value for completion */
|
||||
required: number;
|
||||
/** Whether the task is complete */
|
||||
completed: boolean;
|
||||
/** Task type - persistent or dynamic */
|
||||
type: TaskType;
|
||||
/** Action to perform (if applicable) */
|
||||
action?: 'navigate' | 'open_modal' | 'external_link';
|
||||
/** Target for the action */
|
||||
actionTarget?: string;
|
||||
/** Button label */
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of computing hatch tasks.
|
||||
*/
|
||||
export interface HatchTasksResult {
|
||||
tasks: HatchTask[];
|
||||
/** All persistent tasks are complete */
|
||||
persistentTasksComplete: boolean;
|
||||
/** Dynamic stat task is complete */
|
||||
dynamicTaskComplete: boolean;
|
||||
/** All tasks (persistent + dynamic) are complete - required to hatch */
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Refetch task progress */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the required phrase for a hatch post.
|
||||
* Format: "Posting to hatch {CapitalizedName} #blobbi"
|
||||
*/
|
||||
export function buildHatchPhrase(blobbiName: string): string {
|
||||
const capitalized = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
return `${BLOBBI_POST_PREFIX} ${capitalized} #blobbi`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a post is a valid Blobbi-related post.
|
||||
*/
|
||||
export function isValidHatchPost(event: NostrEvent): boolean {
|
||||
const hasBlobbiTag = event.tags.some(
|
||||
tag => tag[0] === 't' && tag[1]?.toLowerCase() === 'blobbi',
|
||||
);
|
||||
if (hasBlobbiTag) return true;
|
||||
return /#blobbi\b/i.test(event.content);
|
||||
}
|
||||
|
||||
// ─── Main Hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to compute hatch task progress from evolution missions + Nostr event backfill.
|
||||
*
|
||||
* @param companion - The Blobbi companion (must be incubating)
|
||||
* @param missions - Current MissionsContent from the session store
|
||||
*/
|
||||
export function useHatchTasks(
|
||||
companion: BlobbiCompanion | null,
|
||||
missions: MissionsContent | undefined,
|
||||
): HatchTasksResult {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
const isIncubating = companion?.state === 'incubating';
|
||||
const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]);
|
||||
|
||||
// ─── Ensure evolution missions exist in session store ───
|
||||
// Safety net: if the companion is incubating but evolution[] is empty
|
||||
// (e.g. persist didn't fire, hydration lost them), re-populate from
|
||||
// the static definitions so tally tracking works immediately.
|
||||
const ensuredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isIncubating || !pubkey || ensuredRef.current) return;
|
||||
if (evolution.length > 0) { ensuredRef.current = true; return; }
|
||||
|
||||
const store = ensureSessionStore(pubkey);
|
||||
if (store.evolution.length === 0) {
|
||||
writeMissionsToStorage({ ...store, evolution: createHatchMissions() }, pubkey);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
|
||||
}
|
||||
ensuredRef.current = true;
|
||||
}, [isIncubating, pubkey, evolution]);
|
||||
|
||||
// ─── Retroactive Nostr Queries (discover event IDs to backfill) ───
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['hatch-tasks', pubkey],
|
||||
queryFn: async () => {
|
||||
if (!pubkey) return null;
|
||||
|
||||
const filters: NostrFilter[] = [
|
||||
{ kinds: [KIND_THEME_DEFINITION], authors: [pubkey], limit: 1 },
|
||||
{ kinds: [KIND_COLOR_MOMENT], authors: [pubkey], limit: 1 },
|
||||
{ kinds: [KIND_SHORT_TEXT_NOTE], authors: [pubkey], '#t': ['blobbi'], limit: 1 },
|
||||
];
|
||||
|
||||
const events = await nostr.query(filters);
|
||||
|
||||
return {
|
||||
themeEvents: events.filter(e => e.kind === KIND_THEME_DEFINITION),
|
||||
colorMomentEvents: events.filter(e => e.kind === KIND_COLOR_MOMENT),
|
||||
postEvents: events.filter(e => e.kind === KIND_SHORT_TEXT_NOTE),
|
||||
};
|
||||
},
|
||||
enabled: !!pubkey && isIncubating,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
// ─── Compute event counts directly from Nostr query results ───
|
||||
// These are the authoritative counts for event-based tasks.
|
||||
const queryCounts: Record<string, number> = useMemo(() => {
|
||||
if (!data) return {} as Record<string, number>;
|
||||
const validPosts = data.postEvents.filter(e => isValidHatchPost(e));
|
||||
return {
|
||||
create_theme: data.themeEvents.length,
|
||||
color_moment: data.colorMomentEvents.length,
|
||||
create_post: validPosts.length,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// ─── Backfill event IDs into evolution missions (for persistence only) ───
|
||||
const lastBackfilledDataRef = useRef<typeof data>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !pubkey || evolution.length === 0) return;
|
||||
if (data === lastBackfilledDataRef.current) return;
|
||||
lastBackfilledDataRef.current = data;
|
||||
|
||||
const current = readMissionsFromStorage(pubkey);
|
||||
if (!current || current.evolution.length === 0) return;
|
||||
const evo = current.evolution;
|
||||
|
||||
for (const event of data.themeEvents) {
|
||||
const m = findEvolutionMission(evo, 'create_theme');
|
||||
if (m && isEventMission(m) && !m.events.includes(event.id)) {
|
||||
trackEvolutionMissionEvent('create_theme', event.id, pubkey);
|
||||
}
|
||||
}
|
||||
for (const event of data.colorMomentEvents) {
|
||||
const m = findEvolutionMission(evo, 'color_moment');
|
||||
if (m && isEventMission(m) && !m.events.includes(event.id)) {
|
||||
trackEvolutionMissionEvent('color_moment', event.id, pubkey);
|
||||
}
|
||||
}
|
||||
for (const event of data.postEvents) {
|
||||
if (!isValidHatchPost(event)) continue;
|
||||
const m = findEvolutionMission(evo, 'create_post');
|
||||
if (m && isEventMission(m) && !m.events.includes(event.id)) {
|
||||
trackEvolutionMissionEvent('create_post', event.id, pubkey);
|
||||
}
|
||||
}
|
||||
}, [data, pubkey, evolution]);
|
||||
|
||||
// ─── Build task view models ───
|
||||
// For event-based tasks, use the MAX of the Nostr query count and the
|
||||
// evolution mission progress. The query is authoritative but the mission
|
||||
// store may have progress from a previous session that hasn't been
|
||||
// re-queried yet.
|
||||
const tasks: HatchTask[] = HATCH_MISSIONS.map((def) => {
|
||||
const mission = findEvolutionMission(evolution, def.id);
|
||||
const missionCount = mission ? missionProgress(mission) : 0;
|
||||
const queryCount = queryCounts[def.id] ?? 0;
|
||||
const current = Math.max(missionCount, queryCount);
|
||||
const completed = current >= def.target;
|
||||
|
||||
return {
|
||||
id: def.id,
|
||||
name: def.title,
|
||||
description: def.description,
|
||||
current: Math.min(current, def.target),
|
||||
required: def.target,
|
||||
completed,
|
||||
type: 'persistent' as TaskType,
|
||||
action: def.action,
|
||||
actionTarget: def.actionTarget,
|
||||
actionLabel: def.actionLabel,
|
||||
};
|
||||
});
|
||||
|
||||
const persistentTasksComplete = tasks.every(t => t.completed);
|
||||
const dynamicTaskComplete = true; // No dynamic tasks for hatching
|
||||
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
|
||||
|
||||
return {
|
||||
tasks,
|
||||
persistentTasksComplete,
|
||||
dynamicTaskComplete,
|
||||
allCompleted,
|
||||
isLoading,
|
||||
error: error as Error | null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tasks to only persistent tasks (for tag sync).
|
||||
* CRITICAL: Dynamic tasks must NEVER be synced to tags.
|
||||
*/
|
||||
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
|
||||
return tasks.filter(t => t.type === 'persistent');
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* useItemCooldown — React hook for per-item cooldown state.
|
||||
*
|
||||
* Subscribes to the shared item-cooldown singleton so components
|
||||
* re-render when any item's cooldown starts or expires.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { isOnCooldown } = useItemCooldown();
|
||||
* <Button disabled={isOnCooldown(item.id)}>Use</Button>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import { isItemOnCooldown, subscribeCooldowns } from '../lib/item-cooldown';
|
||||
|
||||
/** Monotonic version counter bumped by the subscription callback. */
|
||||
let snapshotVersion = 0;
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
// subscribeCooldowns returns an unsubscribe function.
|
||||
// The callback bumps the version AND notifies React.
|
||||
return subscribeCooldowns(() => {
|
||||
snapshotVersion++;
|
||||
onStoreChange();
|
||||
});
|
||||
}
|
||||
|
||||
function getSnapshot(): number {
|
||||
return snapshotVersion;
|
||||
}
|
||||
|
||||
export function useItemCooldown() {
|
||||
useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
const isOnCooldown = useCallback((itemId: string): boolean => {
|
||||
return isItemOnCooldown(itemId);
|
||||
}, []);
|
||||
|
||||
return { isOnCooldown };
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* usePersistEvolutionProgress - Debounced persistence for evolution mission progress.
|
||||
*
|
||||
* Evolution missions (hatch/evolve tasks) live in `MissionsContent.evolution[]`
|
||||
* in the in-memory session store. This hook listens for changes and debounce-
|
||||
* publishes the updated state to kind 11125 content JSON so progress survives
|
||||
* page refreshes.
|
||||
*
|
||||
* Design:
|
||||
* - Listens to 'daily-missions-updated' CustomEvent (same event the tracker fires)
|
||||
* - Only acts on events with `detail.evolution === true`
|
||||
* - Debounces by PERSIST_DELAY_MS to batch rapid interactions
|
||||
* - Uses fetchFreshEvent to avoid stale-read overwrites
|
||||
* - Skips publish if evolution[] is empty (no active task process)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { serializeProfileContent } from '@/blobbi/core/lib/missions';
|
||||
import { readMissionsFromStorage } from '../lib/daily-mission-tracker';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Delay before persisting evolution progress (ms). */
|
||||
const PERSIST_DELAY_MS = 5_000;
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param updateProfileEvent - Callback to update profile in query cache
|
||||
*/
|
||||
export function usePersistEvolutionProgress(
|
||||
updateProfileEvent: (event: NostrEvent) => void,
|
||||
): void {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const publishingRef = useRef(false);
|
||||
|
||||
const persist = useCallback(async () => {
|
||||
const pubkey = user?.pubkey;
|
||||
if (!pubkey || publishingRef.current) return;
|
||||
|
||||
const missions = readMissionsFromStorage(pubkey);
|
||||
if (!missions || missions.evolution.length === 0) return;
|
||||
|
||||
publishingRef.current = true;
|
||||
try {
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [KIND_BLOBBONAUT_PROFILE],
|
||||
authors: [pubkey],
|
||||
});
|
||||
|
||||
const content = serializeProfileContent(
|
||||
prev?.content ?? '',
|
||||
{ missions },
|
||||
);
|
||||
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content,
|
||||
tags: prev?.tags ?? [],
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
updateProfileEvent(event);
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', pubkey] });
|
||||
} finally {
|
||||
publishingRef.current = false;
|
||||
}
|
||||
}, [user?.pubkey, nostr, publishEvent, updateProfileEvent, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.evolution) return;
|
||||
|
||||
// Clear any pending timer and restart the debounce
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
persist().catch((err) => {
|
||||
console.warn('[PersistEvolution] Failed to persist:', err);
|
||||
});
|
||||
}, PERSIST_DELAY_MS);
|
||||
};
|
||||
|
||||
window.addEventListener('daily-missions-updated', handler);
|
||||
return () => {
|
||||
window.removeEventListener('daily-missions-updated', handler);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [persist]);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* useRerollMission - Replace a daily mission with a new one from the pool
|
||||
*
|
||||
* Updates the in-memory session store.
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiStage } from '../lib/daily-missions';
|
||||
import { rerollMission, getDefinition } from '../lib/daily-missions';
|
||||
import {
|
||||
readMissionsFromStorage,
|
||||
writeMissionsToStorage,
|
||||
} from '../lib/daily-mission-tracker';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RerollMissionRequest {
|
||||
missionId: string;
|
||||
availableStages?: BlobbiStage[];
|
||||
}
|
||||
|
||||
export interface RerollMissionResult {
|
||||
oldMissionId: string;
|
||||
newMissionId: string;
|
||||
rerollsRemaining: number;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useRerollMission() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ missionId, availableStages }: RerollMissionRequest): Promise<RerollMissionResult> => {
|
||||
if (!user?.pubkey) throw new Error('Must be logged in');
|
||||
|
||||
const current = readMissionsFromStorage(user.pubkey);
|
||||
if (!current) throw new Error('No missions state');
|
||||
|
||||
const updated = rerollMission(current, missionId, availableStages);
|
||||
if (!updated) throw new Error('Cannot reroll this mission');
|
||||
|
||||
writeMissionsToStorage(updated, user.pubkey);
|
||||
|
||||
// Notify React
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, rerolled: true },
|
||||
}));
|
||||
|
||||
// Find the new mission ID at the same index
|
||||
const oldIdx = current.daily.findIndex((m) => m.id === missionId);
|
||||
const newMissionId = updated.daily[oldIdx]?.id ?? missionId;
|
||||
|
||||
return {
|
||||
oldMissionId: missionId,
|
||||
newMissionId,
|
||||
rerollsRemaining: updated.rerolls,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ newMissionId, rerollsRemaining }) => {
|
||||
const def = getDefinition(newMissionId);
|
||||
const rerollText = rerollsRemaining === 0
|
||||
? 'No rerolls left'
|
||||
: `${rerollsRemaining} reroll${rerollsRemaining === 1 ? '' : 's'} left`;
|
||||
|
||||
toast({
|
||||
title: 'Mission Replaced',
|
||||
description: `New mission: ${def?.title ?? newMissionId}. ${rerollText}.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to Reroll',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
// src/blobbi/actions/index.ts
|
||||
|
||||
// Components
|
||||
export { BlobbiActionsModal } from './components/BlobbiActionsModal';
|
||||
export { BlobbiActionInventoryModal } from './components/BlobbiActionInventoryModal';
|
||||
export { PlayMusicModal } from './components/PlayMusicModal';
|
||||
export { SingModal } from './components/SingModal';
|
||||
export { InlineMusicPlayer } from './components/InlineMusicPlayer';
|
||||
export { InlineSingCard } from './components/InlineSingCard';
|
||||
export { HatchTasksPanel } from './components/HatchTasksPanel';
|
||||
export { TasksPanel } from './components/TasksPanel';
|
||||
export { BlobbiPostModal } from './components/BlobbiPostModal';
|
||||
export { StartIncubationDialog } from './components/StartIncubationDialog';
|
||||
export { StartEvolutionDialog } from './components/StartEvolutionDialog';
|
||||
export { BlobbiMissionsModal } from './components/BlobbiMissionsModal';
|
||||
|
||||
// Hooks
|
||||
export { useBlobbiUseInventoryItem } from './hooks/useBlobbiUseInventoryItem';
|
||||
export type { UseItemRequest, UseItemResult, UseBlobbiUseInventoryItemParams } from './hooks/useBlobbiUseInventoryItem';
|
||||
|
||||
export { useBlobbiHatch, useBlobbiEvolve } from './hooks/useBlobbiStageTransition';
|
||||
export type {
|
||||
UseBlobbiStageTransitionParams,
|
||||
StageTransitionResult,
|
||||
CanonicalActionResult,
|
||||
} from './hooks/useBlobbiStageTransition';
|
||||
|
||||
export {
|
||||
useStartIncubation,
|
||||
useStopIncubation,
|
||||
useStartEvolution,
|
||||
useStopEvolution,
|
||||
} from './hooks/useBlobbiIncubation';
|
||||
export type {
|
||||
StartIncubationMode,
|
||||
StartIncubationRequest,
|
||||
UseStartIncubationParams,
|
||||
StartIncubationResult,
|
||||
UseStopIncubationParams,
|
||||
StopIncubationResult,
|
||||
UseStartEvolutionParams,
|
||||
StartEvolutionResult,
|
||||
UseStopEvolutionParams,
|
||||
StopEvolutionResult,
|
||||
} from './hooks/useBlobbiIncubation';
|
||||
|
||||
export { useActiveTaskProcess, filterPersistentTasks as filterPersistentTasksFromProcess, filterDynamicTasks } from './hooks/useActiveTaskProcess';
|
||||
export type { TaskProcessType, TaskProcessConfig, ActiveTaskProcessResult } from './hooks/useActiveTaskProcess';
|
||||
|
||||
export {
|
||||
useHatchTasks,
|
||||
filterPersistentTasks,
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
REQUIRED_INTERACTIONS, // Legacy export
|
||||
BLOBBI_POST_PREFIX,
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
} from './hooks/useHatchTasks';
|
||||
export type { HatchTask, HatchTasksResult, TaskType } from './hooks/useHatchTasks';
|
||||
|
||||
export {
|
||||
useEvolveTasks,
|
||||
KIND_PROFILE_TABS,
|
||||
EVOLVE_REQUIRED_THEMES,
|
||||
EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
EVOLVE_REQUIRED_INTERACTIONS,
|
||||
EVOLVE_STAT_THRESHOLD,
|
||||
} from './hooks/useEvolveTasks';
|
||||
export type { EvolveTasksResult } from './hooks/useEvolveTasks';
|
||||
|
||||
export { useBlobbiDirectAction, DIRECT_ACTION_HAPPINESS_EFFECTS } from './hooks/useBlobbiDirectAction';
|
||||
export type { DirectActionRequest, DirectActionResult, UseBlobbiDirectActionParams } from './hooks/useBlobbiDirectAction';
|
||||
|
||||
export { useAudioPlayback } from './hooks/useAudioPlayback';
|
||||
export type { PlaybackState, PlaybackError, UseAudioPlaybackOptions, UseAudioPlaybackReturn } from './hooks/useAudioPlayback';
|
||||
|
||||
// Track catalog
|
||||
export {
|
||||
BLOBBI_TRACK_CATALOG,
|
||||
getAllTracks,
|
||||
getTrackById,
|
||||
formatTrackDuration,
|
||||
type BlobbiTrack,
|
||||
} from './lib/blobbi-track-catalog';
|
||||
|
||||
// Activity state
|
||||
export {
|
||||
createMusicActivity,
|
||||
createSingActivity,
|
||||
createNoActivity,
|
||||
type InlineActivityType,
|
||||
type InlineActivityState,
|
||||
type MusicActivityState,
|
||||
type SingActivityState,
|
||||
type NoActivityState,
|
||||
type BlobbiReactionState,
|
||||
type SelectedTrack,
|
||||
} from './lib/blobbi-activity-state';
|
||||
|
||||
// Re-export stat bounds from canonical source
|
||||
export { STAT_MIN, STAT_MAX } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
// Types
|
||||
type InventoryAction,
|
||||
type DirectAction,
|
||||
type BlobbiAction,
|
||||
type ResolvedInventoryItem,
|
||||
type EggStatPreview,
|
||||
type ItemUsabilityResult,
|
||||
// Constants
|
||||
ACTION_TO_ITEM_TYPE,
|
||||
ACTION_METADATA,
|
||||
DIRECT_ACTION_METADATA,
|
||||
ALL_ACTION_METADATA,
|
||||
GENERAL_ITEM_USABLE_STAGES,
|
||||
EGG_ALLOWED_ACTIONS,
|
||||
EGG_ALLOWED_INVENTORY_ACTIONS,
|
||||
EGG_ALLOWED_DIRECT_ACTIONS,
|
||||
EGG_VISIBLE_INVENTORY_ACTIONS,
|
||||
EGG_VISIBLE_ACTIONS,
|
||||
SHELL_REPAIR_KIT_ID,
|
||||
// Functions
|
||||
clampStat,
|
||||
applyStat,
|
||||
applyItemEffects,
|
||||
filterInventoryByAction,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
canUseDirectAction,
|
||||
isActionVisibleForStage,
|
||||
canUseInventoryItems,
|
||||
getStageRestrictionMessage,
|
||||
previewStatChanges,
|
||||
previewMedicineForEgg,
|
||||
previewCleanForEgg,
|
||||
hasMedicineEffectForEgg,
|
||||
hasHygieneEffectForEgg,
|
||||
canUseItemForStage,
|
||||
getActionForItem,
|
||||
} from './lib/blobbi-action-utils';
|
||||
|
||||
// Daily Missions
|
||||
export { useDailyMissions } from './hooks/useDailyMissions';
|
||||
export type { DailyMissionView, UseDailyMissionsResult } from './hooks/useDailyMissions';
|
||||
export { useAwardDailyXp, useClaimMissionReward } from './hooks/useClaimMissionReward';
|
||||
export { usePersistEvolutionProgress } from './hooks/usePersistEvolutionProgress';
|
||||
export type { AwardDailyXpRequest, AwardDailyXpResult, ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
|
||||
export { useRerollMission } from './hooks/useRerollMission';
|
||||
export type { RerollMissionRequest, RerollMissionResult } from './hooks/useRerollMission';
|
||||
export {
|
||||
trackDailyMissionProgress,
|
||||
trackDailyMissionEvent,
|
||||
trackMultipleDailyMissionActions,
|
||||
} from './lib/daily-mission-tracker';
|
||||
export type {
|
||||
DailyMissionAction,
|
||||
DailyMissionDefinition,
|
||||
Mission,
|
||||
TallyMission,
|
||||
EventMission,
|
||||
MissionsContent,
|
||||
} from './lib/daily-missions';
|
||||
|
||||
// Progression
|
||||
export {
|
||||
xpToLevel,
|
||||
levelToXp,
|
||||
xpProgress,
|
||||
xpToNextLevel,
|
||||
getUnlocks,
|
||||
buildXpTagUpdates,
|
||||
MAX_LEVEL,
|
||||
} from '@/blobbi/core/lib/progression';
|
||||
export type { Unlocks } from '@/blobbi/core/lib/progression';
|
||||
|
||||
// Missions content model
|
||||
export {
|
||||
parseProfileContent,
|
||||
serializeProfileContent,
|
||||
isMissionComplete,
|
||||
isTallyMission,
|
||||
isEventMission,
|
||||
missionProgress,
|
||||
} from '@/blobbi/core/lib/missions';
|
||||
export type { ProfileContent } from '@/blobbi/core/lib/missions';
|
||||
|
||||
// Item cooldown
|
||||
export { isItemOnCooldown, setItemCooldown, subscribeCooldowns } from './lib/item-cooldown';
|
||||
export { ITEM_COOLDOWN_SUCCESS_MS, ITEM_COOLDOWN_FAILURE_MS } from './lib/item-cooldown';
|
||||
export { useItemCooldown } from './hooks/useItemCooldown';
|
||||
|
||||
// Action XP
|
||||
export {
|
||||
ACTION_XP,
|
||||
INVENTORY_ACTION_XP,
|
||||
DIRECT_ACTION_XP,
|
||||
POOP_CLEANUP_XP,
|
||||
calculateActionXP,
|
||||
calculateInventoryActionXP,
|
||||
applyXPGain,
|
||||
formatXPGain,
|
||||
} from './lib/blobbi-xp';
|
||||
|
||||
// Streak tracking
|
||||
export {
|
||||
calculateStreakUpdate,
|
||||
getStreakTagUpdates,
|
||||
needsStreakUpdate,
|
||||
getStreakStatus,
|
||||
} from './lib/blobbi-streak';
|
||||
export type {
|
||||
StreakUpdateResult,
|
||||
StreakTagUpdates,
|
||||
} from './lib/blobbi-streak';
|
||||
|
||||
export { useBlobbiCareActivity } from './hooks/useBlobbiCareActivity';
|
||||
export type {
|
||||
UseBlobbiCareActivityParams,
|
||||
CareActivityResult,
|
||||
} from './hooks/useBlobbiCareActivity';
|
||||
@@ -1,548 +0,0 @@
|
||||
// src/blobbi/actions/lib/blobbi-action-utils.ts
|
||||
|
||||
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
|
||||
// ─── Action Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Item-based care actions (use a shop catalog item on the companion)
|
||||
*/
|
||||
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
|
||||
|
||||
/**
|
||||
* Direct actions that don't use items.
|
||||
* These actions affect stats directly without selecting a shop item.
|
||||
*/
|
||||
export type DirectAction = 'play_music' | 'sing';
|
||||
|
||||
/**
|
||||
* All Blobbi actions (item-based + direct)
|
||||
*/
|
||||
export type BlobbiAction = InventoryAction | DirectAction;
|
||||
|
||||
/**
|
||||
* Mapping from action type to allowed item categories
|
||||
*/
|
||||
export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
|
||||
feed: 'food',
|
||||
play: 'toy',
|
||||
clean: 'hygiene',
|
||||
medicine: 'medicine',
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for UI display (item-based care actions)
|
||||
*/
|
||||
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
|
||||
feed: {
|
||||
label: 'Feed',
|
||||
description: 'Feed your Blobbi',
|
||||
icon: '🍎',
|
||||
},
|
||||
play: {
|
||||
label: 'Play',
|
||||
description: 'Play with your Blobbi',
|
||||
icon: '⚽',
|
||||
},
|
||||
clean: {
|
||||
label: 'Clean',
|
||||
description: 'Clean your Blobbi',
|
||||
icon: '🧼',
|
||||
},
|
||||
medicine: {
|
||||
label: 'Medicine',
|
||||
description: 'Heal your Blobbi',
|
||||
icon: '💊',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for direct actions (no item required)
|
||||
*/
|
||||
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
|
||||
play_music: {
|
||||
label: 'Play Music',
|
||||
description: 'Play music for your Blobbi',
|
||||
icon: '🎵',
|
||||
},
|
||||
sing: {
|
||||
label: 'Sing',
|
||||
description: 'Sing to your Blobbi',
|
||||
icon: '🎤',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined action metadata for all action types
|
||||
*/
|
||||
export const ALL_ACTION_METADATA: Record<BlobbiAction, { label: string; description: string; icon: string }> = {
|
||||
...ACTION_METADATA,
|
||||
...DIRECT_ACTION_METADATA,
|
||||
};
|
||||
|
||||
// ─── Stat Helpers ─────────────────────────────────────────────────────────────
|
||||
// STAT_MIN and STAT_MAX are imported from @/lib/blobbi (single source of truth)
|
||||
|
||||
/**
|
||||
* Clamp a stat value between STAT_MIN (1) and STAT_MAX (100).
|
||||
* Safe for undefined values (returns STAT_MIN).
|
||||
*
|
||||
* The minimum of 1 (instead of 0) ensures:
|
||||
* - Blobbi is never in an unrecoverable state
|
||||
* - Visual feedback shows critical state without being "dead"
|
||||
* - Recovery is always possible with any healing item
|
||||
*/
|
||||
export function clampStat(value: number | undefined): number {
|
||||
if (value === undefined) return STAT_MIN;
|
||||
return Math.max(STAT_MIN, Math.min(STAT_MAX, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a delta to a stat, clamping the result to STAT_MIN-STAT_MAX.
|
||||
*/
|
||||
export function applyStat(current: number | undefined, delta: number): number {
|
||||
const currentValue = current ?? STAT_MIN;
|
||||
return clampStat(currentValue + delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply item effects to current stats.
|
||||
* Returns a new partial stats object with all affected stats clamped.
|
||||
* Only modifies stats that have corresponding effects.
|
||||
*/
|
||||
export function applyItemEffects(
|
||||
currentStats: Partial<BlobbiStats>,
|
||||
effects: ItemEffect
|
||||
): Partial<BlobbiStats> {
|
||||
const newStats: Partial<BlobbiStats> = { ...currentStats };
|
||||
|
||||
if (effects.hunger !== undefined) {
|
||||
newStats.hunger = applyStat(currentStats.hunger, effects.hunger);
|
||||
}
|
||||
if (effects.happiness !== undefined) {
|
||||
newStats.happiness = applyStat(currentStats.happiness, effects.happiness);
|
||||
}
|
||||
if (effects.energy !== undefined) {
|
||||
newStats.energy = applyStat(currentStats.energy, effects.energy);
|
||||
}
|
||||
if (effects.hygiene !== undefined) {
|
||||
newStats.hygiene = applyStat(currentStats.hygiene, effects.hygiene);
|
||||
}
|
||||
if (effects.health !== undefined) {
|
||||
newStats.health = applyStat(currentStats.health, effects.health);
|
||||
}
|
||||
|
||||
return newStats;
|
||||
}
|
||||
|
||||
// ─── Egg-Specific Item Helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The Shell Repair Kit is a special medicine item only usable by eggs.
|
||||
*/
|
||||
export const SHELL_REPAIR_KIT_ID = 'med_shell_repair';
|
||||
|
||||
/**
|
||||
* Result of checking if an item can be used by a specific Blobbi stage.
|
||||
*/
|
||||
export interface ItemUsabilityResult {
|
||||
canUse: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific item can be used by a companion at the given stage.
|
||||
*
|
||||
* This is the centralized item usability logic:
|
||||
* - Shell Repair Kit: Only usable by eggs
|
||||
* - Food items: Only usable by baby/adult (not eggs)
|
||||
* - Toy items: Only usable by baby/adult (not eggs)
|
||||
* - Medicine items (except Shell Repair Kit): Usable by all stages with health effect
|
||||
* - Hygiene items: Usable by all stages
|
||||
*
|
||||
* @param itemId - The shop item ID
|
||||
* @param stage - The companion's life stage
|
||||
* @returns Object with canUse boolean and optional reason string
|
||||
*/
|
||||
export function canUseItemForStage(
|
||||
itemId: string,
|
||||
stage: 'egg' | 'baby' | 'adult'
|
||||
): ItemUsabilityResult {
|
||||
const shopItem = getShopItemById(itemId);
|
||||
if (!shopItem) {
|
||||
return { canUse: false, reason: 'Item not found' };
|
||||
}
|
||||
|
||||
const isEgg = stage === 'egg';
|
||||
|
||||
// Shell Repair Kit special case: only for eggs
|
||||
if (itemId === SHELL_REPAIR_KIT_ID) {
|
||||
if (!isEgg) {
|
||||
return { canUse: false, reason: 'Only usable for eggs' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Food items: not usable by eggs
|
||||
if (shopItem.type === 'food') {
|
||||
if (isEgg) {
|
||||
return { canUse: false, reason: 'Eggs cannot eat food' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Toy items: not usable by eggs
|
||||
if (shopItem.type === 'toy') {
|
||||
if (isEgg) {
|
||||
return { canUse: false, reason: 'Eggs cannot use toys' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Medicine items (except Shell Repair Kit): check for health effect
|
||||
if (shopItem.type === 'medicine') {
|
||||
if (!hasMedicineEffectForEgg(shopItem.effect)) {
|
||||
return { canUse: false, reason: 'This medicine has no effect' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Hygiene items: all stages can use
|
||||
if (shopItem.type === 'hygiene') {
|
||||
if (!hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
|
||||
return { canUse: false, reason: 'This item has no cleaning effect' };
|
||||
}
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the action type for a given item.
|
||||
*/
|
||||
export function getActionForItem(itemId: string): InventoryAction | null {
|
||||
const shopItem = getShopItemById(itemId);
|
||||
if (!shopItem) return null;
|
||||
|
||||
const typeToAction: Record<string, InventoryAction> = {
|
||||
food: 'feed',
|
||||
toy: 'play',
|
||||
hygiene: 'clean',
|
||||
medicine: 'medicine',
|
||||
};
|
||||
|
||||
return typeToAction[shopItem.type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a medicine item has any effect on an egg.
|
||||
*
|
||||
* Eggs use the standard 3-stat model:
|
||||
* - health
|
||||
* - hygiene
|
||||
* - happiness
|
||||
*
|
||||
* Medicine with a health effect will directly affect the egg's health stat.
|
||||
*/
|
||||
export function hasMedicineEffectForEgg(effects: ItemEffect | undefined): boolean {
|
||||
if (!effects) return false;
|
||||
return effects.health !== undefined && effects.health !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hygiene item has any effect on an egg.
|
||||
* Hygiene items with a hygiene effect will directly affect the egg's hygiene stat.
|
||||
*/
|
||||
export function hasHygieneEffectForEgg(effects: ItemEffect | undefined): boolean {
|
||||
if (!effects) return false;
|
||||
return effects.hygiene !== undefined && effects.hygiene !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item has a happiness effect for an egg.
|
||||
* Some items (like bubble bath) give happiness bonus in addition to primary effects.
|
||||
*/
|
||||
export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boolean {
|
||||
if (!effects) return false;
|
||||
return effects.happiness !== undefined && effects.happiness !== 0;
|
||||
}
|
||||
|
||||
// ─── Item Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolved catalog item with shop metadata
|
||||
*/
|
||||
export interface ResolvedInventoryItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
type: ShopItemCategory;
|
||||
effect?: ItemEffect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for filtering catalog items by action
|
||||
*/
|
||||
export interface FilterInventoryOptions {
|
||||
/** Companion stage - used to filter items by egg-compatible effects */
|
||||
stage?: 'egg' | 'baby' | 'adult';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available items for an action type from the shop catalog.
|
||||
* Items are abilities/tools — no inventory ownership is required.
|
||||
*
|
||||
* Filtering rules:
|
||||
* - Only items matching the action's item type are included
|
||||
* - Shell Repair Kit only appears in medicine modal for eggs
|
||||
* - For eggs: only items with egg-compatible effects are returned
|
||||
* - medicine action: only items with health effect
|
||||
* - clean action: only items with hygiene or happiness effect
|
||||
*/
|
||||
export function filterInventoryByAction(
|
||||
_storage: StorageItem[],
|
||||
action: InventoryAction,
|
||||
options: FilterInventoryOptions = {}
|
||||
): ResolvedInventoryItem[] {
|
||||
const allowedType = ACTION_TO_ITEM_TYPE[action];
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
const isEgg = options.stage === 'egg';
|
||||
const allItems = getLiveShopItems();
|
||||
|
||||
for (const shopItem of allItems) {
|
||||
if (shopItem.type !== allowedType) continue;
|
||||
|
||||
// Shell Repair Kit: only show for eggs in medicine modal
|
||||
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For eggs, filter items by egg-compatible effects
|
||||
if (isEgg) {
|
||||
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
|
||||
continue; // Skip medicine without health effect
|
||||
}
|
||||
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
|
||||
continue; // Skip hygiene items without hygiene or happiness effect
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
itemId: shopItem.id,
|
||||
quantity: Infinity,
|
||||
name: shopItem.name,
|
||||
icon: shopItem.icon,
|
||||
type: shopItem.type,
|
||||
effect: shopItem.effect,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement item quantity in storage array.
|
||||
* If quantity becomes 0, removes the item entirely.
|
||||
* Returns a new storage array (immutable).
|
||||
*/
|
||||
export function decrementStorageItem(
|
||||
storage: StorageItem[],
|
||||
itemId: string,
|
||||
amount = 1
|
||||
): StorageItem[] {
|
||||
const result: StorageItem[] = [];
|
||||
|
||||
for (const item of storage) {
|
||||
if (item.itemId !== itemId) {
|
||||
result.push(item);
|
||||
continue;
|
||||
}
|
||||
const newQuantity = item.quantity - amount;
|
||||
if (newQuantity > 0) {
|
||||
result.push({ ...item, quantity: newQuantity });
|
||||
}
|
||||
// If newQuantity <= 0, we don't add it (remove item)
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stages that can use general items (food, toys, hygiene)
|
||||
*/
|
||||
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
|
||||
|
||||
/**
|
||||
* Inventory actions that are allowed for eggs.
|
||||
* Eggs can use: medicine (health), clean (hygiene)
|
||||
*/
|
||||
export const EGG_ALLOWED_INVENTORY_ACTIONS: InventoryAction[] = ['medicine', 'clean'];
|
||||
|
||||
/**
|
||||
* Direct actions that are allowed for eggs.
|
||||
* All direct actions work on eggs.
|
||||
*/
|
||||
export const EGG_ALLOWED_DIRECT_ACTIONS: DirectAction[] = ['play_music', 'sing'];
|
||||
|
||||
/**
|
||||
* Inventory actions visible in the egg UI.
|
||||
* Note: feed, play, sleep are hidden in the UI for eggs but not hard-blocked.
|
||||
*/
|
||||
export const EGG_VISIBLE_INVENTORY_ACTIONS: InventoryAction[] = ['clean', 'medicine'];
|
||||
|
||||
/**
|
||||
* All actions visible in the egg UI.
|
||||
*/
|
||||
export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_music', 'sing'];
|
||||
|
||||
/**
|
||||
* @deprecated Use EGG_ALLOWED_INVENTORY_ACTIONS instead
|
||||
*/
|
||||
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
|
||||
|
||||
/**
|
||||
* Check if a companion can use a specific item action.
|
||||
*
|
||||
* Note: This function no longer hard-blocks egg actions at the domain layer.
|
||||
* UI visibility is handled separately by `isActionVisibleForStage()`.
|
||||
* The domain layer allows all actions - UI chooses what to show.
|
||||
*/
|
||||
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
|
||||
// All stages can technically use all item actions at the domain layer.
|
||||
// UI filtering determines what actions are shown to users.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a companion can use a specific direct action.
|
||||
* Direct actions (play_music, sing) are available for all stages.
|
||||
*/
|
||||
export function canUseDirectAction(_companion: BlobbiCompanion, _action: DirectAction): boolean {
|
||||
// All stages can use direct actions
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action should be visible in the UI for a given stage.
|
||||
* This is for UI filtering only - some actions are hidden but not blocked.
|
||||
*/
|
||||
export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action: BlobbiAction): boolean {
|
||||
if (stage === 'egg') {
|
||||
return EGG_VISIBLE_ACTIONS.includes(action);
|
||||
}
|
||||
return true; // baby and adult see all actions
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a companion can use general items (feed, play, clean).
|
||||
* Eggs cannot use food, toys, or hygiene items.
|
||||
* @deprecated Use canUseAction(companion, action) for action-specific checks
|
||||
*/
|
||||
export function canUseInventoryItems(companion: BlobbiCompanion): boolean {
|
||||
return GENERAL_ITEM_USABLE_STAGES.includes(companion.stage as typeof GENERAL_ITEM_USABLE_STAGES[number]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message explaining why an action can't be used.
|
||||
*/
|
||||
export function getStageRestrictionMessage(companion: BlobbiCompanion, action?: InventoryAction): string | null {
|
||||
if (companion.stage === 'egg') {
|
||||
if (action && EGG_ALLOWED_INVENTORY_ACTIONS.includes(action)) {
|
||||
return null; // Medicine and clean are allowed for eggs
|
||||
}
|
||||
return 'Eggs cannot use this item. Wait for your Blobbi to hatch!';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Stats Preview ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Preview stats after applying an item's effects.
|
||||
* Useful for showing the user what will happen before confirming.
|
||||
*/
|
||||
export function previewStatChanges(
|
||||
currentStats: Partial<BlobbiStats>,
|
||||
effects: ItemEffect | undefined
|
||||
): Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> {
|
||||
if (!effects) return [];
|
||||
|
||||
const changes: Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> = [];
|
||||
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'energy', 'hygiene', 'health'];
|
||||
|
||||
for (const stat of statKeys) {
|
||||
const delta = effects[stat];
|
||||
if (delta !== undefined && delta !== 0) {
|
||||
const current = currentStats[stat] ?? 0;
|
||||
const after = clampStat(current + delta);
|
||||
changes.push({ stat, current, after, delta });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview stat change for an egg.
|
||||
* Eggs use the 3-stat model: health, hygiene, happiness.
|
||||
*/
|
||||
export type EggStatPreview = { stat: 'health' | 'hygiene' | 'happiness'; current: number; after: number; delta: number };
|
||||
|
||||
/**
|
||||
* Preview medicine effects for an egg.
|
||||
* Medicine directly affects the egg's health stat.
|
||||
*/
|
||||
export function previewMedicineForEgg(
|
||||
currentHealth: number | undefined,
|
||||
effects: ItemEffect | undefined
|
||||
): EggStatPreview[] {
|
||||
if (!effects || effects.health === undefined || effects.health === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const current = currentHealth ?? 100;
|
||||
const delta = effects.health;
|
||||
const after = clampStat(current + delta);
|
||||
|
||||
return [{ stat: 'health', current, after, delta }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview clean (hygiene) effects for an egg.
|
||||
* Hygiene items directly affect the egg's hygiene stat.
|
||||
* May also include happiness bonus if the item has one.
|
||||
*/
|
||||
export function previewCleanForEgg(
|
||||
currentStats: { hygiene?: number; happiness?: number },
|
||||
effects: ItemEffect | undefined
|
||||
): EggStatPreview[] {
|
||||
if (!effects) return [];
|
||||
|
||||
const results: EggStatPreview[] = [];
|
||||
|
||||
// Hygiene effect
|
||||
if (effects.hygiene !== undefined && effects.hygiene !== 0) {
|
||||
const current = currentStats.hygiene ?? 100;
|
||||
const delta = effects.hygiene;
|
||||
const after = clampStat(current + delta);
|
||||
results.push({ stat: 'hygiene', current, after, delta });
|
||||
}
|
||||
|
||||
// Happiness bonus (some hygiene items like bubble bath give happiness)
|
||||
if (effects.happiness !== undefined && effects.happiness !== 0) {
|
||||
const current = currentStats.happiness ?? 100;
|
||||
const delta = effects.happiness;
|
||||
const after = clampStat(current + delta);
|
||||
results.push({ stat: 'happiness', current, after, delta });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// src/blobbi/actions/lib/blobbi-activity-state.ts
|
||||
|
||||
import type { SelectedTrack } from '../components/PlayMusicModal';
|
||||
|
||||
/**
|
||||
* Types of inline activities that can be displayed in BlobbiPage
|
||||
*/
|
||||
export type InlineActivityType = 'none' | 'music' | 'sing';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { SelectedTrack } from '../components/PlayMusicModal';
|
||||
|
||||
/**
|
||||
* State for the music inline activity
|
||||
*/
|
||||
export interface MusicActivityState {
|
||||
type: 'music';
|
||||
selection: SelectedTrack;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the sing inline activity
|
||||
*/
|
||||
export interface SingActivityState {
|
||||
type: 'sing';
|
||||
}
|
||||
|
||||
/**
|
||||
* No active inline activity
|
||||
*/
|
||||
export interface NoActivityState {
|
||||
type: 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all inline activity states
|
||||
*/
|
||||
export type InlineActivityState =
|
||||
| NoActivityState
|
||||
| MusicActivityState
|
||||
| SingActivityState;
|
||||
|
||||
/**
|
||||
* Blobbi reaction state - indicates how Blobbi should visually react
|
||||
*/
|
||||
export type BlobbiReactionState =
|
||||
| 'idle' // No special reaction
|
||||
| 'listening' // Music is playing, Blobbi is listening
|
||||
| 'swaying' // Blobbi is swaying to music
|
||||
| 'singing' // User is singing, Blobbi is engaged
|
||||
| 'happy'; // General happy reaction
|
||||
|
||||
/**
|
||||
* Helper to create a music activity state
|
||||
*/
|
||||
export function createMusicActivity(selection: SelectedTrack): MusicActivityState {
|
||||
return {
|
||||
type: 'music',
|
||||
selection,
|
||||
isPublished: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a sing activity state
|
||||
*/
|
||||
export function createSingActivity(): SingActivityState {
|
||||
return {
|
||||
type: 'sing',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create no activity state
|
||||
*/
|
||||
export function createNoActivity(): NoActivityState {
|
||||
return {
|
||||
type: 'none',
|
||||
};
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// src/blobbi/actions/lib/blobbi-random-lyrics.ts
|
||||
|
||||
/**
|
||||
* Random lyrics for the Sing action.
|
||||
* These are fun, simple lyrics that users can sing to their Blobbi.
|
||||
*/
|
||||
|
||||
export interface LyricsEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of placeholder lyrics for singing to a Blobbi.
|
||||
* Simple, fun, and appropriate for all ages.
|
||||
*/
|
||||
export const BLOBBI_LYRICS: LyricsEntry[] = [
|
||||
{
|
||||
id: 'lullaby-1',
|
||||
title: 'Blobbi Lullaby',
|
||||
lines: [
|
||||
'Little Blobbi, close your eyes,',
|
||||
'Dream of stars up in the skies.',
|
||||
'Safe and warm, you drift away,',
|
||||
"We'll play again another day.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'happy-song-1',
|
||||
title: 'Happy Blobbi Song',
|
||||
lines: [
|
||||
'Blobbi, Blobbi, jump around!',
|
||||
"You're the happiest friend I've found!",
|
||||
'Dancing, playing, full of cheer,',
|
||||
"I'm so glad that you are here!",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'adventure-1',
|
||||
title: 'Adventure Time',
|
||||
lines: [
|
||||
"Let's go on an adventure today,",
|
||||
'Through the clouds and far away!',
|
||||
'Mountains high and valleys deep,',
|
||||
'Memories to always keep.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'breakfast-song',
|
||||
title: 'Breakfast Song',
|
||||
lines: [
|
||||
'Wake up, wake up, sleepy head,',
|
||||
"Time to get out of your bed!",
|
||||
"Breakfast's waiting, fresh and yummy,",
|
||||
'Food to fill your happy tummy!',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rainy-day',
|
||||
title: 'Rainy Day',
|
||||
lines: [
|
||||
'Pitter patter on the roof,',
|
||||
'Rainy days can be so nice.',
|
||||
"We'll stay cozy, me and you,",
|
||||
'Watching raindrops, one by two.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sunshine-song',
|
||||
title: 'Sunshine Song',
|
||||
lines: [
|
||||
'Good morning, sunshine, bright and warm,',
|
||||
'A brand new day is being born!',
|
||||
'Blue sky smiling down on me,',
|
||||
'Happy as can be, so free!',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bedtime-1',
|
||||
title: 'Bedtime Blues',
|
||||
lines: [
|
||||
'The moon is up, the stars are bright,',
|
||||
'Time to say a soft goodnight.',
|
||||
'Snuggle up and close your eyes,',
|
||||
'Sweet dreams under starry skies.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'play-time',
|
||||
title: 'Play Time',
|
||||
lines: [
|
||||
"Bounce and jump and run around,",
|
||||
"Spin and twirl without a sound!",
|
||||
"Playing games is so much fun,",
|
||||
"Laughing underneath the sun!",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a random lyrics entry.
|
||||
*/
|
||||
export function getRandomLyrics(): LyricsEntry {
|
||||
const index = Math.floor(Math.random() * BLOBBI_LYRICS.length);
|
||||
return BLOBBI_LYRICS[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available lyrics entries.
|
||||
*/
|
||||
export function getAllLyrics(): LyricsEntry[] {
|
||||
return BLOBBI_LYRICS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format lyrics for display (joined with newlines).
|
||||
*/
|
||||
export function formatLyrics(lyrics: LyricsEntry): string {
|
||||
return lyrics.lines.join('\n');
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* Blobbi Care Streak Management
|
||||
*
|
||||
* This module provides centralized logic for tracking care streaks on Blobbi companions.
|
||||
* A streak represents consecutive days of care activity (opening Blobbi page, performing
|
||||
* care actions, etc.).
|
||||
*
|
||||
* Streak Rules:
|
||||
* - Starts at 1 on first activity
|
||||
* - Increments when activity happens on the NEXT local calendar day
|
||||
* - Same-day activity does not increment (at most once per day)
|
||||
* - Missing 2+ days resets streak to 1
|
||||
*
|
||||
* Tags managed:
|
||||
* - care_streak: The current streak count (positive integer)
|
||||
* - care_streak_last_at: Unix timestamp (seconds) of last streak update
|
||||
* - care_streak_last_day: Local calendar day string (YYYY-MM-DD) of last update
|
||||
*/
|
||||
|
||||
import {
|
||||
getLocalDayString,
|
||||
getDaysDifference,
|
||||
type BlobbiCompanion,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of calculating a streak update.
|
||||
*/
|
||||
export interface StreakUpdateResult {
|
||||
/** Whether the streak was updated (incremented or reset) */
|
||||
wasUpdated: boolean;
|
||||
/** The new streak value */
|
||||
newStreak: number;
|
||||
/** The new timestamp for care_streak_last_at */
|
||||
newLastAt: number;
|
||||
/** The new day string for care_streak_last_day */
|
||||
newLastDay: string;
|
||||
/** Description of what happened (for debugging/logging) */
|
||||
action: 'initialized' | 'incremented' | 'reset' | 'same_day';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag updates to apply to the Blobbi event.
|
||||
* Only present if wasUpdated is true.
|
||||
* Uses index signature for compatibility with updateBlobbiTags.
|
||||
*/
|
||||
export interface StreakTagUpdates {
|
||||
care_streak: string;
|
||||
care_streak_last_at: string;
|
||||
care_streak_last_day: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// ─── Core Logic ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate what the streak should be updated to based on current state and activity.
|
||||
*
|
||||
* This is a pure function that calculates the new streak state without side effects.
|
||||
* Use this to determine if/how the streak should be updated.
|
||||
*
|
||||
* @param currentStreak - Current streak value (0 or undefined means no streak yet)
|
||||
* @param lastDay - The last day string (YYYY-MM-DD) when streak was updated, or undefined
|
||||
* @param now - Current timestamp (defaults to now)
|
||||
* @returns StreakUpdateResult describing the update
|
||||
*/
|
||||
export function calculateStreakUpdate(
|
||||
currentStreak: number | undefined,
|
||||
lastDay: string | undefined,
|
||||
now: Date = new Date()
|
||||
): StreakUpdateResult {
|
||||
const nowTimestamp = Math.floor(now.getTime() / 1000);
|
||||
const todayString = getLocalDayString(now);
|
||||
|
||||
// Case 1: No existing streak - initialize to 1
|
||||
if (currentStreak === undefined || currentStreak === 0 || !lastDay) {
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: 1,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'initialized',
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Activity on the same day - no update needed
|
||||
if (lastDay === todayString) {
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: currentStreak,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate days since last activity
|
||||
const daysMissed = getDaysDifference(lastDay, todayString);
|
||||
|
||||
// Case 3: Next day (1 day difference) - increment streak
|
||||
if (daysMissed === 1) {
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: currentStreak + 1,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'incremented',
|
||||
};
|
||||
}
|
||||
|
||||
// Case 4: Missed 2+ days - reset to 1
|
||||
return {
|
||||
wasUpdated: true,
|
||||
newStreak: 1,
|
||||
newLastAt: nowTimestamp,
|
||||
newLastDay: todayString,
|
||||
action: 'reset',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tag updates to apply to a Blobbi event for a streak update.
|
||||
* Returns undefined if no update is needed (same day activity).
|
||||
*
|
||||
* @param companion - The current Blobbi companion state
|
||||
* @param now - Current timestamp (defaults to now)
|
||||
* @returns Tag updates to apply, or undefined if no update needed
|
||||
*/
|
||||
export function getStreakTagUpdates(
|
||||
companion: BlobbiCompanion,
|
||||
now: Date = new Date()
|
||||
): StreakTagUpdates | undefined {
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
|
||||
if (!result.wasUpdated) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
care_streak: result.newStreak.toString(),
|
||||
care_streak_last_at: result.newLastAt.toString(),
|
||||
care_streak_last_day: result.newLastDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a streak update is needed for the companion.
|
||||
*
|
||||
* @param companion - The current Blobbi companion state
|
||||
* @param now - Current timestamp (defaults to now)
|
||||
* @returns true if the streak should be updated
|
||||
*/
|
||||
export function needsStreakUpdate(
|
||||
companion: BlobbiCompanion,
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
return result.wasUpdated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current streak status for display purposes.
|
||||
*
|
||||
* @param companion - The current Blobbi companion state
|
||||
* @returns Object with streak info for UI display
|
||||
*/
|
||||
export function getStreakStatus(companion: BlobbiCompanion): {
|
||||
streak: number;
|
||||
lastDay: string | undefined;
|
||||
isActive: boolean;
|
||||
daysSinceLastActivity: number | undefined;
|
||||
} {
|
||||
const streak = companion.careStreak ?? 0;
|
||||
const lastDay = companion.careStreakLastDay;
|
||||
const today = getLocalDayString();
|
||||
|
||||
let daysSinceLastActivity: number | undefined;
|
||||
let isActive = false;
|
||||
|
||||
if (lastDay) {
|
||||
daysSinceLastActivity = getDaysDifference(lastDay, today);
|
||||
// Streak is "active" if we've had activity today or yesterday
|
||||
isActive = daysSinceLastActivity <= 1;
|
||||
}
|
||||
|
||||
return {
|
||||
streak,
|
||||
lastDay,
|
||||
isActive,
|
||||
daysSinceLastActivity,
|
||||
};
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// src/blobbi/actions/lib/blobbi-track-catalog.ts
|
||||
|
||||
/**
|
||||
* Blobbi Track Catalog
|
||||
*
|
||||
* Music tracks for the Blobbi "Play Music" action.
|
||||
* All tracks are hosted on remote Blossom servers and streamed on-demand.
|
||||
*
|
||||
* ## Adding New Tracks
|
||||
*
|
||||
* 1. Convert the audio file to M4A (AAC-LC):
|
||||
* `ffmpeg -i input.m4a -c:a aac -b:a 64k -ar 48000 output.m4a`
|
||||
* 2. Upload the M4A file to a Blossom server
|
||||
* 3. Add a new entry to `BLOBBI_TRACK_CATALOG` below
|
||||
* 4. Set `url` to the full Blossom URL
|
||||
* 5. Get the duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 <file>`
|
||||
*
|
||||
* ## Supported Formats
|
||||
*
|
||||
* M4A (AAC-LC) is required for iOS/Safari compatibility and small file size.
|
||||
*/
|
||||
|
||||
export interface BlobbiTrack {
|
||||
/** Unique identifier for the track (used in state/events) */
|
||||
id: string;
|
||||
/** Display title shown in the UI */
|
||||
title: string;
|
||||
/** Artist or source attribution */
|
||||
artist: string;
|
||||
/** Full URL to the remote audio file (Blossom server) */
|
||||
url: string;
|
||||
/** Duration in seconds (for display, get via ffprobe) */
|
||||
durationSeconds: number;
|
||||
/** Optional cover art URL */
|
||||
coverArt?: string;
|
||||
/** Optional tags for categorization/filtering */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Blobbi track catalog.
|
||||
*
|
||||
* All tracks are royalty-free/Creative Commons licensed.
|
||||
* Audio files hosted on remote Blossom servers.
|
||||
*/
|
||||
export const BLOBBI_TRACK_CATALOG: BlobbiTrack[] = [
|
||||
{
|
||||
id: 'nap_in_the_meadow',
|
||||
title: 'Nap in the Meadow',
|
||||
artist: 'Chilltape FM',
|
||||
url: 'https://blossom.ditto.pub/6be1c95e879187f83af2a661ccac2bd96196f7bc334af44529ede6270b2811fc.m4a',
|
||||
durationSeconds: 240, // 4:00
|
||||
tags: ['relaxing', 'nature'],
|
||||
},
|
||||
{
|
||||
id: 'happy_kids',
|
||||
title: 'Happy Kids',
|
||||
artist: 'Dmitrii Kolesnikov',
|
||||
url: 'https://blossom.ditto.pub/94d49abd178aa8afb14737a55e0a7143f6b337f618d74858d011232bb2db845d.m4a',
|
||||
durationSeconds: 129, // 2:09
|
||||
tags: ['upbeat', 'fun'],
|
||||
},
|
||||
{
|
||||
id: 'soft_piano',
|
||||
title: 'Soft Piano',
|
||||
artist: 'Dmitrii Kolesnikov',
|
||||
url: 'https://blossom.ditto.pub/5367242d3dc555c77f5c637fd153df1166708a24c5a4c222bb4dcaeabf740743.m4a',
|
||||
durationSeconds: 124, // 2:04
|
||||
tags: ['calming', 'sleep'],
|
||||
},
|
||||
{
|
||||
id: 'epic_sacred_light',
|
||||
title: 'Epic Sacred Light',
|
||||
artist: 'Ura Megis',
|
||||
url: 'https://blossom.dreamith.to/c22953791d686605958165fd44a84cd7d9fd3d4423ebf786e47891ed3a82c6db.m4a',
|
||||
durationSeconds: 223, // 3:43
|
||||
tags: ['energetic', 'adventure'],
|
||||
},
|
||||
{
|
||||
id: 'split_memories',
|
||||
title: 'Split Memories',
|
||||
artist: 'ido berg',
|
||||
url: 'https://blossom.ditto.pub/57ba2e2122a732449880ae531d4bfac9a580bc19693c7dda735afbfa336b35fe.m4a',
|
||||
durationSeconds: 153, // 2:33
|
||||
tags: ['ambient', 'relaxing'],
|
||||
},
|
||||
{
|
||||
id: 'minhas_mensagens',
|
||||
title: 'Minhas Mensagens',
|
||||
artist: 'PReis',
|
||||
url: 'https://blossom.ditto.pub/0945064dc8f946f3392be23629b166e72090cafca7cca865a20b5395dd83ff46.m4a',
|
||||
durationSeconds: 248, // 4:08
|
||||
tags: ['ambient', 'relaxing'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a track by ID from the catalog
|
||||
*/
|
||||
export function getTrackById(id: string): BlobbiTrack | undefined {
|
||||
return BLOBBI_TRACK_CATALOG.find(track => track.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tracks from the catalog
|
||||
*/
|
||||
export function getAllTracks(): BlobbiTrack[] {
|
||||
return BLOBBI_TRACK_CATALOG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to MM:SS string
|
||||
*/
|
||||
export function formatTrackDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateActionXP,
|
||||
calculateInventoryActionXP,
|
||||
applyXPGain,
|
||||
getXPGainSummary,
|
||||
formatXPGain,
|
||||
getXPGainMessage,
|
||||
ACTION_XP,
|
||||
INVENTORY_ACTION_XP,
|
||||
DIRECT_ACTION_XP,
|
||||
} from './blobbi-xp';
|
||||
|
||||
describe('calculateActionXP', () => {
|
||||
it('returns the correct XP for each inventory action', () => {
|
||||
expect(calculateActionXP('feed')).toBe(5);
|
||||
expect(calculateActionXP('play')).toBe(8);
|
||||
expect(calculateActionXP('clean')).toBe(6);
|
||||
expect(calculateActionXP('medicine')).toBe(10);
|
||||
});
|
||||
|
||||
it('returns the correct XP for each direct action', () => {
|
||||
expect(calculateActionXP('play_music')).toBe(7);
|
||||
expect(calculateActionXP('sing')).toBe(9);
|
||||
});
|
||||
|
||||
it('returns 0 for an unknown action', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(calculateActionXP('unknown' as any)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateInventoryActionXP', () => {
|
||||
it('returns base XP for quantity 1', () => {
|
||||
expect(calculateInventoryActionXP('feed', 1)).toBe(5);
|
||||
expect(calculateInventoryActionXP('medicine', 1)).toBe(10);
|
||||
});
|
||||
|
||||
it('multiplies XP by quantity', () => {
|
||||
expect(calculateInventoryActionXP('feed', 3)).toBe(15);
|
||||
expect(calculateInventoryActionXP('play', 5)).toBe(40);
|
||||
});
|
||||
|
||||
it('defaults to quantity 1 when not specified', () => {
|
||||
expect(calculateInventoryActionXP('clean')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns 0 for quantity less than 1', () => {
|
||||
expect(calculateInventoryActionXP('feed', 0)).toBe(0);
|
||||
expect(calculateInventoryActionXP('feed', -1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyXPGain', () => {
|
||||
it('adds XP to a current value', () => {
|
||||
expect(applyXPGain(100, 25)).toBe(125);
|
||||
});
|
||||
|
||||
it('treats undefined current XP as 0', () => {
|
||||
expect(applyXPGain(undefined, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('never returns a negative value', () => {
|
||||
expect(applyXPGain(5, -20)).toBe(0);
|
||||
expect(applyXPGain(0, -1)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles zero XP gain', () => {
|
||||
expect(applyXPGain(50, 0)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getXPGainSummary', () => {
|
||||
it('returns the correct xpGained and quantity', () => {
|
||||
const result = getXPGainSummary('feed', 3);
|
||||
expect(result).toEqual({ xpGained: 15, quantity: 3 });
|
||||
});
|
||||
|
||||
it('defaults quantity to 1', () => {
|
||||
const result = getXPGainSummary('sing');
|
||||
expect(result).toEqual({ xpGained: 9, quantity: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatXPGain', () => {
|
||||
it('formats positive XP as "+N XP"', () => {
|
||||
expect(formatXPGain(15)).toBe('+15 XP');
|
||||
expect(formatXPGain(1)).toBe('+1 XP');
|
||||
});
|
||||
|
||||
it('returns empty string for zero or negative XP', () => {
|
||||
expect(formatXPGain(0)).toBe('');
|
||||
expect(formatXPGain(-5)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getXPGainMessage', () => {
|
||||
it('formats a message with action and XP earned', () => {
|
||||
expect(getXPGainMessage('feed', 5)).toBe('+5 XP earned!');
|
||||
});
|
||||
|
||||
it('includes total when provided', () => {
|
||||
expect(getXPGainMessage('feed', 5, 105)).toBe('+5 XP earned! Total: 105 XP');
|
||||
});
|
||||
|
||||
it('returns empty string for zero or negative XP', () => {
|
||||
expect(getXPGainMessage('feed', 0)).toBe('');
|
||||
expect(getXPGainMessage('feed', -1)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('XP constants', () => {
|
||||
it('ACTION_XP contains all inventory and direct actions', () => {
|
||||
for (const action of Object.keys(INVENTORY_ACTION_XP)) {
|
||||
expect(ACTION_XP).toHaveProperty(action);
|
||||
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
|
||||
INVENTORY_ACTION_XP[action as keyof typeof INVENTORY_ACTION_XP],
|
||||
);
|
||||
}
|
||||
for (const action of Object.keys(DIRECT_ACTION_XP)) {
|
||||
expect(ACTION_XP).toHaveProperty(action);
|
||||
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
|
||||
DIRECT_ACTION_XP[action as keyof typeof DIRECT_ACTION_XP],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('all XP values are positive integers', () => {
|
||||
for (const xp of Object.values(ACTION_XP)) {
|
||||
expect(xp).toBeGreaterThan(0);
|
||||
expect(Number.isInteger(xp)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Blobbi XP (Experience Points) System
|
||||
*
|
||||
* This module defines XP values for all Blobbi care actions and provides
|
||||
* utilities for calculating and applying XP gains.
|
||||
*
|
||||
* Design Philosophy:
|
||||
* - Different actions award different XP to reflect their complexity/value
|
||||
* - XP values are balanced to encourage variety in care activities
|
||||
* - Item actions (feed, play, clean, medicine) give varied XP per action type
|
||||
* - Direct actions (sing, play_music) give moderate XP
|
||||
* - XP accumulates across all life stages and never resets
|
||||
*/
|
||||
|
||||
import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-action-utils';
|
||||
|
||||
// ─── XP Values by Action ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Base XP values for item-based care actions (feed, play, clean, medicine).
|
||||
*/
|
||||
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
|
||||
feed: 5, // Feeding is common and essential - moderate XP
|
||||
play: 8, // Playing toys provides good interaction - higher XP
|
||||
clean: 6, // Hygiene maintenance is important - moderate-high XP
|
||||
medicine: 10, // Medicine is critical - highest item XP
|
||||
};
|
||||
|
||||
/**
|
||||
* Base XP values for direct actions (play_music, sing).
|
||||
* These actions don't require selecting an item.
|
||||
*/
|
||||
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
|
||||
play_music: 7, // Playing music is engaging - good XP
|
||||
sing: 9, // Singing requires more user effort - higher XP
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined XP lookup for all action types.
|
||||
* Use this for a unified XP calculation interface.
|
||||
*/
|
||||
export const ACTION_XP: Record<BlobbiAction, number> = {
|
||||
...INVENTORY_ACTION_XP,
|
||||
...DIRECT_ACTION_XP,
|
||||
};
|
||||
|
||||
/**
|
||||
* XP awarded for cleaning up poop.
|
||||
*/
|
||||
export const POOP_CLEANUP_XP = 5;
|
||||
|
||||
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate XP gain for a single action.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @returns XP points earned
|
||||
*/
|
||||
export function calculateActionXP(action: BlobbiAction): number {
|
||||
return ACTION_XP[action] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate XP gain for an item-based care action.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of times performed (always 1 in current usage)
|
||||
* @returns Total XP points earned
|
||||
*/
|
||||
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
|
||||
if (quantity < 1) return 0;
|
||||
const baseXP = INVENTORY_ACTION_XP[action] ?? 0;
|
||||
return baseXP * quantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply XP gain to current experience value.
|
||||
*
|
||||
* @param currentXP - Current experience points (undefined = 0)
|
||||
* @param xpGain - XP points to add
|
||||
* @returns New total XP (never negative)
|
||||
*/
|
||||
export function applyXPGain(currentXP: number | undefined, xpGain: number): number {
|
||||
const current = currentXP ?? 0;
|
||||
const newXP = current + xpGain;
|
||||
return Math.max(0, newXP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get XP gain summary for displaying to the user.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of times the action was performed (always 1 in current usage)
|
||||
* @returns Object with xpGained and quantity
|
||||
*/
|
||||
export function getXPGainSummary(
|
||||
action: BlobbiAction,
|
||||
quantity: number = 1
|
||||
): { xpGained: number; quantity: number } {
|
||||
const baseXP = ACTION_XP[action] ?? 0;
|
||||
const xpGained = baseXP * quantity;
|
||||
return { xpGained, quantity };
|
||||
}
|
||||
|
||||
// ─── XP Display Utilities ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format XP gain for display in toasts/notifications.
|
||||
*
|
||||
* @param xpGained - Amount of XP gained
|
||||
* @returns Formatted string like "+15 XP"
|
||||
*/
|
||||
export function formatXPGain(xpGained: number): string {
|
||||
if (xpGained <= 0) return '';
|
||||
return `+${xpGained} XP`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a descriptive message about XP gain.
|
||||
*
|
||||
* @param action - The action that earned XP
|
||||
* @param xpGained - Amount of XP gained
|
||||
* @param newTotal - New total XP (optional, for "You now have X XP" message)
|
||||
* @returns Formatted message for user feedback
|
||||
*/
|
||||
export function getXPGainMessage(
|
||||
action: BlobbiAction,
|
||||
xpGained: number,
|
||||
newTotal?: number
|
||||
): string {
|
||||
if (xpGained <= 0) return '';
|
||||
|
||||
const xpText = formatXPGain(xpGained);
|
||||
|
||||
if (newTotal !== undefined) {
|
||||
return `${xpText} earned! Total: ${newTotal} XP`;
|
||||
}
|
||||
|
||||
return `${xpText} earned!`;
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* Daily Mission Tracker - Standalone progress tracking utility
|
||||
*
|
||||
* Provides a way to record daily mission progress from anywhere
|
||||
* (hooks, event handlers, etc.) without requiring React context.
|
||||
*
|
||||
* Uses a pubkey-scoped in-memory Map. Kind 11125 content JSON is the
|
||||
* persistent source of truth. Completed missions are persisted by
|
||||
* `useAwardDailyXp`; intermediate progress resets on page refresh.
|
||||
*
|
||||
* Dispatches 'daily-missions-updated' CustomEvent so React hooks re-render.
|
||||
*/
|
||||
|
||||
import type { MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
import type { DailyMissionAction } from './daily-missions';
|
||||
import {
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsContent,
|
||||
trackTally,
|
||||
trackEvent,
|
||||
trackEvolutionTally,
|
||||
trackEvolutionEvent,
|
||||
} from './daily-missions';
|
||||
|
||||
// ─── In-Memory Session Store ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pubkey-scoped session cache. Each logged-in user gets their own entry.
|
||||
* Cleared on page refresh (intentional — kind 11125 is the persistent store).
|
||||
*/
|
||||
const sessionStore = new Map<string, MissionsContent>();
|
||||
|
||||
function key(pubkey: string | undefined): string {
|
||||
return pubkey ?? '';
|
||||
}
|
||||
|
||||
function ensureCurrent(pubkey?: string): MissionsContent {
|
||||
const current = sessionStore.get(key(pubkey));
|
||||
if (!needsDailyReset(current)) return current!;
|
||||
const fresh = createDailyMissionsContent(
|
||||
getTodayDateString(),
|
||||
current?.evolution ?? [],
|
||||
pubkey,
|
||||
);
|
||||
sessionStore.set(key(pubkey), fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
function notify(detail?: Record<string, unknown>): void {
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail }));
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record a tally-based action (feed, clean, interact, etc.).
|
||||
*/
|
||||
export function trackDailyMissionProgress(
|
||||
action: DailyMissionAction,
|
||||
count: number = 1,
|
||||
pubkey?: string,
|
||||
): void {
|
||||
const current = ensureCurrent(pubkey);
|
||||
const updated = trackTally(current, action, count);
|
||||
sessionStore.set(key(pubkey), updated);
|
||||
notify({ action, count });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an event-based action (take_photo, etc.) with its Nostr event ID.
|
||||
*/
|
||||
export function trackDailyMissionEvent(
|
||||
action: DailyMissionAction,
|
||||
eventId: string,
|
||||
pubkey?: string,
|
||||
): void {
|
||||
const current = ensureCurrent(pubkey);
|
||||
const updated = trackEvent(current, action, eventId);
|
||||
sessionStore.set(key(pubkey), updated);
|
||||
notify({ action, eventId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Track multiple tally actions at once.
|
||||
*/
|
||||
export function trackMultipleDailyMissionActions(
|
||||
actions: DailyMissionAction[],
|
||||
pubkey?: string,
|
||||
): void {
|
||||
let current = ensureCurrent(pubkey);
|
||||
for (const action of actions) {
|
||||
current = trackTally(current, action, 1);
|
||||
}
|
||||
sessionStore.set(key(pubkey), current);
|
||||
notify({ actions });
|
||||
}
|
||||
|
||||
// ─── Evolution Mission Tracking ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Increment tally for an evolution mission (e.g. interactions).
|
||||
* No-ops if pubkey missing or session store empty.
|
||||
*/
|
||||
export function trackEvolutionMissionTally(
|
||||
missionId: string,
|
||||
count: number = 1,
|
||||
pubkey?: string,
|
||||
): void {
|
||||
const current = sessionStore.get(key(pubkey));
|
||||
if (!current) return;
|
||||
|
||||
const updated = trackEvolutionTally(current, missionId, count);
|
||||
sessionStore.set(key(pubkey), updated);
|
||||
notify({ evolution: true, missionId, count });
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a Nostr event ID to an evolution mission (e.g. create_theme).
|
||||
* Deduplicates by event ID. No-ops if pubkey missing or session store empty.
|
||||
*/
|
||||
export function trackEvolutionMissionEvent(
|
||||
missionId: string,
|
||||
eventId: string,
|
||||
pubkey?: string,
|
||||
): void {
|
||||
const current = sessionStore.get(key(pubkey));
|
||||
if (!current) return;
|
||||
|
||||
const updated = trackEvolutionEvent(current, missionId, eventId);
|
||||
sessionStore.set(key(pubkey), updated);
|
||||
notify({ evolution: true, missionId, eventId });
|
||||
}
|
||||
|
||||
// ─── Storage Access ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Read current session state for a pubkey. */
|
||||
export function readMissionsFromStorage(pubkey?: string): MissionsContent | undefined {
|
||||
return sessionStore.get(key(pubkey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the session store has an entry for the given pubkey.
|
||||
* If the store is empty or needs a daily reset, a fresh entry is created.
|
||||
* Returns the current (possibly newly-created) MissionsContent.
|
||||
*
|
||||
* Use this before writing evolution missions into the store, to avoid
|
||||
* silent no-ops when the store hasn't been hydrated yet.
|
||||
*/
|
||||
export function ensureSessionStore(pubkey?: string): MissionsContent {
|
||||
return ensureCurrent(pubkey);
|
||||
}
|
||||
|
||||
/** Write state to session store for a pubkey. */
|
||||
export function writeMissionsToStorage(missions: MissionsContent, pubkey?: string): void {
|
||||
sessionStore.set(key(pubkey), missions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the session store from kind 11125 persisted data.
|
||||
* Called once on mount / account switch when the session store is empty.
|
||||
* No-op if the store already has data for this pubkey.
|
||||
*/
|
||||
export function hydrateFromPersisted(missions: MissionsContent, pubkey: string): void {
|
||||
if (sessionStore.has(pubkey)) return;
|
||||
sessionStore.set(pubkey, missions);
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
/**
|
||||
* Daily Missions System for Blobbi
|
||||
*
|
||||
* Defines the daily mission pool, selection logic, and state management.
|
||||
* Missions use the tally/event model from missions.ts:
|
||||
* - Tally missions: { id, target, count }
|
||||
* - Event missions: { id, target, events }
|
||||
* Completion is derived: count >= target or events.length >= target.
|
||||
* No explicit completed/claimed flags.
|
||||
*/
|
||||
|
||||
import type { Mission, TallyMission, EventMission, MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
import { isTallyMission, isEventMission, isMissionComplete } from '@/blobbi/core/lib/missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Actions that can trigger daily mission progress.
|
||||
* Tally actions increment a counter. Event actions append an event ID.
|
||||
*/
|
||||
export type DailyMissionAction =
|
||||
| 'interact' // Any care interaction (tally)
|
||||
| 'feed' // Feeding action (tally)
|
||||
| 'clean' // Cleaning action (tally)
|
||||
| 'sing' // Sing direct action (tally)
|
||||
| 'play_music' // Play music direct action (tally)
|
||||
| 'sleep' // Put Blobbi to sleep (tally)
|
||||
| 'take_photo' // Take a photo (event)
|
||||
| 'medicine'; // Give medicine (tally)
|
||||
|
||||
/** Whether a mission action tracks events or tallies */
|
||||
export type MissionTrackingType = 'tally' | 'event';
|
||||
|
||||
/** Blobbi stage type for filtering missions */
|
||||
export type BlobbiStage = 'egg' | 'baby' | 'adult';
|
||||
|
||||
/**
|
||||
* Definition of a daily mission in the pool.
|
||||
* This is the static template -- not the runtime state.
|
||||
*/
|
||||
export interface DailyMissionDefinition {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Display title */
|
||||
title: string;
|
||||
/** Description of what to do */
|
||||
description: string;
|
||||
/** Action that triggers progress */
|
||||
action: DailyMissionAction;
|
||||
/** Number of times the action must be performed */
|
||||
target: number;
|
||||
/** Whether this mission tracks events or tallies */
|
||||
tracking: MissionTrackingType;
|
||||
/** XP reward for completing this mission */
|
||||
xp: number;
|
||||
/** Selection weight (higher = more likely) */
|
||||
weight: number;
|
||||
/** Required stages to show this mission */
|
||||
requiredStages?: BlobbiStage[];
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maximum number of mission rerolls allowed per day */
|
||||
export const MAX_DAILY_REROLLS = 3;
|
||||
|
||||
/** Number of daily missions selected each day */
|
||||
export const DAILY_MISSION_COUNT = 3;
|
||||
|
||||
/** XP bonus for completing all daily missions */
|
||||
export const DAILY_BONUS_XP = 50;
|
||||
|
||||
// ─── Mission Pool ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
// ── Baby/Adult only ──────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'interact_3', title: 'Quick Care',
|
||||
description: 'Interact with your Blobbi 3 times',
|
||||
action: 'interact', target: 3, tracking: 'tally', xp: 15, weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'interact_6', title: 'Attentive Caretaker',
|
||||
description: 'Interact with your Blobbi 6 times',
|
||||
action: 'interact', target: 6, tracking: 'tally', xp: 30, weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'feed_1', title: 'Snack Time',
|
||||
description: 'Feed your Blobbi once',
|
||||
action: 'feed', target: 1, tracking: 'tally', xp: 10, weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'feed_2', title: 'Hungry Blobbi',
|
||||
description: 'Feed your Blobbi 2 times',
|
||||
action: 'feed', target: 2, tracking: 'tally', xp: 20, weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'feed_3', title: 'Feast Day',
|
||||
description: 'Feed your Blobbi 3 times',
|
||||
action: 'feed', target: 3, tracking: 'tally', xp: 35, weight: 5,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'sleep_1', title: 'Nap Time',
|
||||
description: 'Put your Blobbi to sleep',
|
||||
action: 'sleep', target: 1, tracking: 'tally', xp: 15, weight: 6,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'take_photo_1', title: 'Snapshot',
|
||||
description: 'Take a photo of your Blobbi',
|
||||
action: 'take_photo', target: 1, tracking: 'event', xp: 25, weight: 4,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'take_photo_2', title: 'Photo Album',
|
||||
description: 'Take 2 photos of your Blobbi',
|
||||
action: 'take_photo', target: 2, tracking: 'event', xp: 40, weight: 2,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ── All stages ───────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'clean_1', title: 'Quick Cleanup',
|
||||
description: 'Clean your Blobbi once',
|
||||
action: 'clean', target: 1, tracking: 'tally', xp: 10, weight: 10,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'clean_2', title: 'Squeaky Clean',
|
||||
description: 'Clean your Blobbi 2 times',
|
||||
action: 'clean', target: 2, tracking: 'tally', xp: 20, weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'sing_1', title: 'Sing Along',
|
||||
description: 'Sing a song to your Blobbi',
|
||||
action: 'sing', target: 1, tracking: 'tally', xp: 15, weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'sing_2', title: 'Karaoke Session',
|
||||
description: 'Sing 2 songs to your Blobbi',
|
||||
action: 'sing', target: 2, tracking: 'tally', xp: 25, weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'play_music_1', title: 'DJ Time',
|
||||
description: 'Play a song for your Blobbi',
|
||||
action: 'play_music', target: 1, tracking: 'tally', xp: 15, weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'play_music_2', title: 'Music Marathon',
|
||||
description: 'Play 2 songs for your Blobbi',
|
||||
action: 'play_music', target: 2, tracking: 'tally', xp: 25, weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'medicine_1', title: 'Health Check',
|
||||
description: 'Give medicine to your Blobbi',
|
||||
action: 'medicine', target: 1, tracking: 'tally', xp: 20, weight: 5,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'medicine_2', title: 'Doctor Visit',
|
||||
description: 'Give medicine to your Blobbi 2 times',
|
||||
action: 'medicine', target: 2, tracking: 'tally', xp: 35, weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Lookup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const POOL_BY_ID = new Map(DAILY_MISSION_POOL.map((d) => [d.id, d]));
|
||||
|
||||
/** Look up a mission definition by ID */
|
||||
export function getDefinition(id: string): DailyMissionDefinition | undefined {
|
||||
return POOL_BY_ID.get(id);
|
||||
}
|
||||
|
||||
// ─── Date Utilities ──────────────────────────────────────────────────────────
|
||||
|
||||
/** YYYY-MM-DD in local timezone */
|
||||
export function getTodayDateString(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Whether the missions content needs a daily reset */
|
||||
export function needsDailyReset(missions: MissionsContent | undefined): boolean {
|
||||
if (!missions) return true;
|
||||
return missions.date !== getTodayDateString();
|
||||
}
|
||||
|
||||
// ─── Selection ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Seeded PRNG (Mulberry32) */
|
||||
function seededRandom(seed: number): () => number {
|
||||
return function () {
|
||||
let t = seed += 0x6D2B79F5;
|
||||
t = Math.imul(t ^ t >>> 15, t | 1);
|
||||
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
||||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function generateDailySeed(dateString: string, pubkey?: string): number {
|
||||
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash = ((hash << 5) - hash) + input.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
function isMissionAvailableForStages(def: DailyMissionDefinition, stages: BlobbiStage[]): boolean {
|
||||
const required = def.requiredStages ?? ['baby', 'adult'];
|
||||
return required.some((s) => stages.includes(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select N missions deterministically from the pool.
|
||||
* Seeded by date + pubkey so the same user gets the same missions for a given day.
|
||||
*/
|
||||
export function selectDailyMissions(
|
||||
count: number,
|
||||
dateString: string,
|
||||
pubkey?: string,
|
||||
availableStages?: BlobbiStage[],
|
||||
): DailyMissionDefinition[] {
|
||||
const stages = availableStages ?? ['baby', 'adult'];
|
||||
const eligible = DAILY_MISSION_POOL.filter((m) => isMissionAvailableForStages(m, stages));
|
||||
if (eligible.length === 0) return [];
|
||||
|
||||
const random = seededRandom(generateDailySeed(dateString, pubkey));
|
||||
const available = [...eligible];
|
||||
const selected: DailyMissionDefinition[] = [];
|
||||
|
||||
while (selected.length < count && available.length > 0) {
|
||||
const totalWeight = available.reduce((sum, m) => sum + m.weight, 0);
|
||||
let pick = random() * totalWeight;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < available.length; i++) {
|
||||
pick -= available[i].weight;
|
||||
if (pick <= 0) { idx = i; break; }
|
||||
}
|
||||
selected.push(available[idx]);
|
||||
available.splice(idx, 1);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
// ─── Mission Instantiation ───────────────────────────────────────────────────
|
||||
|
||||
/** Create a fresh Mission from a definition */
|
||||
export function createMission(def: DailyMissionDefinition): Mission {
|
||||
if (def.tracking === 'event') {
|
||||
return { id: def.id, target: def.target, events: [] } satisfies EventMission;
|
||||
}
|
||||
return { id: def.id, target: def.target, count: 0 } satisfies TallyMission;
|
||||
}
|
||||
|
||||
/** Create a fresh MissionsContent for a new day, preserving evolution missions */
|
||||
export function createDailyMissionsContent(
|
||||
dateString: string,
|
||||
existingEvolution: Mission[],
|
||||
pubkey?: string,
|
||||
availableStages?: BlobbiStage[],
|
||||
): MissionsContent {
|
||||
const defs = selectDailyMissions(DAILY_MISSION_COUNT, dateString, pubkey, availableStages);
|
||||
return {
|
||||
date: dateString,
|
||||
daily: defs.map(createMission),
|
||||
evolution: existingEvolution,
|
||||
rerolls: MAX_DAILY_REROLLS,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Progress Tracking ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Increment tally for all daily missions matching the given action.
|
||||
* Returns a new missions content (immutable).
|
||||
*/
|
||||
export function trackTally(
|
||||
missions: MissionsContent,
|
||||
action: DailyMissionAction,
|
||||
incrementBy: number = 1,
|
||||
): MissionsContent {
|
||||
const updated = missions.daily.map((m) => {
|
||||
const def = POOL_BY_ID.get(m.id);
|
||||
if (!def || def.action !== action) return m;
|
||||
if (!isTallyMission(m)) return m;
|
||||
if (m.count >= m.target) return m; // already complete
|
||||
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
|
||||
});
|
||||
return { ...missions, daily: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an event ID to a daily mission.
|
||||
* Deduplicates by event ID. Returns new missions content.
|
||||
*/
|
||||
export function trackEvent(
|
||||
missions: MissionsContent,
|
||||
action: DailyMissionAction,
|
||||
eventId: string,
|
||||
): MissionsContent {
|
||||
const updated = missions.daily.map((m) => {
|
||||
const def = POOL_BY_ID.get(m.id);
|
||||
if (!def || def.action !== action) return m;
|
||||
if (!isEventMission(m)) return m;
|
||||
if (m.events.length >= m.target) return m; // already complete
|
||||
if (m.events.includes(eventId)) return m; // dedup
|
||||
return { ...m, events: [...m.events, eventId] };
|
||||
});
|
||||
return { ...missions, daily: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Track progress for an evolution mission by tally.
|
||||
*/
|
||||
export function trackEvolutionTally(
|
||||
missions: MissionsContent,
|
||||
missionId: string,
|
||||
incrementBy: number = 1,
|
||||
): MissionsContent {
|
||||
const updated = missions.evolution.map((m) => {
|
||||
if (m.id !== missionId) return m;
|
||||
if (!isTallyMission(m)) return m;
|
||||
if (m.count >= m.target) return m;
|
||||
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
|
||||
});
|
||||
return { ...missions, evolution: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an event ID to an evolution mission.
|
||||
*/
|
||||
export function trackEvolutionEvent(
|
||||
missions: MissionsContent,
|
||||
missionId: string,
|
||||
eventId: string,
|
||||
): MissionsContent {
|
||||
const updated = missions.evolution.map((m) => {
|
||||
if (m.id !== missionId) return m;
|
||||
if (!isEventMission(m)) return m;
|
||||
if (m.events.length >= m.target) return m;
|
||||
if (m.events.includes(eventId)) return m;
|
||||
return { ...m, events: [...m.events, eventId] };
|
||||
});
|
||||
return { ...missions, evolution: updated };
|
||||
}
|
||||
|
||||
// ─── Completion Queries ──────────────────────────────────────────────────────
|
||||
|
||||
/** Whether all daily missions are complete */
|
||||
export function areAllDailyComplete(missions: MissionsContent): boolean {
|
||||
return missions.daily.length > 0 && missions.daily.every(isMissionComplete);
|
||||
}
|
||||
|
||||
/** Whether all evolution missions are complete */
|
||||
export function areAllEvolutionComplete(missions: MissionsContent): boolean {
|
||||
return missions.evolution.length > 0 && missions.evolution.every(isMissionComplete);
|
||||
}
|
||||
|
||||
/** Total XP available from today's daily missions (including bonus if all complete) */
|
||||
export function totalDailyXp(missions: MissionsContent): number {
|
||||
const base = missions.daily.reduce((sum, m) => {
|
||||
const def = POOL_BY_ID.get(m.id);
|
||||
return sum + (def && isMissionComplete(m) ? def.xp : 0);
|
||||
}, 0);
|
||||
const bonus = areAllDailyComplete(missions) ? DAILY_BONUS_XP : 0;
|
||||
return base + bonus;
|
||||
}
|
||||
|
||||
/** XP earned by a specific daily mission (0 if incomplete or unknown) */
|
||||
export function missionXp(missionId: string, mission: Mission): number {
|
||||
const def = POOL_BY_ID.get(missionId);
|
||||
if (!def || !isMissionComplete(mission)) return 0;
|
||||
return def.xp;
|
||||
}
|
||||
|
||||
// ─── Reroll ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Select a replacement mission not already in the current set.
|
||||
* Uses Math.random (rerolls should feel random, not deterministic).
|
||||
*/
|
||||
export function selectReplacementMission(
|
||||
currentMissions: Mission[],
|
||||
missionToReplaceId: string,
|
||||
availableStages?: BlobbiStage[],
|
||||
): DailyMissionDefinition | null {
|
||||
const stages = availableStages ?? ['baby', 'adult'];
|
||||
const excludedIds = new Set(currentMissions.map((m) => m.id));
|
||||
|
||||
const eligible = DAILY_MISSION_POOL.filter((m) =>
|
||||
m.id !== missionToReplaceId &&
|
||||
!excludedIds.has(m.id) &&
|
||||
isMissionAvailableForStages(m, stages),
|
||||
);
|
||||
|
||||
if (eligible.length === 0) return null;
|
||||
|
||||
const totalWeight = eligible.reduce((sum, m) => sum + m.weight, 0);
|
||||
let pick = Math.random() * totalWeight;
|
||||
for (const def of eligible) {
|
||||
pick -= def.weight;
|
||||
if (pick <= 0) return def;
|
||||
}
|
||||
return eligible[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reroll a daily mission. Returns updated missions content or null if not possible.
|
||||
*/
|
||||
export function rerollMission(
|
||||
missions: MissionsContent,
|
||||
missionId: string,
|
||||
availableStages?: BlobbiStage[],
|
||||
): MissionsContent | null {
|
||||
if (missions.rerolls <= 0) return null;
|
||||
|
||||
const idx = missions.daily.findIndex((m) => m.id === missionId);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const existing = missions.daily[idx];
|
||||
if (isMissionComplete(existing)) return null; // can't reroll completed
|
||||
|
||||
const replacement = selectReplacementMission(missions.daily, missionId, availableStages);
|
||||
if (!replacement) return null;
|
||||
|
||||
const updatedDaily = [...missions.daily];
|
||||
updatedDaily[idx] = createMission(replacement);
|
||||
|
||||
return {
|
||||
...missions,
|
||||
daily: updatedDaily,
|
||||
rerolls: missions.rerolls - 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export mission utilities for convenience
|
||||
export { isTallyMission, isEventMission, isMissionComplete, missionProgress } from '@/blobbi/core/lib/missions';
|
||||
export type { Mission, TallyMission, EventMission, MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* Evolution Missions - Static definitions for hatch and evolve tasks.
|
||||
*
|
||||
* These are the lifecycle tasks that gate stage transitions (egg→baby, baby→adult).
|
||||
* Progress is tracked in `MissionsContent.evolution[]` on kind 11125, using the
|
||||
* same TallyMission / EventMission model as daily missions.
|
||||
*
|
||||
* Unlike daily missions, evolution missions:
|
||||
* - Are populated when incubation/evolution starts
|
||||
* - Are cleared when the stage transition completes (or is cancelled)
|
||||
* - Are NOT deterministically seeded — the full set is always used
|
||||
*/
|
||||
|
||||
import type { Mission, TallyMission, EventMission } from '@/blobbi/core/lib/missions';
|
||||
|
||||
// ─── Shared Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Find an evolution mission by ID in the given array. */
|
||||
export function findEvolutionMission(evolution: Mission[], id: string): Mission | undefined {
|
||||
return evolution.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
// ─── Tracking Type ───────────────────────────────────────────────────────────
|
||||
|
||||
export type EvolutionTrackingType = 'tally' | 'event';
|
||||
|
||||
// ─── Definition ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EvolutionMissionDefinition {
|
||||
/** Unique identifier (matches Mission.id) */
|
||||
id: string;
|
||||
/** Display title */
|
||||
title: string;
|
||||
/** Description shown in the UI */
|
||||
description: string;
|
||||
/** Number of times the action must be performed / events collected */
|
||||
target: number;
|
||||
/** Whether this mission tracks a counter or event IDs */
|
||||
tracking: EvolutionTrackingType;
|
||||
/** UI action hint */
|
||||
action?: 'navigate' | 'open_modal' | 'external_link';
|
||||
/** Target for the action */
|
||||
actionTarget?: string;
|
||||
/** Button label */
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
// ─── Hatch Mission Pool ──────────────────────────────────────────────────────
|
||||
|
||||
export const HATCH_MISSIONS: readonly EvolutionMissionDefinition[] = [
|
||||
{
|
||||
id: 'create_theme',
|
||||
title: 'Create Theme',
|
||||
description: 'Create a custom theme for your profile',
|
||||
target: 1,
|
||||
tracking: 'event',
|
||||
action: 'navigate',
|
||||
actionTarget: '/themes',
|
||||
actionLabel: 'Create Theme',
|
||||
},
|
||||
{
|
||||
id: 'color_moment',
|
||||
title: 'Color Moment',
|
||||
description: 'Share a color moment on espy',
|
||||
target: 1,
|
||||
tracking: 'event',
|
||||
action: 'external_link',
|
||||
actionTarget: 'https://espy.you/',
|
||||
actionLabel: 'Open espy',
|
||||
},
|
||||
{
|
||||
id: 'create_post',
|
||||
title: 'Create Post',
|
||||
description: 'Share a post with the #blobbi hashtag',
|
||||
target: 1,
|
||||
tracking: 'event',
|
||||
action: 'open_modal',
|
||||
actionTarget: 'blobbi_post',
|
||||
actionLabel: 'Create Post',
|
||||
},
|
||||
{
|
||||
id: 'interactions',
|
||||
title: 'Interact with Blobbi',
|
||||
description: 'Care for your Blobbi 7 times',
|
||||
target: 7,
|
||||
tracking: 'tally',
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ─── Evolve Mission Pool ─────────────────────────────────────────────────────
|
||||
|
||||
export const EVOLVE_MISSIONS: readonly EvolutionMissionDefinition[] = [
|
||||
{
|
||||
id: 'create_themes',
|
||||
title: 'Create Themes',
|
||||
description: 'Create 3 custom themes',
|
||||
target: 3,
|
||||
tracking: 'event',
|
||||
action: 'navigate',
|
||||
actionTarget: '/themes',
|
||||
actionLabel: 'Create Theme',
|
||||
},
|
||||
{
|
||||
id: 'color_moments',
|
||||
title: 'Color Moments',
|
||||
description: 'Share 3 color moments on espy',
|
||||
target: 3,
|
||||
tracking: 'event',
|
||||
action: 'external_link',
|
||||
actionTarget: 'https://espy.you/',
|
||||
actionLabel: 'Open espy',
|
||||
},
|
||||
{
|
||||
id: 'interactions',
|
||||
title: 'Interact with Blobbi',
|
||||
description: 'Care for your Blobbi 21 times',
|
||||
target: 21,
|
||||
tracking: 'tally',
|
||||
},
|
||||
{
|
||||
id: 'edit_profile',
|
||||
title: 'Edit Your Profile',
|
||||
description: 'Update your profile info or customize your profile tabs',
|
||||
target: 1,
|
||||
tracking: 'event',
|
||||
action: 'navigate',
|
||||
actionTarget: '/settings/profile',
|
||||
actionLabel: 'Edit Profile',
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ─── Instantiation ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a fresh Mission from an evolution definition */
|
||||
export function createEvolutionMission(def: EvolutionMissionDefinition): Mission {
|
||||
if (def.tracking === 'event') {
|
||||
return { id: def.id, target: def.target, events: [] } satisfies EventMission;
|
||||
}
|
||||
return { id: def.id, target: def.target, count: 0 } satisfies TallyMission;
|
||||
}
|
||||
|
||||
/** Create the full set of hatch missions (for starting incubation) */
|
||||
export function createHatchMissions(): Mission[] {
|
||||
return HATCH_MISSIONS.map(createEvolutionMission);
|
||||
}
|
||||
|
||||
/** Create the full set of evolve missions (for starting evolution) */
|
||||
export function createEvolveMissions(): Mission[] {
|
||||
return EVOLVE_MISSIONS.map(createEvolutionMission);
|
||||
}
|
||||
|
||||
// ─── Constants (re-exported for backward compat) ─────────────────────────────
|
||||
|
||||
/** Required interactions to complete the hatch interactions task */
|
||||
export const HATCH_REQUIRED_INTERACTIONS = 7;
|
||||
|
||||
/** Required interactions to complete the evolve interactions task */
|
||||
export const EVOLVE_REQUIRED_INTERACTIONS = 21;
|
||||
|
||||
/** Required themes for evolve task */
|
||||
export const EVOLVE_REQUIRED_THEMES = 3;
|
||||
|
||||
/** Required color moments for evolve task */
|
||||
export const EVOLVE_REQUIRED_COLOR_MOMENTS = 3;
|
||||
|
||||
/** Stat threshold for evolve dynamic task (all stats >= 80) */
|
||||
export const EVOLVE_STAT_THRESHOLD = 80;
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Centralized item-use cooldown tracking.
|
||||
*
|
||||
* Module-level singleton shared by every item-use path
|
||||
* (dashboard, companion layer, shop modal, falling items).
|
||||
*
|
||||
* Keyed by item type ID (e.g. "food_apple"), not instance IDs.
|
||||
* Separate durations for success (short) and failure (longer).
|
||||
* Built-in subscriber system for React via useSyncExternalStore.
|
||||
*/
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Cooldown after a successful item use (ms). */
|
||||
export const ITEM_COOLDOWN_SUCCESS_MS = 400;
|
||||
|
||||
/** Cooldown after a failed item use (ms). */
|
||||
export const ITEM_COOLDOWN_FAILURE_MS = 2000;
|
||||
|
||||
// ─── Singleton State ──────────────────────────────────────────────────────────
|
||||
|
||||
interface CooldownEntry {
|
||||
expiresAt: number;
|
||||
timerId: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const cooldowns = new Map<string, CooldownEntry>();
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
subscribers.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Check whether an item is currently on cooldown. */
|
||||
export function isItemOnCooldown(itemId: string): boolean {
|
||||
const entry = cooldowns.get(itemId);
|
||||
if (!entry) return false;
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
clearTimeout(entry.timerId);
|
||||
cooldowns.delete(itemId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Put an item on cooldown. Notifies subscribers on start and expiry. */
|
||||
export function setItemCooldown(itemId: string, success: boolean): void {
|
||||
const prev = cooldowns.get(itemId);
|
||||
if (prev) clearTimeout(prev.timerId);
|
||||
|
||||
const ms = success ? ITEM_COOLDOWN_SUCCESS_MS : ITEM_COOLDOWN_FAILURE_MS;
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
cooldowns.delete(itemId);
|
||||
notify();
|
||||
}, ms);
|
||||
|
||||
cooldowns.set(itemId, { expiresAt: Date.now() + ms, timerId });
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Subscribe to cooldown state changes. Returns unsubscribe function. */
|
||||
export function subscribeCooldowns(callback: () => void): () => void {
|
||||
subscribers.add(callback);
|
||||
return () => { subscribers.delete(callback); };
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Grupo das pétalas com rotação -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="10s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
|
||||
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
|
||||
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
|
||||
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
|
||||
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
|
||||
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
|
||||
</g>
|
||||
|
||||
<!-- Grupo das partículas giratórias -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="20s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.8" />
|
||||
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.6" />
|
||||
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.7" />
|
||||
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.5" />
|
||||
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.9" />
|
||||
</g>
|
||||
|
||||
<!-- Centro da flor -->
|
||||
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
|
||||
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
|
||||
|
||||
<!-- Eyes (white/base eye shapes) -->
|
||||
<circle cx="88" cy="105" r="8" fill="white" />
|
||||
<circle cx="112" cy="105" r="8" fill="white" />
|
||||
|
||||
<!-- Pupils (pupil + highlights) -->
|
||||
<circle cx="88" cy="105" r="5" fill="#1f2937" />
|
||||
<circle cx="112" cy="105" r="5" fill="#1f2937" />
|
||||
<circle cx="90" cy="103" r="2" fill="white" />
|
||||
<circle cx="114" cy="103" r="2" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 90 120 Q 100 128 110 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Bochechas -->
|
||||
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
|
||||
<!-- Gradientes -->
|
||||
<defs>
|
||||
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fed7d7" />
|
||||
<stop offset="100%" stop-color="#f87171" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#e0e7ff" />
|
||||
<stop offset="100%" stop-color="#8b5cf6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dcfce7" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dbeafe" />
|
||||
<stop offset="100%" stop-color="#3b82f6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="50%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,99 +0,0 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Grupo das pétalas com rotação mais lenta (ou pode ser removido completamente) -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="360 100 110"
|
||||
dur="20s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
|
||||
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
|
||||
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
|
||||
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
|
||||
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
|
||||
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
|
||||
</g>
|
||||
|
||||
<!-- Grupo das partículas giratórias -->
|
||||
<g transform="rotate(0 100 110)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 100 110"
|
||||
to="-360 100 110"
|
||||
dur="30s"
|
||||
repeatCount="indefinite" />
|
||||
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.4" />
|
||||
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.3" />
|
||||
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.3" />
|
||||
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.2" />
|
||||
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.4" />
|
||||
</g>
|
||||
|
||||
<!-- Centro da flor -->
|
||||
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
|
||||
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
|
||||
|
||||
<!-- Olhos dormindo -->
|
||||
<path d="M 80 105 Q 88 108 96 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 104 105 Q 112 108 120 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Boca calma -->
|
||||
<circle cx="100" cy="120" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Bochechas -->
|
||||
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
|
||||
|
||||
<!-- "Zzz" dormindo -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<!-- Gradientes -->
|
||||
<defs>
|
||||
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fed7d7" />
|
||||
<stop offset="100%" stop-color="#f87171" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#e0e7ff" />
|
||||
<stop offset="100%" stop-color="#8b5cf6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dcfce7" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#dbeafe" />
|
||||
<stop offset="100%" stop-color="#3b82f6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="50%" stop-color="#fbbf24" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#fce7f3" />
|
||||
<stop offset="100%" stop-color="#f472b6" />
|
||||
</radialGradient>
|
||||
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#fef3c7" />
|
||||
<stop offset="100%" stop-color="#fbbf24" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1,100 +0,0 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Main leaf body - classic leaf shape -->
|
||||
<path d="M 100 40 Q 70 60 60 90 Q 55 120 70 140 Q 85 155 100 160 Q 115 155 130 140 Q 145 120 140 90 Q 130 60 100 40"
|
||||
fill="url(#breezyBody)" />
|
||||
|
||||
<!-- Leaf veins - central vein -->
|
||||
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
|
||||
|
||||
<!-- Side veins -->
|
||||
<path d="M 100 70 L 80 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 70 L 120 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 75 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 125 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 85 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 115 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Inner leaf highlight -->
|
||||
<path d="M 100 50 Q 75 65 68 85 Q 65 105 75 120 Q 85 130 100 135 Q 115 130 125 120 Q 135 105 132 85 Q 125 65 100 50"
|
||||
fill="url(#breezyInner)" opacity="0.6" />
|
||||
|
||||
<!-- Eyes (white base) -->
|
||||
<circle cx="85" cy="90" r="10" fill="white" />
|
||||
<circle cx="115" cy="90" r="10" fill="white" />
|
||||
|
||||
<!-- Pupils (dark circles + highlights) -->
|
||||
<circle cx="85" cy="90" r="6" fill="#1f2937" />
|
||||
<circle cx="115" cy="90" r="6" fill="#1f2937" />
|
||||
<circle cx="87" cy="88" r="3" fill="white" />
|
||||
<circle cx="117" cy="88" r="3" fill="white" />
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M 85 110 Q 100 120 115 110" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<path d="M 65 100 Q 55 95 50 105 Q 55 115 65 110" fill="url(#breezyArm)" />
|
||||
<path d="M 135 100 Q 145 95 150 105 Q 145 115 135 110" fill="url(#breezyArm)" />
|
||||
|
||||
<!-- Little legs -->
|
||||
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
|
||||
<!-- Floating leaves with rotation groups -->
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
|
||||
<path d="M -50 -30 Q -55 -25 -50 -20 Q -45 -25 -50 -30" fill="url(#breezyFloating)" opacity="0.8" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
|
||||
<path d="M 50 -25 Q 45 -20 50 -15 Q 55 -20 50 -25" fill="url(#breezyFloating)" opacity="0.6" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
|
||||
<path d="M -55 30 Q -60 35 -55 40 Q -50 35 -55 30" fill="url(#breezyFloating)" opacity="0.7" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
|
||||
<path d="M 55 25 Q 50 30 55 35 Q 60 30 55 25" fill="url(#breezyFloating)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<radialGradient id="breezyBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="30%" stop-color="#4ade80" />
|
||||
<stop offset="70%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#bbf7d0" />
|
||||
<stop offset="100%" stop-color="#86efac" />
|
||||
</radialGradient>
|
||||
<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#15803d" />
|
||||
<stop offset="50%" stop-color="#16a34a" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</linearGradient>
|
||||
<radialGradient id="breezyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyFloating" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -1,95 +0,0 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Main leaf body -->
|
||||
<path d="M 100 40 Q 70 60 60 90 Q 55 120 70 140 Q 85 155 100 160 Q 115 155 130 140 Q 145 120 140 90 Q 130 60 100 40"
|
||||
fill="url(#breezyBody)" />
|
||||
|
||||
<!-- Leaf veins -->
|
||||
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
|
||||
<path d="M 100 70 L 80 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 70 L 120 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 75 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 100 L 125 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 85 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
<path d="M 100 130 L 115 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
|
||||
|
||||
<!-- Inner leaf highlight -->
|
||||
<path d="M 100 50 Q 75 65 68 85 Q 65 105 75 120 Q 85 130 100 135 Q 115 130 125 120 Q 135 105 132 85 Q 125 65 100 50"
|
||||
fill="url(#breezyInner)" opacity="0.6" />
|
||||
|
||||
<!-- Olhos dormindo -->
|
||||
<path d="M 75 90 Q 85 93 95 90" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M 105 90 Q 115 93 125 90" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Boca tranquila -->
|
||||
<circle cx="100" cy="110" r="2" fill="#1f2937" />
|
||||
|
||||
<!-- Little arms -->
|
||||
<path d="M 65 100 Q 55 95 50 105 Q 55 115 65 110" fill="url(#breezyArm)" />
|
||||
<path d="M 135 100 Q 145 95 150 105 Q 145 115 135 110" fill="url(#breezyArm)" />
|
||||
|
||||
<!-- Little legs -->
|
||||
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
|
||||
|
||||
<!-- Floating leaves -->
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
|
||||
<path d="M -50 -30 Q -55 -25 -50 -20 Q -45 -25 -50 -30" fill="url(#breezyFloating)" opacity="0.6" />
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
|
||||
<path d="M 50 -25 Q 45 -20 50 -15 Q 55 -20 50 -25" fill="url(#breezyFloating)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
|
||||
<path d="M -55 30 Q -60 35 -55 40 Q -50 35 -55 30" fill="url(#breezyFloating)" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(100 100)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
|
||||
<path d="M 55 25 Q 50 30 55 35 Q 60 30 55 25" fill="url(#breezyFloating)" opacity="0.4" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- "Zzz" dormindo -->
|
||||
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
|
||||
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
|
||||
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
|
||||
|
||||
<!-- Gradientes -->
|
||||
<defs>
|
||||
<radialGradient id="breezyBody" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="30%" stop-color="#4ade80" />
|
||||
<stop offset="70%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyInner" cx="0.4" cy="0.3">
|
||||
<stop offset="0%" stop-color="#bbf7d0" />
|
||||
<stop offset="100%" stop-color="#86efac" />
|
||||
</radialGradient>
|
||||
<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#15803d" />
|
||||
<stop offset="50%" stop-color="#16a34a" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</linearGradient>
|
||||
<radialGradient id="breezyArm" cx="0.3" cy="0.3">
|
||||
<stop offset="0%" stop-color="#4ade80" />
|
||||
<stop offset="100%" stop-color="#16a34a" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyLeg" cx="0.3" cy="0.2">
|
||||
<stop offset="0%" stop-color="#22c55e" />
|
||||
<stop offset="100%" stop-color="#15803d" />
|
||||
</radialGradient>
|
||||
<radialGradient id="breezyFloating" cx="0.5" cy="0.5">
|
||||
<stop offset="0%" stop-color="#86efac" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user