Compare commits
199 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7df27c754 | |||
| ae622909f3 | |||
| 5fa021329e | |||
| e1d4939c81 | |||
| 8c83758461 | |||
| da1d872dd7 | |||
| 70f74c6f9d | |||
| 556af013db | |||
| b7a128ad28 | |||
| c17be3d191 | |||
| e2d3a164a6 | |||
| 88d2fdd904 | |||
| 6929097466 | |||
| 52dae96a61 | |||
| c82c6f4179 | |||
| 0c389397d2 | |||
| 7254f40fc9 | |||
| 1ffa5289ba | |||
| 6d51f6eeac | |||
| bd6eb18022 | |||
| 5f2e88c0f3 | |||
| 55fe82adf9 | |||
| 81a91f033b | |||
| 711a9527e9 | |||
| ced5d00163 | |||
| b6dffa9828 | |||
| 5a94ef10d7 | |||
| ec9f57476d | |||
| 6a60612ba6 | |||
| 945ae3b126 | |||
| a23a470eac | |||
| 2ee979afc0 | |||
| ba996d9878 | |||
| e0e2300521 | |||
| 0f0ea01f9a | |||
| a56860a6ce | |||
| 9550094ffb | |||
| 71918f8381 | |||
| 99fefdda67 | |||
| dabe3c1687 | |||
| 1caf911f53 | |||
| c3f0e9d3fa | |||
| bc39c99d07 | |||
| 377b536456 | |||
| bf0fde9d06 | |||
| fb5278b891 | |||
| a27ee3af86 | |||
| 7073cadb43 | |||
| 2dfb880566 | |||
| 13d4f667b6 | |||
| d73460a617 | |||
| ec9b6c43be | |||
| 0d3b8ed23d | |||
| a61925b821 | |||
| cbfbca063e | |||
| f3393b2cc8 | |||
| 2eb643f422 | |||
| e22dbbe85c | |||
| e01ed039fb | |||
| 17cdb87723 | |||
| a55ff61669 | |||
| 5c215aeec5 | |||
| 591ab57352 | |||
| cb42b1b6a3 | |||
| 3039c46565 | |||
| 2d74088b25 | |||
| 2d52aa8a56 | |||
| 02b83be58e | |||
| 8c3371e968 | |||
| 1a106545f7 | |||
| 86c4594cdd | |||
| 6d157c0a65 | |||
| 43c75175f4 | |||
| ffa1094f93 | |||
| e890e913f5 | |||
| 12a4966b84 | |||
| b68ea276db | |||
| cc702027b0 | |||
| 328c858e4e | |||
| dcf77aac2a | |||
| cdf3391aad | |||
| 787446b4ee | |||
| 5febdb2d7d | |||
| 005f40b536 | |||
| 01a6012a0a | |||
| c009eb4d5c | |||
| 9bdfa1a485 | |||
| 6742792e90 | |||
| 8f6d52a9f9 | |||
| 51a25919c7 | |||
| 1405b5e2c2 | |||
| 8b3b412b16 | |||
| bbcefbb79e | |||
| 83f2f1de7e | |||
| 3dd77c2fcc | |||
| b51b11063f | |||
| 4ffa3119a7 | |||
| dbf7ed9bb2 | |||
| 8f5f33560e | |||
| 41392d9299 | |||
| 4623438652 | |||
| 6948938768 | |||
| db9cdd04c5 | |||
| 528cf905fb | |||
| 2c08bcd94a | |||
| 9de3fa7112 | |||
| 28027cd7b2 | |||
| e54fad61ae | |||
| 31189801f8 | |||
| d579e91bbd | |||
| 27133d69f2 | |||
| 5e895e59ae | |||
| c5f9f8be6c | |||
| 1a58875418 | |||
| 8ee6388ab8 | |||
| 5878b8ad5f | |||
| ec4359f1aa | |||
| f217394012 | |||
| 32908f7b4f | |||
| bd333b9584 | |||
| 3ac1dc6b0a | |||
| 025ecd8645 | |||
| 0fca39a1bd | |||
| 3152f7f0ec | |||
| 7cba044b9d | |||
| 4245b2aede | |||
| 3cdec3ceb6 | |||
| aa8f7539ae | |||
| c6b3cb8758 | |||
| 59f68efdc7 | |||
| dc81585f9a | |||
| 54e6c964db | |||
| dceda199c3 | |||
| 8967012035 | |||
| 0b73d4aac5 | |||
| 6f53f7ad99 | |||
| 399df4da4d | |||
| c06a66ade4 | |||
| 1fca26ae2e | |||
| ccd8f213f6 | |||
| 1c25702453 | |||
| 357ba7d8c8 | |||
| 207ca6893a | |||
| 6dc7fb7ade | |||
| 37df5d0bd1 | |||
| 19906cf918 | |||
| 874010c4fe | |||
| d256acdef3 | |||
| 98e0273bdb | |||
| e26407d740 | |||
| b42f12ce77 | |||
| 7a10e4a406 | |||
| eda18d8b93 | |||
| 126dce1dfc | |||
| 70809a8c7c | |||
| 5b15300f23 | |||
| 105da53e2e | |||
| 8585dd4833 | |||
| 7bc4a632b0 | |||
| 12bda76526 | |||
| 0222248d76 | |||
| a542dd3b36 | |||
| fc292a8654 | |||
| 9214bd823b | |||
| 8f5b8264c9 | |||
| 94f821d064 | |||
| 6d73e6d06b | |||
| bd724de1e8 | |||
| 9d899cfe87 | |||
| 173f789242 | |||
| 5c8c33747e | |||
| 07a9b956cb | |||
| 0e7f847de0 | |||
| 4998ea8f5d | |||
| 0cc81cd35f | |||
| ed09c8947d | |||
| 2e79d93806 | |||
| f05097087b | |||
| 72268dfde6 | |||
| 7b63f6112c | |||
| ce61d8d1a6 | |||
| c4a10b1303 | |||
| 76c6846e91 | |||
| ac1e82b52d | |||
| 437b8de652 | |||
| adadb6ed53 | |||
| f7c90a4a23 | |||
| 82632bb76c | |||
| 3a70d34e6d | |||
| 221d3f4aff | |||
| 6a1a462ab0 | |||
| 5ee8bc1cc0 | |||
| 61c84ed137 | |||
| a24b755e08 | |||
| 46a970b900 | |||
| 2fbc9e0409 | |||
| 313222d12e | |||
| 46ba6978dd | |||
| f4363dcbff |
@@ -4,4 +4,8 @@ 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=""
|
||||
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
|
||||
# ALLOWED_HOSTS="*"
|
||||
# ALLOWED_HOSTS="*"
|
||||
# Breez SDK (Spark) API key - request one at https://breez.technology/request-api-key/
|
||||
# Required for the self-custodial Agora Wallet to connect. Without this, the wallet UI falls back
|
||||
# to a disabled state and users can still use NWC / WebLN for zaps.
|
||||
VITE_BREEZ_API_KEY=""
|
||||
@@ -26,13 +26,17 @@ test:
|
||||
script:
|
||||
- npm run test
|
||||
|
||||
# Disabled: nsite deploy not needed right now; re-enable by restoring the
|
||||
# rules below to run on default branch (and ensure NSITE_NBUNKSEC is set).
|
||||
deploy-nsite:
|
||||
stage: deploy
|
||||
timeout: 10 minutes
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: never
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
- when: never
|
||||
# rules:
|
||||
# - if: $CI_COMMIT_TAG
|
||||
# when: never
|
||||
# - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
variables:
|
||||
NSYTE_VERSION: "v0.24.1"
|
||||
script:
|
||||
@@ -50,7 +54,7 @@ deploy-nsite:
|
||||
nsyte deploy ./dist
|
||||
-i
|
||||
--sec "$NSITE_NBUNKSEC"
|
||||
--name ditto
|
||||
--name agora
|
||||
--relays "wss://relay.ditto.pub,wss://relay.nsite.lol,wss://relay.dreamith.to,wss://relay.primal.net"
|
||||
--servers "https://blossom.primal.net,https://blossom.ditto.pub,https://blossom.dreamith.to"
|
||||
--fallback "/index.html"
|
||||
@@ -154,24 +158,24 @@ build-apk:
|
||||
|
||||
# Copy APK to a predictable artifact path
|
||||
- mkdir -p artifacts
|
||||
- cp android/app/build/outputs/apk/release/app-release.apk "artifacts/Ditto.apk"
|
||||
- cp android/app/build/outputs/bundle/release/app-release.aab "artifacts/Ditto.aab"
|
||||
- cp android/app/build/outputs/apk/release/app-release.apk "artifacts/Agora.apk"
|
||||
- cp android/app/build/outputs/bundle/release/app-release.aab "artifacts/Agora.aab"
|
||||
- ls -lh artifacts/
|
||||
|
||||
# Upload to Generic Packages registry for a stable public download URL
|
||||
- |
|
||||
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
|
||||
--upload-file "artifacts/Ditto.apk" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk"
|
||||
--upload-file "artifacts/Agora.apk" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.apk"
|
||||
- |
|
||||
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
|
||||
--upload-file "artifacts/Ditto.aab" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab"
|
||||
--upload-file "artifacts/Agora.aab" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.aab"
|
||||
|
||||
artifacts:
|
||||
paths:
|
||||
- artifacts/Ditto.apk
|
||||
- artifacts/Ditto.aab
|
||||
- artifacts/Agora.apk
|
||||
- artifacts/Agora.aab
|
||||
expire_in: 90 days
|
||||
cache:
|
||||
key: android-gradle
|
||||
@@ -194,7 +198,7 @@ release:
|
||||
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="Ditto ${CI_COMMIT_TAG}"
|
||||
RELEASE_NOTES="Agora ${CI_COMMIT_TAG}"
|
||||
fi
|
||||
- echo "$RELEASE_NOTES" > release-notes.md
|
||||
release:
|
||||
@@ -203,11 +207,11 @@ release:
|
||||
description: './release-notes.md'
|
||||
assets:
|
||||
links:
|
||||
- name: Ditto-${CI_COMMIT_TAG}.apk
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk
|
||||
- name: Agora-${CI_COMMIT_TAG}.apk
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.apk
|
||||
link_type: package
|
||||
- name: Ditto-${CI_COMMIT_TAG}.aab
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab
|
||||
- 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
|
||||
|
||||
publish-zapstore:
|
||||
@@ -219,7 +223,7 @@ publish-zapstore:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
SIGN_WITH: $ZAPSTORE_BUNKER_URL
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub"
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub,wss://relay.dreamith.to,wss://relay.primal.net"
|
||||
BLOSSOM_URL: "https://blossom.ditto.pub"
|
||||
script:
|
||||
- go install github.com/zapstore/zsp@latest
|
||||
@@ -230,8 +234,37 @@ publish-zapstore:
|
||||
- mkdir -p ~/.config/zsp/bunker-keys
|
||||
- echo "$ZAPSTORE_CLIENT_KEY" > ~/.config/zsp/bunker-keys/${BUNKER_PUBKEY}.key
|
||||
|
||||
- APK_PATH="artifacts/Ditto.apk"
|
||||
- APK_PATH="artifacts/Agora.apk"
|
||||
- VERSION="${CI_COMMIT_TAG#v}"
|
||||
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
|
||||
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
|
||||
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
|
||||
|
||||
publish-google-play:
|
||||
stage: publish
|
||||
image: ruby:3.3
|
||||
needs:
|
||||
- build-apk
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
- gem install fastlane --no-document
|
||||
|
||||
# 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
|
||||
- >-
|
||||
fastlane supply
|
||||
--aab artifacts/Agora.aab
|
||||
--package_name pub.agora.app
|
||||
--track production
|
||||
--json_key /tmp/play-service-account.json
|
||||
--skip_upload_metadata
|
||||
--skip_upload_changelogs
|
||||
--skip_upload_images
|
||||
--skip_upload_screenshots
|
||||
--skip_upload_apk
|
||||
|
||||
# Clean up
|
||||
- rm -f /tmp/play-service-account.json
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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.
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!-- Link the GitLab issue. MRs without a linked issue will not be reviewed. -->
|
||||
|
||||
Closes #
|
||||
|
||||
## What Changed
|
||||
|
||||
<!-- 1-3 sentences: what you changed and why. -->
|
||||
|
||||
## Live Preview
|
||||
|
||||
<!-- REQUIRED for UI changes. Deploy your branch and paste the URL. -->
|
||||
<!-- Example: npx surge dist your-branch.surge.sh -->
|
||||
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- REQUIRED for UI changes. Show before and after. -->
|
||||
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
|
||||
|
||||
**Before:**
|
||||
|
||||
|
||||
**After:**
|
||||
|
||||
## Philosophy Alignment
|
||||
|
||||
<!-- Answer this question for your change: -->
|
||||
<!-- "Does this make Ditto more magnetic, more threatening to the status quo, -->
|
||||
<!-- and more peaceful to inhabit?" -->
|
||||
<!-- See: https://about.ditto.pub/philosophy -->
|
||||
<!-- For bug fixes: "Bug fix -- restores intended behavior" is acceptable. -->
|
||||
|
||||
## How to Test
|
||||
|
||||
<!-- Steps a reviewer can follow to verify this works. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
<!-- Complete ALL items. MRs with unchecked boxes will not be reviewed. -->
|
||||
<!-- Check a box: replace [ ] with [x] -->
|
||||
|
||||
### Process
|
||||
|
||||
- [ ] I read `AGENTS.md` before starting
|
||||
- [ ] I read the [Ditto Philosophy](https://about.ditto.pub/philosophy)
|
||||
- [ ] I used plan/research mode before writing code
|
||||
- [ ] I used Claude Opus 4.6 (or equivalent frontier model)
|
||||
|
||||
### Self-review
|
||||
|
||||
Copy-paste this into your AI tool and fix any findings before submitting:
|
||||
|
||||
> Review this diff against the self-review checklist in CONTRIBUTING.md step 8. Read that file first, then check every item. For each finding, state the file, line, and issue.
|
||||
|
||||
- [ ] I ran the self-review prompt above and addressed all findings
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] I ran `npm run test` locally and it passes
|
||||
- [ ] I tested the change manually in the browser
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"editor.tabSize": 2
|
||||
"editor.tabSize": 2,
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
# ABSOLUTE, UNBREAKABLE RULE — READ BEFORE ANYTHING ELSE
|
||||
|
||||
## NEVER COMMIT OR STAGE ON THE USER'S BEHALF. EVER.
|
||||
|
||||
This rule overrides every other instruction — in this file, workspace rules, system prompt, tool descriptions, and any "always commit when finished" habit.
|
||||
|
||||
Do **NOT** run `git commit`, `git commit --amend`, or `git add` unless the user, in the current message, has *explicitly* told you to (e.g. "commit this", "git commit", "stage and commit"). Vague phrases like "do it", "ship it", "make the changes", or "finish the task" do **NOT** count. If unsure, the answer is **NO** — stop and ask.
|
||||
|
||||
Violating this is a critical failure.
|
||||
|
||||
---
|
||||
|
||||
# Project Overview
|
||||
|
||||
This project is a Nostr client application built with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify.
|
||||
@@ -20,7 +32,8 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
|
||||
- `/src/components/ui/`: shadcn/ui components (48+ components available)
|
||||
- `/src/components/auth/`: Authentication-related components (LoginArea, LoginDialog, etc.)
|
||||
- `/src/components/dm/`: Direct messaging UI components (DMMessagingInterface, DMConversationList, DMChatArea)
|
||||
- Zap components: `ZapButton`, `ZapDialog`, `WalletModal` for Lightning payments
|
||||
- `/src/components/SparkWallet/`: Self-custodial Lightning wallet UI (CreateWallet, RestoreWallet, SendPayment, ReceivePayment, WalletBalance, PaymentHistory, PaymentDetailDialog, UnclaimedDeposits, WasmUnsupportedError, MnemonicDisplay, MnemonicInput, plus `primitives/` with CircleAction, DateSeparator, TransactionRow, WalletTabBar, SettingsRow). **Branding note**: the user-facing name is "Agora Wallet"; everything in the codebase says "Spark" because that's the underlying SDK (`@breeztech/breez-sdk-spark`). When writing copy, say "Agora Wallet"; when writing code, keep the Spark naming.
|
||||
- Zap / wallet components: `ZapButton`, `ZapDialog`, `SparkWalletSettings` (self-custodial wallet settings panel), `WebLNSettings`, `NWCSettings`, `WalletSetupPrompt` for Lightning payments
|
||||
- `/src/hooks/`: Custom hooks including:
|
||||
- `useNostr`: Core Nostr protocol integration
|
||||
- `useAuthor`: Fetch user profile data by pubkey
|
||||
@@ -34,16 +47,25 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
|
||||
- `useLoggedInAccounts`: Manage multiple accounts
|
||||
- `useLoginActions`: Authentication actions
|
||||
- `useIsMobile`: Responsive design helper
|
||||
- `useZaps`: Lightning zap functionality with payment processing
|
||||
- `useWallet`: Unified wallet detection (WebLN + NWC)
|
||||
- `useZaps`: Lightning zap functionality with payment processing (Spark → NWC → WebLN → manual QR fallback chain)
|
||||
- `useWallet`: Unified wallet detection (Spark + WebLN + NWC)
|
||||
- `useNWC`: Nostr Wallet Connect connection management
|
||||
- `useNWCContext`: Access NWC context provider
|
||||
- `useSparkWallet`: Self-custodial Breez SDK Spark wallet (balance, send, receive, backup, Lightning Address, on-chain deposits)
|
||||
- `useNostrRecipientSearch`: Lightning-recipient picker backed by the profile search
|
||||
- `useBarcodeScanner`: Native QR code scanner wrapping `@capacitor/barcode-scanner`
|
||||
- `useExchangeRate` / `useSatsToUsd` / `useUsdToSats`: BTC/USD exchange rate with caching
|
||||
- `usePaymentContext` / `useEnrichedPayment`: Enrich Spark payments with matching Nostr zap receipts
|
||||
- `useShakespeare`: AI chat completions with Shakespeare AI API
|
||||
- `/src/pages/`: Page components used by React Router (Index, NotFound)
|
||||
- `/src/pages/`: Page components used by React Router (Index, NotFound, WalletPage at /wallet, WalletSettingsPage at /settings/wallet)
|
||||
- `/src/lib/`: Utility functions and shared logic
|
||||
- `/src/contexts/`: React context providers (AppContext, NWCContext, DMContext)
|
||||
- `/src/lib/spark/`: Breez SDK service wrapper (`breezService`), NIP-44 encrypted localStorage (`store`), NIP-78 relay backups (`backup`), restore rate limiter (`rateLimiter`), CSV export (`exportCsv`), date bucketing (`formatDateBucket`), build-time API-key availability check (`walletAvailability`)
|
||||
- `/src/lib/checkWasmSupport.ts`: Runtime WASM capability probe (iOS Lockdown Mode detection)
|
||||
- `/src/lib/logger.ts`: Development-only logger wrapping `console.*`
|
||||
- `/src/contexts/`: React context providers (AppContext, NWCContext, SparkWalletContext, DMContext)
|
||||
- `useDMContext`: Hook exported from DMContext for direct messaging (NIP-04 & NIP-17)
|
||||
- `useConversationMessages`: Hook exported from DMContext for paginated messages
|
||||
- `SparkWalletProvider` / `useSparkWalletContext`: Self-custodial Lightning wallet state and actions
|
||||
- `/src/test/`: Testing utilities including TestApp component
|
||||
- `/public/`: Static assets
|
||||
- `App.tsx`: Main app component with provider setup (**CRITICAL**: this file is **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider` and other important providers - **read this file before making changes**. Changes are usually not necessary unless adding new providers. Changing this file may break the application)
|
||||
@@ -409,6 +431,74 @@ Without filtering approvals by the moderator list, anyone could publish kind 455
|
||||
|
||||
Author filtering is not needed for public user-generated content where anyone should be able to post (kind 1 notes, reactions, discovery queries, public feeds, etc.).
|
||||
|
||||
#### Sanitizing URLs from Event Data
|
||||
|
||||
**CRITICAL**: Any URL extracted from Nostr event tags, content, or metadata fields is **untrusted user input**. Malicious URLs can cause harm in many ways beyond `javascript:` XSS — `data:` URIs for resource exhaustion, `http://` URLs leaking user IPs without TLS, relative paths triggering unintended requests to the app's own origin, and more. Reasoning about which rendering context is "safe enough" to skip sanitization is fragile and error-prone.
|
||||
|
||||
**Rule: sanitize every event-sourced URL unconditionally**, regardless of where it will be used (`href`, `img src`, `style`, etc.). Use `sanitizeUrl()` from `@/lib/sanitizeUrl`:
|
||||
|
||||
```typescript
|
||||
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` accepts `string | undefined | null` and 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`.
|
||||
|
||||
**Best practice — 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 extracted by regex that already constrains the protocol (e.g. `NoteContent` tokeniser matches only `https?://`)
|
||||
- Hardcoded or application-generated URLs (relay configs, internal routes, etc.)
|
||||
- URLs displayed as plain text without being placed into any HTML attribute or CSS value
|
||||
|
||||
#### Preventing CSS Injection from Event Data
|
||||
|
||||
**CRITICAL**: Any value from a Nostr event that is interpolated into a CSS string (inside a `<style>` element or inline `style` attribute) is a CSS injection vector. A malicious value containing `"`, `)`, `}`, or `;` can break out of the CSS context and inject arbitrary rules — for example, overlaying phishing content or hiding UI elements.
|
||||
|
||||
**Common CSS injection surfaces:**
|
||||
- `background-image: url("${url}")` — a URL with `"); body { display:none }` breaks out
|
||||
- `font-family: "${family}"` — a family name with `"; } body { visibility:hidden } .x {` breaks out
|
||||
- `@font-face { src: url("${url}") }` — same risk as background URLs
|
||||
|
||||
**Mitigation strategy — sanitize at the parse layer:**
|
||||
|
||||
1. **URLs in CSS `url()` values**: Pass through `sanitizeUrl()` at parse time. The `URL` constructor normalises the string, percent-encoding characters like `"`, `)`, and `\` that could escape the CSS context. Invalid or non-`https:` URLs are rejected entirely. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
|
||||
|
||||
2. **Strings in CSS declarations** (e.g. font family names): Use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens, underscores, apostrophes, and periods are permitted. Everything else is stripped.
|
||||
|
||||
```typescript
|
||||
// ❌ UNSAFE — raw event data interpolated into CSS
|
||||
const bgUrl = getTagValue(event.tags, 'bg');
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); }`;
|
||||
|
||||
const family = getTagValue(event.tags, 'f');
|
||||
style.textContent = `html { font-family: "${family}"; }`;
|
||||
|
||||
// ✅ SAFE — URLs validated, strings sanitised
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
const bgUrl = sanitizeUrl(getTagValue(event.tags, 'bg'));
|
||||
if (bgUrl) {
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); }`;
|
||||
}
|
||||
|
||||
// For non-URL strings, allowlist safe characters only
|
||||
const safeFamily = family.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
|
||||
style.textContent = `html { font-family: "${safeFamily}"; }`;
|
||||
```
|
||||
|
||||
**Rule of thumb**: Never interpolate untrusted strings into CSS without sanitisation. If it's a URL, use `sanitizeUrl()`. If it's any other string, strip characters that can break out of the CSS string context.
|
||||
|
||||
### The `useNostr` Hook
|
||||
|
||||
The `useNostr` hook returns an object containing a `nostr` property, with `.query()` and `.event()` methods for querying and publishing Nostr events respectively.
|
||||
@@ -1325,23 +1415,24 @@ Run available tools in this priority order:
|
||||
2. **Building/Compilation** (Required): Verify the project builds successfully
|
||||
3. **Linting** (Recommended): Check code style and catch potential issues
|
||||
4. **Tests** (If Available): Run existing test suite
|
||||
5. **Git Commit** (Required): Create a commit with your changes when finished
|
||||
|
||||
**Minimum Requirements:**
|
||||
- Code must type-check without errors
|
||||
- Code must build/compile successfully
|
||||
- Fix any critical linting errors that would break functionality
|
||||
- Create a git commit when your changes are complete
|
||||
- **Do NOT commit.** Leave changes uncommitted for the user to review. See the "ABSOLUTE, UNBREAKABLE RULE" at the top of this file.
|
||||
|
||||
The validation ensures code quality and catches errors before deployment, regardless of the development environment.
|
||||
|
||||
### 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.
|
||||
|
||||
### Using Git
|
||||
|
||||
If git is available in your environment (through a `shell` tool, or other git-specific tools), you should utilize `git log` to understand project history. Use `git status` and `git diff` to check the status of your changes, and if you make a mistake use `git checkout` to restore files.
|
||||
|
||||
When your changes are complete and validated, create a git commit with a descriptive message summarizing your changes.
|
||||
|
||||
**ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted changes.**
|
||||
When your changes are complete and validated, leave the working tree as-is for the user to review. **Do NOT create a git commit unless the user has explicitly told you to in the current message.** See the "ABSOLUTE, UNBREAKABLE RULE" at the top of this file — it overrides any habit or template guidance about always committing at the end of a task.
|
||||
|
||||
## Capacitor Compatibility
|
||||
|
||||
@@ -1412,7 +1503,7 @@ The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
|
||||
2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only)
|
||||
3. **build** - Builds a signed release APK (`build-apk` job, tags only)
|
||||
4. **release** - Creates a GitLab Release with the APK artifact (tags only)
|
||||
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
|
||||
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only) and AAB to Google Play (`publish-google-play` job, tags only)
|
||||
|
||||
### Creating a Release
|
||||
|
||||
@@ -1422,7 +1513,7 @@ Releases are triggered by pushing a version tag. Use the npm script:
|
||||
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`, and `publish-zapstore` stages.
|
||||
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` stages.
|
||||
|
||||
### Zapstore Publishing
|
||||
|
||||
@@ -1514,4 +1605,39 @@ The `--use-fallback-relays` and `--use-fallback-servers` flags also include nsyt
|
||||
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
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
|
||||
|
||||
### Google Play Publishing
|
||||
|
||||
The project automatically publishes Android AABs (App Bundles) to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). The `publish-google-play` CI job runs after a successful AAB build and uploads directly to the production track.
|
||||
|
||||
**GitLab CI/CD Variables** (Settings > 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 file. The CI job decodes it with `base64 -d` before passing it to `fastlane supply`. | Yes | Yes | No |
|
||||
|
||||
#### Initial Setup (one-time)
|
||||
|
||||
1. Create or reuse a project in the [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 the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). 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) since 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 changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.)
|
||||
- The same signing keystore used for Zapstore is used here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`)
|
||||
@@ -1,5 +1,127 @@
|
||||
# Changelog
|
||||
|
||||
## [2.8.0] - 2026-04-16
|
||||
|
||||
### 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
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# Contributing to Agora
|
||||
|
||||
We welcome contributions, but we have high standards. Agora is a carefully designed product with a specific vision, and every merge request must meet that bar. This guide exists to help you succeed.
|
||||
|
||||
**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.
|
||||
- `AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
|
||||
|
||||
## Understanding Agora
|
||||
|
||||
Agora is a carnival, not a platform. Before contributing, you need to understand what that means.
|
||||
|
||||
### The product decision filter
|
||||
|
||||
Every change to Agora should pass this test:
|
||||
|
||||
> *Does this make Agora more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
|
||||
|
||||
- **Magnetic** -- Agora attracts through experience, not ideology. People don't need to understand Nostr to love it. They need to feel something they haven't felt online since the early web. Features should be odd, intriguing, and captivating -- not generic social media clones.
|
||||
- **Threatening to the status quo** -- Agora threatens mainstream platforms when someone opens it and thinks: *"Why can't my platform do this?"* Theming, games, treasure hunts, interoperable micro-apps -- these are things walled gardens can't replicate.
|
||||
- **Peaceful to inhabit** -- Agora displaces argument with creation, conformity with expression, and consumption with participation. No ads, no engagement-optimized algorithms, no outrage incentives.
|
||||
|
||||
If a change does all three, it belongs. If it only does one, think harder. If it does none, it doesn't belong here.
|
||||
|
||||
### What Agora is NOT
|
||||
|
||||
- A Twitter/X clone with decentralization bolted on
|
||||
- A place to replicate features that mainstream platforms already do well
|
||||
- A showcase for generic UI components or boilerplate social features
|
||||
|
||||
### What Agora IS
|
||||
|
||||
- A convergence point for interoperable Nostr experiences (games, treasure hunts, magic decks, themes, color moments, live streams, and things nobody has imagined yet)
|
||||
- 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.
|
||||
|
||||
## What we accept
|
||||
|
||||
### Bug fixes
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
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)).
|
||||
2. Prove it works and get user feedback.
|
||||
3. Open an issue to discuss integration.
|
||||
|
||||
**Feature MRs that don't link to an issue or don't align with the Agora Philosophy will be closed.** Our open issues are our internal roadmap -- some require deep product context. If your implementation doesn't match the product vision, it will be closed regardless of code quality.
|
||||
|
||||
## Required tools
|
||||
|
||||
- **Claude Opus 4.6** (or the latest frontier model) -- not Sonnet, not GPT-4o, not local models. Quality depends on model quality.
|
||||
- **An AI coding agent with plan/research mode** -- [OpenCode](https://opencode.ai), [Shakespeare](https://shakespeare.diy), Cursor, or similar.
|
||||
- **Node.js 22+** and npm 10.9.4+.
|
||||
|
||||
## The contribution workflow
|
||||
|
||||
Follow these steps in order. Skipping steps is the most common reason MRs are rejected.
|
||||
|
||||
### 1. Ask: does anyone need this?
|
||||
|
||||
Before writing a single line of code, answer this honestly. For bug fixes this is straightforward -- someone hit the bug. For features, it requires more thought. Is there evidence of real user demand? Is the underlying technology mature enough? A beautifully written feature for a nonexistent user base is the wrong thing to build. If you can't point to a concrete user need, reconsider.
|
||||
|
||||
### 2. Understand the issue
|
||||
|
||||
Read the issue thoroughly. If anything is unclear, ask in the issue comments before writing code. Understand not just *what* to change, but *why* -- what problem does this solve for users?
|
||||
|
||||
### 3. Read the codebase conventions
|
||||
|
||||
Read `AGENTS.md` in the repo root. This is the single source of truth for how code should be written in this project. Your AI tool should load this file automatically. If it doesn't, paste it in or configure your tool to read it.
|
||||
|
||||
### 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.
|
||||
|
||||
### 5. Plan before you code
|
||||
|
||||
Start your AI tool in **plan mode** (or research/think mode). Spend the first few prompts:
|
||||
|
||||
- Exploring the existing codebase to understand how similar features are implemented
|
||||
- Reading the files you'll need to modify
|
||||
- Proposing an approach
|
||||
|
||||
Do not write code until you have a plan. The most expensive mistake is implementing the wrong approach.
|
||||
|
||||
### 6. Implement
|
||||
|
||||
Switch to code mode and implement your plan. Use Opus 4.6 or equivalent.
|
||||
|
||||
### 7. Run the test suite
|
||||
|
||||
```sh
|
||||
npm run test
|
||||
```
|
||||
|
||||
This runs type-checking, linting, unit tests, and a production build. All must pass. Do not submit an MR with a failing test suite.
|
||||
|
||||
### 8. Self-review
|
||||
|
||||
Run this prompt against your diff (copy the full `git diff` output and paste it to your AI tool along with this prompt):
|
||||
|
||||
```
|
||||
Review this diff as if you are a senior maintainer of this codebase who has to
|
||||
maintain it long-term. For each finding, state the file, line, and issue.
|
||||
|
||||
- [ ] Does the diff contain changes that weren't requested? Flag anything out of scope.
|
||||
- [ ] Is there dead code, commented-out blocks, or debug artifacts left in?
|
||||
- [ ] Are there placeholder comments like "// In a real app..." or "// TODO: implement"?
|
||||
- [ ] For every value displayed to a user, can you trace it from source to render without a gap?
|
||||
- [ ] Are error, loading, and empty states all handled -- and in the right order?
|
||||
- [ ] Does a mutation reflect in the UI without requiring a manual refresh?
|
||||
- [ ] Is there a new read/write path that assumes fresh data but could get a stale cache?
|
||||
- [ ] For replaceable/addressable Nostr events: is fetchFreshEvent used before mutation?
|
||||
- [ ] Does anything new block the critical render path or fire N+1 network requests?
|
||||
- [ ] Are Nostr queries efficient (combined kinds, relay-level filtering vs client-side)?
|
||||
- [ ] Are user inputs used in queries or rendered as content without sanitization?
|
||||
- [ ] Were existing patterns/conventions in AGENTS.md ignored in favor of something novel?
|
||||
- [ ] Are secrets, keys, or env-specific values hardcoded?
|
||||
- [ ] Does the code use the `any` type anywhere?
|
||||
- [ ] Is the code Capacitor-compatible (no `<a download>`, no `window.open()`)?
|
||||
- [ ] Are new Nostr event kinds documented in NIP.md with links to relevant specs?
|
||||
- [ ] 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?
|
||||
|
||||
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
|
||||
|
||||
Then answer: "If you were the people who have to maintain this codebase and deal
|
||||
with all long-term issues, what would be your biggest concerns about this
|
||||
implementation?"
|
||||
```
|
||||
|
||||
Address every finding before submitting.
|
||||
|
||||
### 9. Deploy a live preview
|
||||
|
||||
Deploy your branch so reviewers can test it without pulling your code:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npx surge dist your-branch-name.surge.sh
|
||||
```
|
||||
|
||||
Or use Netlify, Vercel, or any static hosting. Include the live preview URL in your MR description.
|
||||
|
||||
### 10. Take screenshots
|
||||
|
||||
Capture before and after screenshots of any UI changes. Include them directly in the MR description. If your change has no visual component, state that explicitly.
|
||||
|
||||
### 11. Submit
|
||||
|
||||
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)
|
||||
- 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)
|
||||
- Placeholder code, dead code, or debug artifacts
|
||||
- Evidence of low-quality AI generation ("In a real application..." comments, hallucinated APIs, generic template code)
|
||||
- Failing test suite
|
||||
- No evidence of planning (code-first, think-later approach produces recognizable patterns)
|
||||
- Undocumented Nostr event kinds (new kinds must be in NIP.md)
|
||||
- Large binary assets committed to git (images >100KB, fonts, videos)
|
||||
- Security issues (dangerouslySetInnerHTML, eval, innerHTML, unsanitized user input)
|
||||
|
||||
## MR review process
|
||||
|
||||
1. The CI pipeline validates your MR description automatically. If it fails, read the error message and fix your MR description.
|
||||
2. Maintainers will review your MR when all CI checks pass and the template is complete.
|
||||
3. If changes are requested, address them promptly. Stale MRs will be closed.
|
||||
|
||||
We appreciate your interest in contributing. These standards exist because reviewing a low-quality MR takes 3x longer than doing the work ourselves. Help us help you by following the process.
|
||||
@@ -10,6 +10,21 @@
|
||||
| 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
|
||||
|
||||
| Kind | Name | Description |
|
||||
|-------|----------------------------|----------------------------------------------------------------|
|
||||
| 20000 | Ephemeral Geo Chat (public) | Geo-anchored ephemeral chat message (kind 20000, public) |
|
||||
| 20001 | Ephemeral Geo Heartbeat | Geo-anchored ephemeral presence heartbeat (kind 20001) |
|
||||
| 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards |
|
||||
| 36639 | Activist Action | Country-scoped activist challenge with a sats bounty |
|
||||
|
||||
### Agora Protocols
|
||||
|
||||
| Protocol | Composed Kinds | Description |
|
||||
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
|
||||
| Hierarchical Communities | 34550, 30009, 8, 1111, 1984, 5 | Ranked community membership via badge award chains (NIP-72 ext) |
|
||||
|
||||
### Community Kinds
|
||||
|
||||
These event kinds were created by community contributors and are supported by Ditto. Full specifications are maintained by their respective authors.
|
||||
@@ -288,6 +303,573 @@ After resolution (assuming `$follows` = `["pk1", "pk2"]`):
|
||||
|
||||
---
|
||||
|
||||
## Kind 36639: Activist Action
|
||||
|
||||
### Summary
|
||||
|
||||
Addressable event kind for publishing **activist actions** (called "challenges" internally for backwards compatibility). An action is a country-scoped task — take a photo, make art, gather information, or take direct action — with an optional sats bounty paid out via NIP-57 zaps to the best **submissions**.
|
||||
|
||||
Submissions are **NIP-22 comments** (kind 1111) authored under the action's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse.
|
||||
|
||||
### Trust model
|
||||
|
||||
Anyone can publish a kind 36639 event, but clients SHOULD only display actions whose author is either:
|
||||
|
||||
1. A platform-level admin (see `src/lib/admins.ts`), or
|
||||
2. An organizer for the action's country (see kind 30078 `agora-organizers`).
|
||||
|
||||
This authorization model is identical to the per-country pin model — see Kind 30078 in this document for the storage shape.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 36639,
|
||||
"content": "<long-form description, freeform markdown-ish text>",
|
||||
"tags": [
|
||||
["d", "plant-a-tree-1729000000000"],
|
||||
["title", "Plant a tree in your neighborhood"],
|
||||
["challenge-type", "photo"],
|
||||
["bounty", "10000"],
|
||||
["i", "iso3166:US"],
|
||||
["t", "agora-action"],
|
||||
["image", "https://example.com/cover.jpg"],
|
||||
["start", "1729000000"],
|
||||
["deadline", "1729604800"],
|
||||
["alt", "Agora activist action: Plant a tree in your neighborhood"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|------------------|----------|----------------------------------------------------------------------------------------------------------|
|
||||
| `d` | Yes | Unique identifier (typically slug + timestamp). Forms the addressable coordinate `36639:<pubkey>:<d>`. |
|
||||
| `title` | Yes | Short title shown on cards. |
|
||||
| `challenge-type` | Yes | One of `photo`, `art`, `info`, `action`. Drives the display icon and submission expectations. |
|
||||
| `bounty` | Yes | Bounty in **sats**, as an unsigned integer string. Paid out via zaps to the chosen submission(s). |
|
||||
| `i` | Yes | NIP-73 country identifier: `iso3166:XX` (preferred). Legacy `geo:XX` (length 6, country code only) is accepted as a read alias. Optionally combined with a `location` tag fallback. |
|
||||
| `t` | Yes | Discovery tag. Canonical write value is `agora-action`. Read aliases: `pathos-challenge`, `agora-challenge`. |
|
||||
| `image` | No | Cover image URL. |
|
||||
| `start` | No | Unix timestamp when the action becomes active. Defaults to `created_at`. |
|
||||
| `deadline` | No | Unix timestamp when the action expires. Defaults to `start + 48h`. |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora activist action: <title>"`. |
|
||||
|
||||
### Content
|
||||
|
||||
Long-form description of the action. Plain text or light markdown. Clients render this as the action's body on the detail page.
|
||||
|
||||
### Submissions
|
||||
|
||||
Submissions are kind 1111 NIP-22 comments addressed to the action's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD:
|
||||
|
||||
- Sort top-level submissions by **total zap amount** (sum of NIP-57 zap receipts on each submission), descending.
|
||||
- Show the bounty as the prize pool that organizers can distribute to top submissions via zaps.
|
||||
- Hide submissions with `created_at` after the action's `deadline` for "past" leaderboards (or surface them separately as "late submissions").
|
||||
|
||||
### Discovery
|
||||
|
||||
Clients querying actions globally:
|
||||
|
||||
```json
|
||||
{ "kinds": [36639], "#t": ["agora-action", "pathos-challenge", "agora-challenge"], "limit": 50 }
|
||||
```
|
||||
|
||||
Per country:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [36639],
|
||||
"#t": ["agora-action", "pathos-challenge", "agora-challenge"],
|
||||
"#i": ["iso3166:US", "geo:US"],
|
||||
"limit": 50
|
||||
}
|
||||
```
|
||||
|
||||
After fetching, clients MUST filter the results down to events whose author is either an admin or an organizer for the event's country.
|
||||
|
||||
---
|
||||
|
||||
## Kind 30385: Community Stats Snapshot
|
||||
|
||||
### Summary
|
||||
|
||||
Addressable event kind for **pre-computed community statistics** (per-country and global). A trusted off-app indexer (the "stats bot") publishes one event per scope:
|
||||
|
||||
- **Per-country**: `d` tag is `iso3166:XX` (ISO 3166-1 alpha-2 country code).
|
||||
- **Global**: `d` tag is `iso3166:ZZ` — `ZZ` is the ISO 3166-1 user-assigned code Agora uses for the cross-country aggregate.
|
||||
|
||||
Each event contains aggregate counts (comments, authors, zaps, submissions) and ranked leaderboards (top posters, trending hashtags, top zapped authors, top donors, top actions) across multiple time windows (`7d`, `30d`, `90d`, all-time). Storing pre-computed leaderboards in a single event lets clients render community pages without scanning thousands of underlying events.
|
||||
|
||||
### Trust model
|
||||
|
||||
Anyone can publish kind 30385, but clients MUST only consume events from trusted authors:
|
||||
|
||||
- **Per-country events**: trusted authors are platform admins (`src/lib/admins.ts`) **plus** appointed organizers for that specific country (kind 30078 `agora-organizers`).
|
||||
- **Global event** (`iso3166:ZZ`): trusted authors are platform admins only.
|
||||
|
||||
When multiple trusted events exist for the same scope, clients pick the most recent by `created_at`.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30385,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["d", "iso3166:US"],
|
||||
|
||||
["comment_cnt", "12345"],
|
||||
["comment_cnt_7d", "789"],
|
||||
["comment_cnt_30d", "3421"],
|
||||
["comment_cnt_90d", "9876"],
|
||||
["author_cnt", "543"],
|
||||
["zap_amount", "123456789"],
|
||||
["zap_cnt", "1234"],
|
||||
["submission_cnt", "456"],
|
||||
|
||||
["top_poster", "<pubkey-hex>", "987"],
|
||||
["top_poster_7d", "<pubkey-hex>", "42"],
|
||||
|
||||
["trending_hashtag", "climate", "321"],
|
||||
["trending_hashtag_7d", "protest", "67"],
|
||||
|
||||
["top_zapped", "<pubkey-hex>", "<totalSats>", "<postCount>", "<avgSats>", "<zapCount>"],
|
||||
["top_donor", "<pubkey-hex>", "<totalSats>", "<zapCount>"],
|
||||
|
||||
["top_action", "36639:<pubkey>:<d-tag>", "Plant a tree", "<submissions>", "<bounty>", "<zapAmountSats>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Tag families
|
||||
|
||||
All numeric values are unsigned integers serialised as base-10 strings.
|
||||
|
||||
#### Aggregate counts (one tag per metric per timeframe)
|
||||
|
||||
| Tag base name | Meaning |
|
||||
|--------------------|--------------------------------------------------------|
|
||||
| `comment_cnt` | Number of NIP-22 comments in scope |
|
||||
| `author_cnt` | Distinct author pubkeys in scope |
|
||||
| `zap_amount` | Total zap amount in **sats** |
|
||||
| `zap_cnt` | Number of NIP-57 zap receipts |
|
||||
| `submission_cnt` | Submissions to activist actions (kind 36639) |
|
||||
|
||||
Each is published as four tags: bare (`<base>`, all-time), `<base>_7d`, `<base>_30d`, `<base>_90d`.
|
||||
|
||||
#### Leaderboards (repeated; one tag per row, ordered by rank)
|
||||
|
||||
All-time variants use the bare tag name; windowed variants use the `_7d`, `_30d`, `_90d` suffixes.
|
||||
|
||||
| Tag base name | Positional fields |
|
||||
|----------------------|-----------------------------------------------------------------------------------------------|
|
||||
| `top_poster` | `[name, pubkeyHex, count]` |
|
||||
| `trending_hashtag` | `[name, hashtag, count]` |
|
||||
| `top_zapped` | `[name, pubkeyHex, totalSats, postCount, avgSats, zapCount]` (`zapCount` optional, legacy) |
|
||||
| `top_donor` | `[name, pubkeyHex, totalSats, zapCount]` |
|
||||
| `top_action` | `[name, "36639:<pubkey>:<d>", title, submissions, bounty, zapAmountSats]` |
|
||||
|
||||
Clients SHOULD parse defensively — accept missing trailing fields as `0` or omitted to maintain backwards compatibility as the schema evolves.
|
||||
|
||||
### Content
|
||||
|
||||
Empty string. All data lives in tags so relays can index/filter and clients don't need to parse JSON.
|
||||
|
||||
### Discovery
|
||||
|
||||
Per-country snapshot:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [30385],
|
||||
"authors": [<admin and organizer pubkeys>],
|
||||
"#d": ["iso3166:US"],
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
Global snapshot:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [30385],
|
||||
"authors": [<admin pubkeys>],
|
||||
"#d": ["iso3166:ZZ"],
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
After fetching, take the event with the highest `created_at` and parse it. Cache for ~1–2 minutes; the producer typically refreshes on a similar cadence.
|
||||
|
||||
---
|
||||
|
||||
## Kinds 20000 / 20001: Ephemeral Geo Chat
|
||||
|
||||
### Summary
|
||||
|
||||
Ephemeral events used to power realtime location-anchored chat on the world map. Both kinds live in NIP-01's ephemeral range (`20000 ≤ kind < 30000`), so relays MUST NOT persist them — they are short-lived signals only.
|
||||
|
||||
- **Kind 20000** — public chat message. The `content` field carries the message text.
|
||||
- **Kind 20001** — presence "heartbeat". Same tag schema, but `content` MAY be empty (the event simply broadcasts that someone is listening at the geohash).
|
||||
|
||||
This kind range is shared with the wider Bitchat / geo-chat ecosystem; Agora interoperates with Pathos and other clients producing the same shape.
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Purpose |
|
||||
|-----|----------|-------------------------------------------------------------------------|
|
||||
| `g` | Yes | Geohash anchoring the message. Any precision is allowed; the dialog filters by exact-match `g` value, while the map clusters by full geohash. |
|
||||
| `n` | No | Display nickname (≤ 16 chars after client-side truncation). Anonymous senders pick a random "ghost" handle; logged-in senders may use their account display name. |
|
||||
|
||||
Events without a `g` tag MUST be ignored — they cannot be plotted.
|
||||
|
||||
### Identity
|
||||
|
||||
There are two valid signing paths:
|
||||
|
||||
1. **Real identity** — a logged-in user signs with their existing Nostr key (typically via NIP-07 / NIP-46). Other clients can correlate the chat message with the author's public profile.
|
||||
2. **Ephemeral "ghost" identity** — the client generates a fresh in-memory keypair (never persisted) and signs locally. Only the chosen `n` nickname is persisted (in `localStorage`) so the user keeps a stable handle even though the pubkey rotates per session.
|
||||
|
||||
Clients SHOULD let logged-in users toggle between modes per-session and SHOULD default to the ghost mode when no account is available.
|
||||
|
||||
### Relay Routing
|
||||
|
||||
Because ephemeral events are not stored, latency dominates the experience. Clients SHOULD:
|
||||
|
||||
1. Always include a baseline of widely-reachable relays (`wss://nos.lol`, `wss://relay.damus.io`, `wss://relay.primal.net`).
|
||||
2. Augment with geo-located relays drawn from the [permissionlesstech/georelays](https://github.com/permissionlesstech/georelays) CSV catalogue (`relayUrl,latitude,longitude` per line).
|
||||
3. For a specific geohash conversation, prefer the relays nearest the decoded coordinates (Haversine distance, top-N).
|
||||
4. For the global map heatmap, take a rotating window (e.g. 8 relays, rotated every 5 minutes) so coverage spreads without saturating any single relay.
|
||||
|
||||
### Time Window
|
||||
|
||||
Clients SHOULD only surface events from the last hour (`since = now - 3600`). Older ephemeral events are uninteresting for "what's happening right now" and most relays will have dropped them anyway.
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 20000,
|
||||
"created_at": 1734567890,
|
||||
"pubkey": "...",
|
||||
"tags": [
|
||||
["g", "u4pruydqqvj"],
|
||||
["n", "stealthranger4242"]
|
||||
],
|
||||
"content": "anyone in berlin tonight?",
|
||||
"sig": "..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hierarchical Communities
|
||||
|
||||
Hierarchical communities on Nostr, composed from existing event kinds. Communities have ranked membership where authority flows downward through a chain of badge awards.
|
||||
|
||||
**No new event kinds are introduced.** The system composes:
|
||||
|
||||
- **Kind 34550** ([NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)) -- Community Definition
|
||||
- **Kind 30009** ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)) -- Badge Definition
|
||||
- **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:
|
||||
|
||||
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.
|
||||
|
||||
### Membership Derivation
|
||||
|
||||
Community membership is derived from three distinct sources, each resolved differently:
|
||||
|
||||
- **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).
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
#### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|-----|----------|-------------|
|
||||
| `d` | Yes | Unique community identifier (UUID recommended). |
|
||||
| `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"`. |
|
||||
| `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>"]
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
#### Example
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 34550,
|
||||
"pubkey": "<founder-pubkey>",
|
||||
"content": "",
|
||||
"tags": [
|
||||
["d", "a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
|
||||
["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"],
|
||||
["p", "<co-moderator-pubkey>", "", "moderator"],
|
||||
["relay", "wss://relay.example.com"],
|
||||
["alt", "Community: The Arbiter's Guard"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 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 `d` tag SHOULD use the format `<community-d-tag>-<rank-name>` for global uniqueness.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 30009,
|
||||
"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"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
```jsonc
|
||||
// Moderator (rank 0) awarding Staff (rank 1)
|
||||
{
|
||||
"kind": 8,
|
||||
"pubkey": "<founder-pubkey>",
|
||||
"content": "",
|
||||
"tags": [
|
||||
["a", "30009:<founder-pubkey>:a1b2c3d4-...-staff"],
|
||||
["p", "<recipient-pubkey>"],
|
||||
["alt", "Badge award: Staff in The Arbiter's Guard"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Chain Validation
|
||||
|
||||
Membership is **derived state**. Clients compute effective membership by resolving the authority graph from badge awards, then applying moderation overlays.
|
||||
|
||||
#### 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.
|
||||
|
||||
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.
|
||||
|
||||
### Community Posts
|
||||
|
||||
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.
|
||||
|
||||
#### Top-Level Post
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1111,
|
||||
"content": "Hello clan!",
|
||||
"tags": [
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>", "<relay-hint>"],
|
||||
["K", "34550"],
|
||||
["P", "<founder-pubkey>", "<relay-hint>"],
|
||||
["a", "34550:<founder-pubkey>:<community-d-tag>", "<relay-hint>"],
|
||||
["k", "34550"],
|
||||
["p", "<founder-pubkey>", "<relay-hint>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Reply
|
||||
|
||||
Replies keep the community as root scope and point to the parent comment:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1111,
|
||||
"content": "Great point!",
|
||||
"tags": [
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>", "<relay-hint>"],
|
||||
["K", "34550"],
|
||||
["P", "<founder-pubkey>", "<relay-hint>"],
|
||||
["e", "<parent-comment-id>", "<relay-hint>", "<parent-author-pubkey>"],
|
||||
["k", "1111"],
|
||||
["p", "<parent-author-pubkey>", "<relay-hint>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Querying
|
||||
|
||||
Fetch all community-scoped posts and moderation data in a single request:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kinds": [1111, 1984],
|
||||
"#A": ["34550:<founder-pubkey>:<community-d-tag>"]
|
||||
}
|
||||
```
|
||||
|
||||
Clients then filter client-side: discard kind `1111` posts from non-members, and apply authoritative kind `1984` reports per the moderation rules below.
|
||||
|
||||
### 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**.
|
||||
|
||||
#### Authority Rules
|
||||
|
||||
A report is **authoritative** if:
|
||||
|
||||
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).
|
||||
|
||||
Reports from non-members or insufficiently-ranked members are ignored.
|
||||
|
||||
#### Content Removal
|
||||
|
||||
Hide a post by publishing kind `1984` with both `e` and `p` tags:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1984,
|
||||
"pubkey": "<moderator-pubkey>",
|
||||
"content": "Spam",
|
||||
"tags": [
|
||||
["e", "<offending-event-id>"],
|
||||
["p", "<offending-author-pubkey>"],
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Member Ban
|
||||
|
||||
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.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 1984,
|
||||
"pubkey": "<moderator-pubkey>",
|
||||
"content": "Violated guidelines",
|
||||
"tags": [
|
||||
["p", "<banned-member-pubkey>"],
|
||||
["A", "34550:<founder-pubkey>:<community-d-tag>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients distinguish content removal (`e` + `p` + `A`) from bans (`p` + `A`, no `e`).
|
||||
|
||||
#### Reinstatement
|
||||
|
||||
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.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 5,
|
||||
"tags": [["e", "<kind-1984-event-id>"], ["k", "1984"]]
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### Discovery
|
||||
|
||||
**Communities founded by a user:**
|
||||
|
||||
```jsonc
|
||||
{ "kinds": [34550], "authors": ["<user-pubkey>"] }
|
||||
```
|
||||
|
||||
**Communities a user belongs to:**
|
||||
|
||||
1. `{ "kinds": [8], "#p": ["<user-pubkey>"] }`
|
||||
2. Extract badge `a` tags from results.
|
||||
3. `{ "kinds": [34550], "#a": ["30009:...", "..."] }`
|
||||
|
||||
### 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.
|
||||
|
||||
### 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-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
|
||||
|
||||
---
|
||||
|
||||
## Kind 0 Extension: Avatar Shape
|
||||
|
||||
### Summary
|
||||
@@ -361,3 +943,21 @@ Kind 16158 (replaceable) describes a weather station's configuration: name, geoh
|
||||
|
||||
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 11125 `content` JSON — `missions` field
|
||||
|
||||
The `content` of kind 11125 is a JSON object. Ditto extends it with a `missions` field that tracks daily and evolution mission progress:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"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.)
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
# Ditto
|
||||
# Agora
|
||||
|
||||
Your content. Your vibe. Your rules. A fun, customizable [Nostr](https://nostr.com/) client that puts you in control.
|
||||
Power to the people.
|
||||
|
||||
**[ditto.pub](https://ditto.pub)** | **[Docs](https://docs.ditto.pub)** | **[Source](https://gitlab.com/soapbox-pub/ditto)**
|
||||
Agora is a Nostr client focused on community ownership, expressive identity, and censorship resistance. This repository (`agora-3`) is the Agora-branded app built from the Ditto codebase.
|
||||
|
||||
## About
|
||||
**[agora.spot](https://agora.spot)** | **[Source](https://gitlab.com/soapbox-pub/agora-3)**
|
||||
|
||||
Ditto is an open-source, decentralized social media client built on the Nostr protocol. It's designed for people who want to have fun online without feeding the Big Tech machine. Express yourself with custom themes, Lightning payments, and an ever-growing set of content types -- all while owning your identity and data.
|
||||
## What This Repo Is
|
||||
|
||||
Made by [Soapbox](https://soapbox.pub).
|
||||
- Agora product identity (name, theme, assets, native IDs)
|
||||
- Ditto-derived implementation with broad Nostr feature coverage
|
||||
- Configurable deployment defaults via `agora.json`
|
||||
|
||||
## Features
|
||||
|
||||
- **Theming** -- 9 built-in theme presets, 19 CSS token properties for full customization, and the ability to publish and share themes as Nostr events
|
||||
- **Infinite Content Types** -- Text notes, articles, short-form videos (Divines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
|
||||
- **Lightning Payments** -- Zap posts and profiles with sats via Nostr Wallet Connect (NWC) or WebLN
|
||||
- **Private Messaging** -- End-to-end encrypted DMs (NIP-04 and NIP-17)
|
||||
- **Comments** -- Comment on anything: posts, URLs, profiles, hashtags, books, and more (NIP-22)
|
||||
- **Self-Hosting** -- Builds to static HTML/JS/CSS. Deploy anywhere -- GitHub Pages, Netlify, Vercel, a VPS, or a Raspberry Pi
|
||||
- **Mobile** -- Android native app via Capacitor, responsive design for all screen sizes
|
||||
- **Community-first social client**: notes, articles, comments, reposts, reactions, and rich event rendering
|
||||
- **Theming system**: built-in presets + custom color/font/background themes that can be shared as events
|
||||
- **Lightning support**: zaps with Nostr Wallet Connect and WebLN
|
||||
- **Private messaging**: NIP-04 and NIP-17 direct messages
|
||||
- **Mobile app shell**: Capacitor-powered Android/iOS wrappers
|
||||
- **Self-hostable**: static web build + configurable relay and upload infrastructure
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -30,13 +31,13 @@ Made by [Soapbox](https://soapbox.pub).
|
||||
### Development
|
||||
|
||||
```sh
|
||||
git clone https://gitlab.com/soapbox-pub/ditto.git
|
||||
cd ditto
|
||||
git clone https://gitlab.com/soapbox-pub/agora-3.git
|
||||
cd agora-3
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev server starts at `http://localhost:8080`.
|
||||
Development server: `http://localhost:8080`
|
||||
|
||||
### Build
|
||||
|
||||
@@ -44,66 +45,58 @@ The dev server starts at `http://localhost:8080`.
|
||||
npm run build
|
||||
```
|
||||
|
||||
The built site is output to `dist/`.
|
||||
Build output: `dist/`
|
||||
|
||||
### Test
|
||||
|
||||
Runs type-checking, linting, unit tests, and a production build:
|
||||
### Validate
|
||||
|
||||
```sh
|
||||
npm test
|
||||
```
|
||||
|
||||
This runs type-checking, linting, unit tests, and production build checks.
|
||||
|
||||
## Configuration
|
||||
|
||||
Ditto is configured through a `ditto.json` file at the project root, read at build time. This file is gitignored so each deployment can have its own configuration.
|
||||
Build-time config is read from `agora.json` (gitignored by default so each deployment can provide its own values).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"theme": "dark",
|
||||
"relayMetadata": {
|
||||
"relays": [
|
||||
{ "url": "wss://relay.ditto.pub", "read": true, "write": true }
|
||||
{ "url": "wss://relay.ditto.pub", "read": true, "write": true },
|
||||
{ "url": "wss://relay.primal.net", "read": true, "write": true },
|
||||
{ "url": "wss://relay.damus.io", "read": true, "write": true }
|
||||
]
|
||||
},
|
||||
"blossomServers": ["https://blossom.ditto.pub"],
|
||||
"feedSettings": {
|
||||
"showPosts": true,
|
||||
"showReposts": true,
|
||||
"showArticles": true
|
||||
// ...and more content type toggles
|
||||
}
|
||||
"blossomServers": [
|
||||
"https://blossom.ditto.pub",
|
||||
"https://blossom.primal.net/"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Configuration is resolved in three layers (highest priority first):
|
||||
Configuration priority (highest first):
|
||||
|
||||
1. **User settings** stored in localStorage
|
||||
2. **Build config** from `ditto.json`
|
||||
3. **Hardcoded defaults**
|
||||
1. User settings (local storage)
|
||||
2. Build config (`agora.json`)
|
||||
3. Hardcoded app defaults
|
||||
|
||||
Use an alternate config file path with: `CONFIG_FILE=./my-config.json npm run build`
|
||||
Use a custom config path:
|
||||
|
||||
### Custom Branding
|
||||
|
||||
For self-hosted instances:
|
||||
|
||||
- Replace `public/logo.svg` and `public/logo.png` with your logo
|
||||
- Update the app name in `index.html` and `public/manifest.webmanifest`
|
||||
- Replace `public/og-image.jpg` for social sharing previews
|
||||
- Set default relays and upload servers in `ditto.json`
|
||||
```sh
|
||||
CONFIG_FILE=./my-config.json npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Ditto builds to static files and can be deployed anywhere that serves HTML.
|
||||
Agora builds to static files and can be deployed to any static host.
|
||||
|
||||
- **GitHub Pages / GitLab Pages** -- Push to `main` and CI auto-deploys
|
||||
- **Netlify / Vercel** -- Connect your fork and deploy. A `_redirects` file is included for SPA routing
|
||||
- **VPS / Any web server** -- Build and copy `dist/` to your server. Configure SPA routing (e.g., Nginx `try_files $uri $uri/ /index.html`)
|
||||
- GitLab/GitHub Pages
|
||||
- Netlify/Vercel
|
||||
- VPS or any web server with SPA routing fallback
|
||||
|
||||
### Android
|
||||
|
||||
Build a native Android app with [Capacitor](https://capacitorjs.com/):
|
||||
For Android:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
@@ -114,29 +107,20 @@ npx cap open android
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| Framework | React 18 |
|
||||
| Build | Vite |
|
||||
| Language | TypeScript |
|
||||
| Styling | TailwindCSS 3 + shadcn/ui |
|
||||
| Routing | React Router 6 |
|
||||
| Routing | React Router |
|
||||
| Data | TanStack Query |
|
||||
| Nostr | Nostrify + nostr-tools |
|
||||
| Mobile | Capacitor |
|
||||
| Testing | Vitest + React Testing Library |
|
||||
|
||||
## Project Structure
|
||||
## Contributing
|
||||
|
||||
```
|
||||
src/
|
||||
components/ UI components (100+), including shadcn/ui primitives
|
||||
hooks/ Custom React hooks (65+)
|
||||
pages/ Page components for each route (30+)
|
||||
contexts/ React context providers
|
||||
lib/ Utilities and shared logic
|
||||
test/ Test setup and helpers
|
||||
public/ Static assets, icons, manifest
|
||||
```
|
||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a merge request.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "pub.ditto.app"
|
||||
namespace = "pub.agora.app"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "pub.ditto.app"
|
||||
applicationId "pub.agora.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.6.1"
|
||||
versionName "2.8.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -10,10 +10,14 @@ android {
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-barcode-scanner')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-keyboard')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capgo-capacitor-autofill-save-password')
|
||||
implementation project(':capacitor-secure-storage-plugin')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@@ -22,12 +24,12 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep links: open ditto.pub URLs in the app -->
|
||||
<!-- Deep links: open agora.spot URLs in the app -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="ditto.pub" />
|
||||
<data android:scheme="https" android:host="agora.spot" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
@@ -56,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.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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;
|
||||
@@ -13,6 +15,8 @@ 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;
|
||||
@@ -30,6 +34,8 @@ 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.
|
||||
@@ -79,19 +85,41 @@ public class SandboxPlugin extends Plugin {
|
||||
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
|
||||
sandboxes.put(sandboxId, sandbox);
|
||||
|
||||
// Add the WebView on top of the Capacitor WebView.
|
||||
// The parent is a CoordinatorLayout — using the wrong LayoutParams
|
||||
// type causes a ClassCastException when it intercepts touch events.
|
||||
// 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.webView, params);
|
||||
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;
|
||||
}
|
||||
|
||||
// Load the initial page.
|
||||
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
@@ -131,7 +159,7 @@ public class SandboxPlugin extends Plugin {
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
sandbox.webView.setLayoutParams(params);
|
||||
sandbox.container.setLayoutParams(params);
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
@@ -214,9 +242,9 @@ public class SandboxPlugin extends Plugin {
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.remove(sandboxId);
|
||||
if (sandbox != null) {
|
||||
ViewGroup parent = (ViewGroup) sandbox.webView.getParent();
|
||||
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
|
||||
if (parent != null) {
|
||||
parent.removeView(sandbox.webView);
|
||||
parent.removeView(sandbox.container);
|
||||
}
|
||||
sandbox.webView.destroy();
|
||||
}
|
||||
@@ -244,13 +272,19 @@ public class SandboxPlugin extends Plugin {
|
||||
*/
|
||||
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();
|
||||
@@ -260,13 +294,53 @@ public class SandboxPlugin extends Plugin {
|
||||
settings.setAllowContentAccess(false);
|
||||
settings.setDatabaseEnabled(true);
|
||||
|
||||
webView.setBackgroundColor(Color.WHITE);
|
||||
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) {
|
||||
@@ -353,8 +427,11 @@ public class SandboxPlugin extends Plugin {
|
||||
// Emit to JS.
|
||||
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
|
||||
|
||||
// Block this thread until JS responds (with a timeout).
|
||||
WebResourceResponse response = pending.awaitResponse(10000);
|
||||
// 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;
|
||||
@@ -377,6 +454,11 @@ public class SandboxPlugin extends Plugin {
|
||||
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() {
|
||||
@@ -446,11 +528,12 @@ public class SandboxPlugin extends Plugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending request that blocks the WebViewClient thread until resolved.
|
||||
* A pending request that blocks the WebViewClient IO thread until JS
|
||||
* responds with the complete resource.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private WebResourceResponse response;
|
||||
private final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
|
||||
private volatile WebResourceResponse response;
|
||||
private final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
void resolve(WebResourceResponse response) {
|
||||
this.response = response;
|
||||
@@ -459,7 +542,7 @@ public class SandboxPlugin extends Plugin {
|
||||
|
||||
WebResourceResponse awaitResponse(long timeoutMs) {
|
||||
try {
|
||||
latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Ditto</string>
|
||||
<string name="title_activity_main">Ditto</string>
|
||||
<string name="package_name">pub.ditto.app</string>
|
||||
<string name="custom_url_scheme">pub.ditto.app</string>
|
||||
<string name="app_name">Agora</string>
|
||||
<string name="title_activity_main">Agora</string>
|
||||
<string name="package_name">pub.agora.app</string>
|
||||
<string name="custom_url_scheme">pub.agora.app</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Android Auto Backup rules (Android 11 and below).
|
||||
|
||||
Ditto excludes WebView storage (Local Storage, IndexedDB, databases) and
|
||||
any shared_prefs that hold sensitive credentials so they don't end up in
|
||||
Google Drive backups. Keychain/KeyStore entries used by
|
||||
capacitor-secure-storage-plugin are not backed up by default, so we don't
|
||||
need to exclude those explicitly; but we also exclude the plugin's
|
||||
SharedPreferences for defense in depth.
|
||||
|
||||
See: https://developer.android.com/guide/topics/data/autobackup
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!-- WebView: localStorage, IndexedDB, cookies, caches -->
|
||||
<exclude domain="file" path="app_webview/" />
|
||||
<exclude domain="database" path="webview.db" />
|
||||
<exclude domain="database" path="webviewCache.db" />
|
||||
|
||||
<!-- capacitor-secure-storage-plugin fallback SharedPreferences -->
|
||||
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
|
||||
|
||||
<!-- Capacitor preferences plugin — may contain app-level settings -->
|
||||
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Android 12+ data extraction rules.
|
||||
|
||||
Separate rules apply to cloud backups (Google Drive) and device-to-device
|
||||
transfers. Both exclude WebView storage and sensitive SharedPreferences so
|
||||
wallet credentials, login tokens, and cached private data don't leak.
|
||||
|
||||
See: https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="file" path="app_webview/" />
|
||||
<exclude domain="database" path="webview.db" />
|
||||
<exclude domain="database" path="webviewCache.db" />
|
||||
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
|
||||
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="file" path="app_webview/" />
|
||||
<exclude domain="database" path="webview.db" />
|
||||
<exclude domain="database" path="webviewCache.db" />
|
||||
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
|
||||
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -5,14 +5,26 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-barcode-scanner'
|
||||
project(':capacitor-barcode-scanner').projectDir = new File('../node_modules/@capacitor/barcode-scanner/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-haptics'
|
||||
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
|
||||
|
||||
include ':capacitor-keyboard'
|
||||
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
include ':capgo-capacitor-autofill-save-password'
|
||||
project(':capgo-capacitor-autofill-save-password').projectDir = new File('../node_modules/@capgo/capacitor-autofill-save-password/android')
|
||||
|
||||
include ':capacitor-secure-storage-plugin'
|
||||
project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android')
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'pub.ditto.app',
|
||||
appName: 'Ditto',
|
||||
appId: 'pub.agora.app',
|
||||
appName: 'Agora',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
// Handle deep links from your domain
|
||||
hostname: 'ditto.pub',
|
||||
androidScheme: 'https',
|
||||
iosScheme: 'https'
|
||||
},
|
||||
@@ -18,8 +16,15 @@ const config: CapacitorConfig = {
|
||||
ios: {
|
||||
backgroundColor: '#14161f',
|
||||
contentInset: 'never',
|
||||
scheme: 'Ditto'
|
||||
}
|
||||
scheme: 'Agora'
|
||||
},
|
||||
plugins: {
|
||||
SystemBars: {
|
||||
// Inject --safe-area-inset-* CSS variables on Android to work around
|
||||
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
|
||||
insetsHandling: 'css',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# Spark Wallet (Breez SDK) Integration
|
||||
|
||||
Agora ships with a self-custodial Lightning wallet powered by the
|
||||
[Breez SDK Spark](https://sdk-doc-spark.breez.technology/) variant. This
|
||||
document summarises how the integration is structured and what you need to
|
||||
know when working on it.
|
||||
|
||||
## What it is
|
||||
|
||||
A **self-custodial, on-device Lightning wallet** built on top of
|
||||
`@breeztech/breez-sdk-spark`. The user's BIP39 recovery phrase lives on
|
||||
their device (NIP-44 encrypted in `localStorage`) and is never sent to a
|
||||
server. The SDK runs as a WebAssembly module in the user's browser /
|
||||
WKWebView; there is no native Capacitor plugin involved.
|
||||
|
||||
The integration mirrors the implementation in
|
||||
[Agora](https://gitlab.com/soapbox-pub/agora) so backup files and relay
|
||||
backups are interoperable between the two apps and with Zap Cooking.
|
||||
|
||||
## API key requirement
|
||||
|
||||
The SDK requires a Breez API key to connect. Set `VITE_BREEZ_API_KEY` in
|
||||
your environment (request one at <https://breez.technology/request-api-key/>).
|
||||
Without it the wallet UI surfaces an error and zaps fall through to NWC /
|
||||
WebLN / manual QR as before.
|
||||
|
||||
## SDK version pin (do not bump past 0.12.2 without testing)
|
||||
|
||||
`@breeztech/breez-sdk-spark` is **pinned exactly** to `0.12.2` in
|
||||
`package.json` (no caret). Versions `0.12.3` through at least `0.13.2-dev2`
|
||||
ship a broken WASM build: the bundled WASM module imports six new host
|
||||
functions (`getTokenOutputs`, `insertTokenOutputs`, `listTokensOutputs`,
|
||||
`reserveTokenOutputs`, `setTokensOutputs`,
|
||||
`createPostgresTokenStoreWithPool`) that are **not implemented** by the
|
||||
bundled `IndexedDBStorage` class, so `await initBreezSDK()` throws a
|
||||
`LinkError: ... function import requires a callable` at startup and the
|
||||
wallet is unusable.
|
||||
|
||||
We previously tried working around the gap with a runtime storage shim
|
||||
that monkey-patched the missing methods. The WASM linker error went away
|
||||
but the SDK's higher-level `buyBitcoin({ type: 'cashApp' })` path
|
||||
silently routed to MoonPay with the wrong amount, so the user-visible
|
||||
behaviour was still wrong. Sticking with 0.12.2 until upstream lands a
|
||||
clean fix is the safer option.
|
||||
|
||||
Reproduce with:
|
||||
|
||||
```sh
|
||||
npm install @breeztech/breez-sdk-spark@0.13.1
|
||||
# Open the wallet → see "Failed to initialize Breez SDK WASM: LinkError"
|
||||
```
|
||||
|
||||
Before bumping the SDK in the future, do this check first:
|
||||
|
||||
```sh
|
||||
# 1. Install the candidate version
|
||||
npm install @breeztech/breez-sdk-spark@<new-version> --no-save
|
||||
# 2. List host imports the WASM expects
|
||||
grep -oE '__wbg_[a-zA-Z]+_[a-f0-9]+' \
|
||||
node_modules/@breeztech/breez-sdk-spark/web/breez_sdk_spark_wasm.js \
|
||||
| sed 's/^__wbg_//;s/_[a-f0-9]\+$//' | sort -u > /tmp/wasm-imports.txt
|
||||
# 3. Cross-check against the storage methods that exist
|
||||
grep -oE '\b[a-zA-Z]+\s*\(' \
|
||||
node_modules/@breeztech/breez-sdk-spark/web/storage/index.js \
|
||||
| sed 's/\s*($//' | sort -u > /tmp/storage-methods.txt
|
||||
# 4. Any imports the storage class doesn't implement need shimming
|
||||
comm -23 /tmp/wasm-imports.txt /tmp/storage-methods.txt
|
||||
# 5. Independently verify the on-ramp paths actually open the right
|
||||
# provider (e.g. tap "Buy with Cash App" → URL must start with
|
||||
# https://cash.app/launch/lightning/, not https://buy.moonpay.io/).
|
||||
```
|
||||
|
||||
Once upstream packaging is fixed (the storage class implements every
|
||||
`__wbg_*` host function the WASM imports AND `buyBitcoin({ type:
|
||||
'cashApp' })` actually returns a `cash.app/launch/lightning/...` URL),
|
||||
restore the caret range: `"@breeztech/breez-sdk-spark": "^0.12.2"`.
|
||||
|
||||
Track the upstream bug at <https://github.com/breez/spark-sdk/issues>.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/lib/spark/
|
||||
breezService.ts BreezWalletService singleton around the SDK
|
||||
store.ts NIP-44 encrypted mnemonic + config storage
|
||||
backup.ts NIP-78 (kind 30078) relay backup + v2 file backup
|
||||
rateLimiter.ts Exponential backoff on mnemonic restore attempts
|
||||
types.ts Shared TypeScript types
|
||||
|
||||
src/lib/checkWasmSupport.ts
|
||||
Runtime probe that compiles a minimal WASM module to detect
|
||||
iOS Lockdown Mode (which disables WASM entirely).
|
||||
|
||||
src/contexts/SparkWalletContext.tsx
|
||||
Global provider exposing the full SparkWalletContextValue. Mounted
|
||||
inside NWCProvider in src/App.tsx.
|
||||
|
||||
src/hooks/
|
||||
useSparkWallet.ts Thin re-export of the context hook
|
||||
useWallet.ts Unified status: Spark > NWC > WebLN > manual
|
||||
useZaps.ts Spark is attempted first in the payment chain
|
||||
useBarcodeScanner.ts @capacitor/barcode-scanner wrapper
|
||||
useExchangeRate.ts Coinbase BTC/USD rate with caching
|
||||
usePaymentContext.ts Zap-receipt enrichment for payment history
|
||||
|
||||
src/components/
|
||||
SparkWallet/ Feature directory (create, restore, send,
|
||||
receive, history, deposits, lock, etc.)
|
||||
SparkWalletSettings.tsx Inline settings panel shown in
|
||||
/settings/wallet
|
||||
WalletSetupPrompt.tsx Dialog that nudges users to /wallet
|
||||
|
||||
src/pages/
|
||||
WalletPage.tsx /wallet dashboard
|
||||
WalletSettingsPage.tsx /settings/wallet (Spark + NWC combined)
|
||||
```
|
||||
|
||||
## Storage layout
|
||||
|
||||
Per-user, scoped by Nostr pubkey:
|
||||
|
||||
| Key | Location | Contents |
|
||||
|----------------------------------|--------------------|--------------------------------------------|
|
||||
| `spark_seed_<pubkey>` | `localStorage` | NIP-44 encrypted mnemonic |
|
||||
| `spark_config_<pubkey>` | `localStorage` | Balance cache, lock timeout, `isEnabled` |
|
||||
| `spark-wallet-mnemonic-session` | `sessionStorage` | NIP-44 encrypted mnemonic (fast reconnect) |
|
||||
| `spark-wallet-restore-rate-limit`| `sessionStorage` | Failed restore attempt counter |
|
||||
|
||||
Encryption uses the currently logged-in signer's
|
||||
`nip44.encrypt(userPubkey, plaintext)` / `decrypt(userPubkey, ciphertext)`
|
||||
— i.e. the user encrypts to themselves. The raw mnemonic is never
|
||||
written to disk in plaintext.
|
||||
|
||||
## Backup formats
|
||||
|
||||
- **Relay backup**: NIP-78 (kind 30078) addressable event with
|
||||
`d: "spark-wallet-backup"`, content NIP-44 encrypted. Fallback relays
|
||||
include `relay.damus.io`, `relay.primal.net`, `nos.lol`. Compatible
|
||||
with Zap Cooking / sparkihonne.
|
||||
- **File backup**: v2 JSON format with `encryptedMnemonic` field
|
||||
(NIP-44). Same format as Agora.
|
||||
|
||||
## Zap payment priority
|
||||
|
||||
`useZaps` attempts payment methods in this order. Any failure falls
|
||||
through to the next:
|
||||
|
||||
1. **Spark** — if `sparkEnabled && sparkConnected && balance >= amount`
|
||||
2. **NWC** — if an active connection is configured
|
||||
3. **WebLN** — if `window.webln` is present
|
||||
4. **Manual QR** — the invoice is shown with a "Open in Lightning
|
||||
Wallet" button as a final fallback
|
||||
|
||||
## Onboarding integration
|
||||
|
||||
The signup flow in `InitialSyncGate` includes a wallet step between
|
||||
`profile` and `follows`:
|
||||
|
||||
```
|
||||
SIGNUP_STEPS = theme → keygen → download → profile → wallet → follows → outro
|
||||
```
|
||||
|
||||
The step shows a 3-bullet pitch with a **Set up my wallet** primary
|
||||
button and a **Skip for now** secondary button. On setup, it generates
|
||||
a mnemonic, shows it via `<MnemonicDisplay>`, offers a one-tap
|
||||
"Sync encrypted backup to my relays" action, and requires the user to
|
||||
acknowledge they've saved the phrase before continuing.
|
||||
|
||||
Devices without WASM support (iOS Lockdown Mode) are shown a friendly
|
||||
fallback screen with a single Continue button; they skip the wallet
|
||||
step entirely.
|
||||
|
||||
`SETTINGS_STEPS` (the flow for returning users missing settings) is
|
||||
unchanged — it never includes the wallet step.
|
||||
|
||||
## QR scanning
|
||||
|
||||
Agora previously generated QR codes (via `qrcode`) but had no scanner.
|
||||
`useBarcodeScanner` now wraps `@capacitor/barcode-scanner` for native
|
||||
builds; web builds expose `isSupported: false` and the Spark
|
||||
`SendPayment` UI hides the scan button gracefully.
|
||||
|
||||
Required permissions:
|
||||
- **iOS** (`ios/App/App/Info.plist`): `NSCameraUsageDescription`
|
||||
- **Android** (`android/app/src/main/AndroidManifest.xml`):
|
||||
`android.permission.CAMERA` and `<uses-feature android:name="android.hardware.camera" android:required="false" />`
|
||||
|
||||
After installing or updating the plugin, run `npm run cap:sync`.
|
||||
|
||||
## Vite / WASM configuration
|
||||
|
||||
`vite.config.ts` needs two tweaks so the WASM loader works:
|
||||
|
||||
```ts
|
||||
optimizeDeps: {
|
||||
exclude: ['@breeztech/breez-sdk-spark', ...],
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
```
|
||||
|
||||
`build.target: 'esnext'` is required for top-level await (used inside
|
||||
the SDK's WASM bootstrap).
|
||||
|
||||
## Links
|
||||
|
||||
- Breez SDK Spark docs: <https://sdk-doc-spark.breez.technology/>
|
||||
- Request an API key: <https://breez.technology/request-api-key/>
|
||||
- Agora reference implementation: <https://gitlab.com/soapbox-pub/agora>
|
||||
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
|
||||
import customRules from "./eslint-rules/index.js";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "android"] },
|
||||
{ ignores: ["dist", "android", "ios"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
@@ -1,43 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<title>Ditto — Your content. Your vibe. Your rules.</title>
|
||||
<title>Agora — Power to the people.</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Agora — a Nostr social client for communities, creativity, and ownership." />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Ditto" />
|
||||
<meta property="og:description" content="Your content. Your vibe. Your rules." />
|
||||
<meta property="og:image" content="https://ditto.pub/og-image.jpg" />
|
||||
<meta property="og:title" content="Agora" />
|
||||
<meta property="og:description" content="Power to the people." />
|
||||
<meta property="og:image" content="https://agora.spot/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://ditto.pub" />
|
||||
<meta property="og:site_name" content="Ditto" />
|
||||
<meta property="og:url" content="https://agora.spot" />
|
||||
<meta property="og:site_name" content="Agora" />
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Ditto" />
|
||||
<meta name="twitter:description" content="Your content. Your vibe. Your rules." />
|
||||
<meta name="twitter:image" content="https://ditto.pub/og-image.jpg" />
|
||||
<meta name="twitter:title" content="Agora" />
|
||||
<meta name="twitter:description" content="Power to the people." />
|
||||
<meta name="twitter:image" content="https://agora.spot/og-image.png" />
|
||||
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#161b2e" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/icon-192.png">
|
||||
<meta name="theme-color" content="#0a0c14" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#e85d3c" media="(prefers-color-scheme: light)">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<style>@keyframes ditto-spin{to{transform:rotate(360deg)}}</style>
|
||||
<style>@keyframes agora-spin{to{transform:rotate(360deg)}}</style>
|
||||
</head>
|
||||
<body style="margin:0;background:hsl(228 20% 10%)">
|
||||
<body style="margin:0;background:hsl(0 0% 10%)">
|
||||
<!-- Pre-React loading screen. Lives OUTSIDE #root so React doesn't
|
||||
touch it. Removed by main.tsx once the app has mounted. -->
|
||||
<div id="preloader" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:hsl(228 20% 10%)">
|
||||
<div id="preloader" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:hsl(0 0% 10%)">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:24px">
|
||||
<div data-logo style="width:48px;height:48px;background:hsl(258 70% 60%);-webkit-mask:url(/logo.svg) center/contain no-repeat;mask:url(/logo.svg) center/contain no-repeat"></div>
|
||||
<div data-spinner style="width:24px;height:24px;border:2.5px solid hsl(258 70% 60% / 0.25);border-top-color:hsl(258 70% 60%);border-radius:50%;animation:ditto-spin .7s linear infinite"></div>
|
||||
<div data-logo style="width:48px;height:48px;background:hsl(14 79% 58%);-webkit-mask:url(/logo.svg) center/contain no-repeat;mask:url(/logo.svg) center/contain no-repeat"></div>
|
||||
<div data-spinner style="width:24px;height:24px;border:2.5px solid hsl(14 79% 58% / 0.25);border-top-color:hsl(14 79% 58%);border-radius:50%;animation:agora-spin .7s linear infinite"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Blocking script: reads theme from localStorage and applies it
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
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 */; };
|
||||
B1A2C3D40007000100000001 /* NostrPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40007000100000002 /* NostrPoller.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -32,6 +35,10 @@
|
||||
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>"; };
|
||||
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoNotificationPlugin.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40007000100000002 /* NostrPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoller.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -67,13 +74,17 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
B1A2C3D40004000100000002 /* App.entitlements */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
|
||||
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */,
|
||||
B1A2C3D40007000100000002 /* NostrPoller.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
@@ -151,6 +162,7 @@
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -164,6 +176,8 @@
|
||||
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 */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -303,17 +317,19 @@
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GZLTTH5DLM;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.1;
|
||||
MARKETING_VERSION = 2.8.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -325,16 +341,18 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GZLTTH5DLM;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
MARKETING_VERSION = 2.8.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>webcredentials:agora.spot</string>
|
||||
<string>webcredentials:agora.spot?mode=developer</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,36 +1,45 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import BackgroundTasks
|
||||
import UserNotifications
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
// Register the background task handler for notification polling.
|
||||
// Must happen before the app finishes launching.
|
||||
DittoNotificationPlugin.registerBackgroundTask()
|
||||
|
||||
// Set ourselves as the notification center delegate so we can:
|
||||
// 1. Show banners even when the app is in the foreground.
|
||||
// 2. Handle notification taps to navigate the WebView.
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
// Register notification categories with summary formats for iOS grouping.
|
||||
registerNotificationCategories()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
// Trigger an immediate poll when returning to foreground to catch up
|
||||
// on any notifications missed while backgrounded.
|
||||
DittoNotificationPlugin.pollNow()
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
@@ -46,4 +55,66 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
/// Show notification banners even when the app is in the foreground.
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
|
||||
/// Handle notification tap: navigate the Capacitor WebView to /notifications.
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
let path = userInfo["url"] as? String ?? "/notifications"
|
||||
|
||||
// Navigate the Capacitor WebView to the notifications page.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let rootVC = self?.window?.rootViewController as? DittoBridgeViewController else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
let js = "window.location.pathname !== '\(path)' && (window.location.pathname = '\(path)');"
|
||||
rootVC.webView?.evaluateJavaScript(js) { _, _ in }
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
// MARK: - Notification Categories
|
||||
|
||||
/// Register notification categories with summary formats for native iOS
|
||||
/// notification grouping. When multiple notifications share a thread
|
||||
/// identifier, iOS automatically collapses them and uses the summary
|
||||
/// format to describe the group.
|
||||
private func registerNotificationCategories() {
|
||||
let categories: [UNNotificationCategory] = [
|
||||
makeCategory(id: NostrPoller.categoryReactions, summary: "%u more reactions"),
|
||||
makeCategory(id: NostrPoller.categoryReposts, summary: "%u more reposts"),
|
||||
makeCategory(id: NostrPoller.categoryZaps, summary: "%u more zaps"),
|
||||
makeCategory(id: NostrPoller.categoryMentions, summary: "%u more mentions"),
|
||||
makeCategory(id: NostrPoller.categoryComments, summary: "%u more comments"),
|
||||
makeCategory(id: NostrPoller.categoryBadges, summary: "%u more badge awards"),
|
||||
makeCategory(id: NostrPoller.categoryLetters, summary: "%u more letters"),
|
||||
]
|
||||
UNUserNotificationCenter.current().setNotificationCategories(Set(categories))
|
||||
}
|
||||
|
||||
private func makeCategory(id: String, summary: String) -> UNNotificationCategory {
|
||||
return UNNotificationCategory(
|
||||
identifier: id,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: nil,
|
||||
categorySummaryFormat: summary,
|
||||
options: []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import BackgroundTasks
|
||||
import UserNotifications
|
||||
|
||||
// MARK: - DittoNotificationPlugin
|
||||
|
||||
/// Capacitor plugin that bridges the JS notification configuration to the
|
||||
/// native iOS background polling system.
|
||||
///
|
||||
/// Mirrors the Android `DittoNotificationPlugin.java` interface:
|
||||
/// - Receives `userPubkey`, `relayUrls`, `enabledKinds`, `authors`, and
|
||||
/// `notificationStyle` from the JS layer via `configure()`.
|
||||
/// - Stores configuration in UserDefaults.
|
||||
/// - Schedules / cancels a `BGAppRefreshTask` to periodically poll relays
|
||||
/// and display local notifications via `NostrPoller`.
|
||||
///
|
||||
/// On iOS the "push" vs "persistent" distinction maps to:
|
||||
/// - **"push"**: No background polling. Relies on Web Push (where supported)
|
||||
/// or in-app polling when the app is open.
|
||||
/// - **"persistent"**: Schedules `BGAppRefreshTask` for periodic relay polling.
|
||||
/// iOS manages the interval (~15 min minimum, adaptive based on app usage).
|
||||
@objc(DittoNotificationPlugin)
|
||||
public class DittoNotificationPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
// MARK: - Capacitor Bridging
|
||||
|
||||
public let identifier = "DittoNotificationPlugin"
|
||||
public let jsName = "DittoNotification"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
static let bgTaskIdentifier = "pub.ditto.app.notification-refresh"
|
||||
private static let prefsKey = "ditto_notification_config"
|
||||
|
||||
// MARK: - Plugin Methods
|
||||
|
||||
/// Called from JS: `DittoNotification.configure({ ... })`.
|
||||
@objc func configure(_ call: CAPPluginCall) {
|
||||
let userPubkey = call.getString("userPubkey")
|
||||
let notificationStyle = call.getString("notificationStyle") ?? "push"
|
||||
let relayUrls = call.getArray("relayUrls")?.compactMap { $0 as? String }
|
||||
let enabledKinds = call.getArray("enabledKinds")?.compactMap { $0 as? Int }
|
||||
let authors = call.getArray("authors")?.compactMap { $0 as? String }
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
if let userPubkey, let relayUrls, !relayUrls.isEmpty {
|
||||
// Save configuration.
|
||||
defaults.set(userPubkey, forKey: "\(Self.prefsKey).userPubkey")
|
||||
defaults.set(relayUrls, forKey: "\(Self.prefsKey).relayUrls")
|
||||
defaults.set(notificationStyle, forKey: "\(Self.prefsKey).notificationStyle")
|
||||
if let enabledKinds {
|
||||
defaults.set(enabledKinds, forKey: "\(Self.prefsKey).enabledKinds")
|
||||
}
|
||||
if let authors, !authors.isEmpty {
|
||||
defaults.set(authors, forKey: "\(Self.prefsKey).authors")
|
||||
} else {
|
||||
defaults.removeObject(forKey: "\(Self.prefsKey).authors")
|
||||
}
|
||||
|
||||
let kindsStr = enabledKinds?.map(String.init).joined(separator: ",") ?? "none"
|
||||
NSLog("[DittoNotification] Configured: pubkey=%@..., style=%@, relays=%d, kinds=%@",
|
||||
String(userPubkey.prefix(8)), notificationStyle,
|
||||
relayUrls.count,
|
||||
kindsStr)
|
||||
} else {
|
||||
// Clear configuration (user logged out).
|
||||
for suffix in ["userPubkey", "relayUrls", "notificationStyle", "enabledKinds", "authors"] {
|
||||
defaults.removeObject(forKey: "\(Self.prefsKey).\(suffix)")
|
||||
}
|
||||
NSLog("[DittoNotification] Config cleared (user logged out)")
|
||||
}
|
||||
|
||||
// Schedule or cancel background polling based on style + config.
|
||||
let hasConfig = userPubkey != nil && relayUrls != nil && !(relayUrls?.isEmpty ?? true)
|
||||
Self.manageBackgroundRefresh(style: notificationStyle, hasConfig: hasConfig)
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// MARK: - Background Task Management
|
||||
|
||||
/// Register the BGAppRefreshTask handler. Must be called from
|
||||
/// `application(_:didFinishLaunchingWithOptions:)` before the app
|
||||
/// finishes launching.
|
||||
static func registerBackgroundTask() {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: bgTaskIdentifier,
|
||||
using: nil
|
||||
) { task in
|
||||
guard let refreshTask = task as? BGAppRefreshTask else {
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
Self.handleBackgroundRefresh(task: refreshTask)
|
||||
}
|
||||
NSLog("[DittoNotification] Registered BGAppRefreshTask: %@", bgTaskIdentifier)
|
||||
}
|
||||
|
||||
/// Schedule or cancel the BGAppRefreshTask.
|
||||
/// On iOS both "push" and "persistent" modes use BGAppRefreshTask
|
||||
/// (there is no Web Push in WKWebView and no foreground service concept),
|
||||
/// so we schedule whenever there is a valid config.
|
||||
static func manageBackgroundRefresh(style: String, hasConfig: Bool) {
|
||||
if hasConfig {
|
||||
scheduleBackgroundRefresh()
|
||||
} else {
|
||||
cancelBackgroundRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule the next background refresh. iOS decides the actual timing
|
||||
/// (minimum ~15 minutes, adaptive based on user app usage patterns).
|
||||
static func scheduleBackgroundRefresh() {
|
||||
let request = BGAppRefreshTaskRequest(identifier: bgTaskIdentifier)
|
||||
// Suggest earliest begin date of 8 minutes from now (iOS may defer).
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 8 * 60)
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
NSLog("[DittoNotification] Scheduled background refresh")
|
||||
} catch {
|
||||
NSLog("[DittoNotification] Failed to schedule background refresh: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cancelBackgroundRefresh() {
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: bgTaskIdentifier)
|
||||
NSLog("[DittoNotification] Cancelled background refresh")
|
||||
}
|
||||
|
||||
/// Handle a BGAppRefreshTask: read config, poll, reschedule.
|
||||
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
|
||||
NSLog("[DittoNotification] Background refresh triggered")
|
||||
|
||||
// Read configuration from UserDefaults.
|
||||
let defaults = UserDefaults.standard
|
||||
guard let userPubkey = defaults.string(forKey: "\(prefsKey).userPubkey"),
|
||||
let relayUrls = defaults.stringArray(forKey: "\(prefsKey).relayUrls"),
|
||||
!relayUrls.isEmpty else {
|
||||
NSLog("[DittoNotification] No config, completing task")
|
||||
task.setTaskCompleted(success: true)
|
||||
return
|
||||
}
|
||||
|
||||
let enabledKinds = defaults.array(forKey: "\(prefsKey).enabledKinds") as? [Int] ?? []
|
||||
let authors = defaults.stringArray(forKey: "\(prefsKey).authors")
|
||||
|
||||
guard !enabledKinds.isEmpty else {
|
||||
NSLog("[DittoNotification] No enabled kinds, completing task")
|
||||
task.setTaskCompleted(success: true)
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule the next refresh before starting work (in case we're
|
||||
// terminated mid-task, the next refresh is already queued).
|
||||
scheduleBackgroundRefresh()
|
||||
|
||||
// Run the poll in a detached Task.
|
||||
let pollTask = Task {
|
||||
let poller = NostrPoller()
|
||||
let count = await poller.poll(
|
||||
userPubkey: userPubkey,
|
||||
relayUrls: relayUrls,
|
||||
enabledKinds: enabledKinds,
|
||||
authors: authors
|
||||
)
|
||||
NSLog("[DittoNotification] Background poll complete: %d notifications", count)
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
|
||||
// Handle task expiration (iOS is about to kill us).
|
||||
task.expirationHandler = {
|
||||
NSLog("[DittoNotification] Background task expired")
|
||||
pollTask.cancel()
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Immediate Poll
|
||||
|
||||
/// Trigger an immediate poll (e.g., when the app enters the foreground
|
||||
/// after being backgrounded, to catch up on missed notifications).
|
||||
static func pollNow() {
|
||||
let defaults = UserDefaults.standard
|
||||
guard let userPubkey = defaults.string(forKey: "\(prefsKey).userPubkey"),
|
||||
let relayUrls = defaults.stringArray(forKey: "\(prefsKey).relayUrls"),
|
||||
!relayUrls.isEmpty else { return }
|
||||
|
||||
let enabledKinds = defaults.array(forKey: "\(prefsKey).enabledKinds") as? [Int] ?? []
|
||||
let authors = defaults.stringArray(forKey: "\(prefsKey).authors")
|
||||
|
||||
guard !enabledKinds.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
let poller = NostrPoller()
|
||||
await poller.poll(
|
||||
userPubkey: userPubkey,
|
||||
relayUrls: relayUrls,
|
||||
enabledKinds: enabledKinds,
|
||||
authors: authors
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Ditto</string>
|
||||
<string>Agora</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -48,8 +48,20 @@
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Ditto needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<string>Agora needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Agora needs camera access to take photos and videos for your posts, and to scan Lightning invoices and Bitcoin addresses for wallet payments.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Ditto needs access to your microphone to record voice messages.</string>
|
||||
<string>Agora needs access to your microphone to record voice messages.</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>pub.agora.app.notification-refresh</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
// MARK: - NostrPoller
|
||||
|
||||
/// Polls Nostr relays for notification events and displays native iOS
|
||||
/// notifications with author names, content previews, and iOS thread grouping.
|
||||
///
|
||||
/// Improvements over the Android implementation:
|
||||
/// - Fetches kind 0 metadata so notifications show "Alice reacted" not "Someone reacted"
|
||||
/// - Uses iOS thread identifiers for native notification grouping per category+post
|
||||
/// - Caches author metadata in UserDefaults (24h TTL) to minimise relay queries
|
||||
/// - Designed to complete within the ~30s BGAppRefreshTask budget
|
||||
final class NostrPoller {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private static let prefsKey = "ditto_notifications"
|
||||
private static let lastSeenKey = "nostr:notification-last-seen"
|
||||
private static let metadataCacheKey = "nostr:author-metadata-cache"
|
||||
private static let metadataTTL: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
|
||||
private static let fetchLimit = 5
|
||||
private static let wsTimeout: TimeInterval = 10
|
||||
private static let metadataFetchTimeout: TimeInterval = 5
|
||||
|
||||
// MARK: - Notification Categories (registered by AppDelegate)
|
||||
|
||||
/// Category identifiers used for UNNotificationCategory registration.
|
||||
static let categoryReactions = "reactions"
|
||||
static let categoryReposts = "reposts"
|
||||
static let categoryZaps = "zaps"
|
||||
static let categoryMentions = "mentions"
|
||||
static let categoryComments = "comments"
|
||||
static let categoryBadges = "badges"
|
||||
static let categoryLetters = "letters"
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
/// Minimal parsed Nostr event used during polling.
|
||||
struct NostrEvent {
|
||||
let id: String
|
||||
let pubkey: String
|
||||
let kind: Int
|
||||
let createdAt: Int
|
||||
let content: String
|
||||
let tags: [[String]]
|
||||
|
||||
init?(json: [String: Any]) {
|
||||
guard let id = json["id"] as? String,
|
||||
let pubkey = json["pubkey"] as? String,
|
||||
let kind = json["kind"] as? Int,
|
||||
let createdAt = json["created_at"] as? Int else { return nil }
|
||||
self.id = id
|
||||
self.pubkey = pubkey
|
||||
self.kind = kind
|
||||
self.createdAt = createdAt
|
||||
self.content = json["content"] as? String ?? ""
|
||||
self.tags = (json["tags"] as? [[String]]) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached author display name.
|
||||
private struct AuthorCache: Codable {
|
||||
let name: String
|
||||
let timestamp: TimeInterval
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Run a single poll cycle: fetch events from a relay, resolve metadata,
|
||||
/// and display notifications. Returns the number of notifications shown.
|
||||
@discardableResult
|
||||
func poll(
|
||||
userPubkey: String,
|
||||
relayUrls: [String],
|
||||
enabledKinds: [Int],
|
||||
authors: [String]?
|
||||
) async -> Int {
|
||||
guard !relayUrls.isEmpty, !enabledKinds.isEmpty else { return 0 }
|
||||
|
||||
let since = lastSeenTimestamp
|
||||
let effectiveSince = since > 0 ? since : Int(Date().timeIntervalSince1970) - 300
|
||||
|
||||
if since == 0 {
|
||||
setLastSeenTimestamp(effectiveSince)
|
||||
}
|
||||
|
||||
// Try each relay in order until one succeeds.
|
||||
for relayUrl in relayUrls {
|
||||
guard let events = await fetchEvents(
|
||||
relayUrl: relayUrl,
|
||||
userPubkey: userPubkey,
|
||||
enabledKinds: enabledKinds,
|
||||
authors: authors,
|
||||
since: effectiveSince
|
||||
) else {
|
||||
continue // Try next relay on failure.
|
||||
}
|
||||
|
||||
// Deduplicate + filter self-interactions.
|
||||
var seenIds = Set<String>()
|
||||
let filtered = events.filter { ev in
|
||||
guard ev.pubkey != userPubkey, !seenIds.contains(ev.id) else { return false }
|
||||
seenIds.insert(ev.id)
|
||||
return true
|
||||
}
|
||||
|
||||
guard !filtered.isEmpty else {
|
||||
// Successful fetch but nothing new — update timestamp and return.
|
||||
return 0
|
||||
}
|
||||
|
||||
// Verify referenced events for reactions/reposts/zaps.
|
||||
let notifiable = await verifyReferencedEvents(
|
||||
events: filtered,
|
||||
userPubkey: userPubkey,
|
||||
relayUrl: relayUrl
|
||||
)
|
||||
|
||||
// Update last-seen to newest event in the full filtered set (not
|
||||
// just notifiable) so we don't re-fetch already-seen events.
|
||||
let newestTs = filtered.map(\.createdAt).max() ?? effectiveSince
|
||||
if newestTs > lastSeenTimestamp {
|
||||
setLastSeenTimestamp(newestTs)
|
||||
}
|
||||
|
||||
guard !notifiable.isEmpty else { return 0 }
|
||||
|
||||
// Fetch author metadata for unique pubkeys.
|
||||
let pubkeys = Array(Set(notifiable.map(\.pubkey)))
|
||||
let authorNames = await resolveAuthorNames(pubkeys: pubkeys, relayUrl: relayUrl)
|
||||
|
||||
// Display notifications.
|
||||
await displayNotifications(events: notifiable, authorNames: authorNames)
|
||||
|
||||
return notifiable.count
|
||||
}
|
||||
|
||||
return 0 // All relays failed.
|
||||
}
|
||||
|
||||
// MARK: - Relay Communication
|
||||
|
||||
/// Fetch notification events from a single relay. Returns nil on failure.
|
||||
private func fetchEvents(
|
||||
relayUrl: String,
|
||||
userPubkey: String,
|
||||
enabledKinds: [Int],
|
||||
authors: [String]?,
|
||||
since: Int
|
||||
) async -> [NostrEvent]? {
|
||||
guard let url = URL(string: relayUrl) else { return nil }
|
||||
|
||||
var filter: [String: Any] = [
|
||||
"kinds": enabledKinds,
|
||||
"#p": [userPubkey],
|
||||
"since": since + 1,
|
||||
"limit": Self.fetchLimit,
|
||||
]
|
||||
if let authors, !authors.isEmpty {
|
||||
filter["authors"] = authors
|
||||
}
|
||||
|
||||
return await relayQuery(url: url, filters: [filter])
|
||||
}
|
||||
|
||||
/// Fetch events by IDs from a relay for referenced-event verification.
|
||||
private func fetchEventsByIds(ids: [String], relayUrl: String) async -> [String: NostrEvent] {
|
||||
guard !ids.isEmpty, let url = URL(string: relayUrl) else { return [:] }
|
||||
|
||||
let filter: [String: Any] = [
|
||||
"ids": ids,
|
||||
"limit": ids.count,
|
||||
]
|
||||
|
||||
guard let events = await relayQuery(url: url, filters: [filter], timeout: Self.metadataFetchTimeout) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
var map = [String: NostrEvent]()
|
||||
for ev in events {
|
||||
map[ev.id] = ev
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/// Fetch kind 0 metadata events for a set of pubkeys.
|
||||
private func fetchMetadata(pubkeys: [String], relayUrl: String) async -> [String: NostrEvent] {
|
||||
guard !pubkeys.isEmpty, let url = URL(string: relayUrl) else { return [:] }
|
||||
|
||||
let filter: [String: Any] = [
|
||||
"kinds": [0],
|
||||
"authors": pubkeys,
|
||||
"limit": pubkeys.count,
|
||||
]
|
||||
|
||||
guard let events = await relayQuery(url: url, filters: [filter], timeout: Self.metadataFetchTimeout) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
var map = [String: NostrEvent]()
|
||||
for ev in events {
|
||||
// Keep only the newest kind 0 per pubkey.
|
||||
if let existing = map[ev.pubkey], existing.createdAt > ev.createdAt {
|
||||
continue
|
||||
}
|
||||
map[ev.pubkey] = ev
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/// Low-level relay query: open WebSocket, send REQ, collect events until
|
||||
/// EOSE, close. Returns nil on connection/timeout failure.
|
||||
private func relayQuery(
|
||||
url: URL,
|
||||
filters: [[String: Any]],
|
||||
timeout: TimeInterval = wsTimeout
|
||||
) async -> [NostrEvent]? {
|
||||
await withCheckedContinuation { continuation in
|
||||
var events = [NostrEvent]()
|
||||
var resumed = false
|
||||
let subId = "ditto-\(UInt64.random(in: 0...UInt64.max))"
|
||||
|
||||
let session = URLSession(configuration: .default)
|
||||
let task = session.webSocketTask(with: url)
|
||||
task.resume()
|
||||
|
||||
// Build REQ message: ["REQ", subId, filter1, filter2, ...]
|
||||
var reqArray: [Any] = ["REQ", subId]
|
||||
reqArray.append(contentsOf: filters)
|
||||
|
||||
guard let reqData = try? JSONSerialization.data(withJSONObject: reqArray),
|
||||
let reqStr = String(data: reqData, encoding: .utf8) else {
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Timeout guard.
|
||||
let timeoutWork = DispatchWorkItem { [weak task] in
|
||||
guard !resumed else { return }
|
||||
resumed = true
|
||||
task?.cancel(with: .goingAway, reason: nil)
|
||||
session.invalidateAndCancel()
|
||||
continuation.resume(returning: events.isEmpty ? nil : events)
|
||||
}
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutWork)
|
||||
|
||||
func finish(result: [NostrEvent]?) {
|
||||
timeoutWork.cancel()
|
||||
guard !resumed else { return }
|
||||
resumed = true
|
||||
// Send CLOSE and disconnect.
|
||||
if let closeData = try? JSONSerialization.data(withJSONObject: ["CLOSE", subId]),
|
||||
let closeStr = String(data: closeData, encoding: .utf8) {
|
||||
task.send(.string(closeStr)) { _ in }
|
||||
}
|
||||
task.cancel(with: .normalClosure, reason: nil)
|
||||
session.invalidateAndCancel()
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
|
||||
func receiveNext() {
|
||||
task.receive { result in
|
||||
switch result {
|
||||
case .success(.string(let text)):
|
||||
guard let data = text.data(using: .utf8),
|
||||
let arr = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||
let type = arr.first as? String else {
|
||||
receiveNext()
|
||||
return
|
||||
}
|
||||
|
||||
if type == "EVENT", arr.count >= 3,
|
||||
let evJson = arr[2] as? [String: Any],
|
||||
let ev = NostrEvent(json: evJson) {
|
||||
events.append(ev)
|
||||
receiveNext()
|
||||
} else if type == "EOSE" || type == "CLOSED" {
|
||||
finish(result: events)
|
||||
} else {
|
||||
receiveNext()
|
||||
}
|
||||
|
||||
case .failure:
|
||||
finish(result: nil)
|
||||
|
||||
default:
|
||||
receiveNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task.send(.string(reqStr)) { error in
|
||||
if error != nil {
|
||||
finish(result: nil)
|
||||
} else {
|
||||
receiveNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Verification
|
||||
|
||||
/// For reactions (7), reposts (6, 16), and zaps (9735), verify that the
|
||||
/// referenced event was authored by the current user. Events that pass
|
||||
/// verification or don't need it are returned.
|
||||
private func verifyReferencedEvents(
|
||||
events: [NostrEvent],
|
||||
userPubkey: String,
|
||||
relayUrl: String
|
||||
) async -> [NostrEvent] {
|
||||
let needsVerification: Set<Int> = [7, 6, 16, 9735]
|
||||
|
||||
// Collect referenced IDs that need verification.
|
||||
var refIdsNeeded = Set<String>()
|
||||
for ev in events where needsVerification.contains(ev.kind) {
|
||||
if let refId = referencedEventId(from: ev) {
|
||||
refIdsNeeded.insert(refId)
|
||||
}
|
||||
}
|
||||
|
||||
let refMap: [String: NostrEvent]
|
||||
if !refIdsNeeded.isEmpty {
|
||||
refMap = await fetchEventsByIds(ids: Array(refIdsNeeded), relayUrl: relayUrl)
|
||||
} else {
|
||||
refMap = [:]
|
||||
}
|
||||
|
||||
return events.filter { ev in
|
||||
guard needsVerification.contains(ev.kind) else { return true }
|
||||
|
||||
// Zaps with #p tag targeting the user are valid (profile zaps have no e tag).
|
||||
if ev.kind == 9735 {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let refId = referencedEventId(from: ev) else { return false }
|
||||
guard let refEvent = refMap[refId] else {
|
||||
// Couldn't fetch — keep the notification rather than silently dropping it.
|
||||
return true
|
||||
}
|
||||
return refEvent.pubkey == userPubkey
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the last `e` tag value from an event's tags.
|
||||
private func referencedEventId(from event: NostrEvent) -> String? {
|
||||
event.tags.last(where: { $0.first == "e" && $0.count > 1 })?[1]
|
||||
}
|
||||
|
||||
// MARK: - Author Metadata Resolution
|
||||
|
||||
/// Resolve display names for a set of pubkeys, using cache where possible.
|
||||
private func resolveAuthorNames(pubkeys: [String], relayUrl: String) async -> [String: String] {
|
||||
var result = [String: String]()
|
||||
var uncached = [String]()
|
||||
|
||||
let cache = loadMetadataCache()
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
for pk in pubkeys {
|
||||
if let cached = cache[pk], now - cached.timestamp < Self.metadataTTL {
|
||||
result[pk] = cached.name
|
||||
} else {
|
||||
uncached.append(pk)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch uncached metadata from the relay.
|
||||
if !uncached.isEmpty {
|
||||
let metadataEvents = await fetchMetadata(pubkeys: uncached, relayUrl: relayUrl)
|
||||
var updatedCache = cache
|
||||
|
||||
for pk in uncached {
|
||||
if let ev = metadataEvents[pk], let name = parseDisplayName(from: ev) {
|
||||
result[pk] = name
|
||||
updatedCache[pk] = AuthorCache(name: name, timestamp: now)
|
||||
} else {
|
||||
// Fall back to truncated npub-style identifier.
|
||||
let fallback = formatPubkey(pk)
|
||||
result[pk] = fallback
|
||||
// Don't cache failures — retry next time.
|
||||
}
|
||||
}
|
||||
|
||||
saveMetadataCache(updatedCache)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Parse display_name or name from a kind 0 event's content JSON.
|
||||
private func parseDisplayName(from event: NostrEvent) -> String? {
|
||||
guard let data = event.content.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
// Prefer display_name, fall back to name.
|
||||
if let displayName = json["display_name"] as? String, !displayName.isEmpty {
|
||||
return displayName
|
||||
}
|
||||
if let name = json["name"] as? String, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Format a hex pubkey as a short identifier: first 8 + "..." + last 4.
|
||||
private func formatPubkey(_ pubkey: String) -> String {
|
||||
guard pubkey.count >= 12 else { return pubkey }
|
||||
let start = pubkey.prefix(8)
|
||||
let end = pubkey.suffix(4)
|
||||
return "\(start)...\(end)"
|
||||
}
|
||||
|
||||
// MARK: - Metadata Cache (UserDefaults)
|
||||
|
||||
private func loadMetadataCache() -> [String: AuthorCache] {
|
||||
let defaults = UserDefaults.standard
|
||||
guard let data = defaults.data(forKey: Self.metadataCacheKey),
|
||||
let cache = try? JSONDecoder().decode([String: AuthorCache].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
private func saveMetadataCache(_ cache: [String: AuthorCache]) {
|
||||
guard let data = try? JSONEncoder().encode(cache) else { return }
|
||||
UserDefaults.standard.set(data, forKey: Self.metadataCacheKey)
|
||||
}
|
||||
|
||||
// MARK: - Notification Display
|
||||
|
||||
/// Display native iOS notifications for a batch of verified events.
|
||||
private func displayNotifications(events: [NostrEvent], authorNames: [String: String]) async {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
|
||||
for event in events {
|
||||
let authorName = authorNames[event.pubkey] ?? formatPubkey(event.pubkey)
|
||||
let (title, body, categoryId, threadId) = notificationContent(
|
||||
event: event,
|
||||
authorName: authorName
|
||||
)
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = categoryId
|
||||
content.threadIdentifier = threadId
|
||||
content.userInfo = ["url": "/notifications"]
|
||||
|
||||
let identifier = "ditto-\(event.id.prefix(16))"
|
||||
let request = UNNotificationRequest(
|
||||
identifier: identifier,
|
||||
content: content,
|
||||
trigger: nil // Deliver immediately.
|
||||
)
|
||||
|
||||
try? await center.add(request)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build notification title, body, category ID, and thread identifier for an event.
|
||||
private func notificationContent(
|
||||
event: NostrEvent,
|
||||
authorName: String
|
||||
) -> (title: String, body: String, categoryId: String, threadId: String) {
|
||||
let refId = referencedEventId(from: event) ?? ""
|
||||
|
||||
switch event.kind {
|
||||
case 7:
|
||||
// Reaction — show the reaction content (emoji) if available.
|
||||
let reaction = event.content.isEmpty || event.content == "+" ? "❤️" : event.content
|
||||
return (
|
||||
"\(authorName) reacted \(reaction)",
|
||||
"Reacted to your post",
|
||||
Self.categoryReactions,
|
||||
"reactions:\(refId)"
|
||||
)
|
||||
|
||||
case 6, 16:
|
||||
return (
|
||||
"\(authorName) reposted your note",
|
||||
"",
|
||||
Self.categoryReposts,
|
||||
"reposts:\(refId)"
|
||||
)
|
||||
|
||||
case 9735:
|
||||
let sats = zapAmount(from: event)
|
||||
if sats > 0 {
|
||||
return (
|
||||
"\(formatSats(sats)) sats from \(authorName)",
|
||||
"You received a zap",
|
||||
Self.categoryZaps,
|
||||
"zaps"
|
||||
)
|
||||
}
|
||||
return (
|
||||
"\(authorName) zapped you",
|
||||
"",
|
||||
Self.categoryZaps,
|
||||
"zaps"
|
||||
)
|
||||
|
||||
case 1:
|
||||
let hasETag = event.tags.contains(where: { $0.first == "e" })
|
||||
let preview = contentPreview(event.content, maxLength: 120)
|
||||
if hasETag {
|
||||
return (
|
||||
"\(authorName) replied to you",
|
||||
preview,
|
||||
Self.categoryMentions,
|
||||
"mentions"
|
||||
)
|
||||
}
|
||||
return (
|
||||
"\(authorName) mentioned you",
|
||||
preview,
|
||||
Self.categoryMentions,
|
||||
"mentions"
|
||||
)
|
||||
|
||||
case 1111, 1222, 1244:
|
||||
let preview = contentPreview(event.content, maxLength: 120)
|
||||
// Check if this is a reply to another comment (k tag == "1111").
|
||||
let isReply = event.tags.contains(where: { $0.first == "k" && $0.count > 1 && $0[1] == "1111" })
|
||||
let action = isReply ? "replied to your comment" : "commented on your post"
|
||||
return (
|
||||
"\(authorName) \(action)",
|
||||
preview,
|
||||
Self.categoryComments,
|
||||
"comments:\(refId)"
|
||||
)
|
||||
|
||||
case 8:
|
||||
return (
|
||||
"\(authorName) awarded you a badge",
|
||||
"You received a new badge",
|
||||
Self.categoryBadges,
|
||||
"badges"
|
||||
)
|
||||
|
||||
case 8211:
|
||||
return (
|
||||
"\(authorName) sent you a letter",
|
||||
"You have a new letter waiting for you",
|
||||
Self.categoryLetters,
|
||||
"letters"
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
"\(authorName) interacted with you",
|
||||
"",
|
||||
Self.categoryMentions,
|
||||
"mentions"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate content for notification body preview.
|
||||
private func contentPreview(_ content: String, maxLength: Int) -> String {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// Replace newlines with spaces for a single-line preview.
|
||||
let singleLine = trimmed.replacingOccurrences(
|
||||
of: "\\s*\\n+\\s*",
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
guard singleLine.count > maxLength else { return singleLine }
|
||||
return String(singleLine.prefix(maxLength)) + "…"
|
||||
}
|
||||
|
||||
// MARK: - Zap Amount Extraction
|
||||
|
||||
/// Extract zap amount in sats from a kind 9735 zap receipt event.
|
||||
/// Checks the "amount" tag first (millisats), then falls back to
|
||||
/// parsing the "description" tag's zap request JSON.
|
||||
private func zapAmount(from event: NostrEvent) -> Int {
|
||||
// Check for direct "amount" tag (value in millisats).
|
||||
for tag in event.tags where tag.first == "amount" && tag.count > 1 {
|
||||
if let msats = Int(tag[1]), msats > 0 {
|
||||
return msats / 1000
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to "description" tag (zap request JSON) -> amount tag.
|
||||
for tag in event.tags where tag.first == "description" && tag.count > 1 {
|
||||
guard let data = tag[1].data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let reqTags = json["tags"] as? [[String]] else { continue }
|
||||
for reqTag in reqTags where reqTag.first == "amount" && reqTag.count > 1 {
|
||||
if let msats = Int(reqTag[1]), msats > 0 {
|
||||
return msats / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/// Format sats for compact display: 500 -> "500", 1500 -> "1.5K", 1000000 -> "1M".
|
||||
private func formatSats(_ sats: Int) -> String {
|
||||
if sats >= 1_000_000 {
|
||||
let val = Double(sats) / 1_000_000.0
|
||||
if val == val.rounded(.down) {
|
||||
return "\(Int(val))M"
|
||||
}
|
||||
return String(format: "%.1fM", val).replacingOccurrences(of: ".0M", with: "M")
|
||||
} else if sats >= 1_000 {
|
||||
let val = Double(sats) / 1_000.0
|
||||
if val == val.rounded(.down) {
|
||||
return "\(Int(val))K"
|
||||
}
|
||||
return String(format: "%.1fK", val).replacingOccurrences(of: ".0K", with: "K")
|
||||
}
|
||||
return "\(sats)"
|
||||
}
|
||||
|
||||
// MARK: - Last-Seen Timestamp
|
||||
|
||||
var lastSeenTimestamp: Int {
|
||||
UserDefaults.standard.integer(forKey: Self.lastSeenKey)
|
||||
}
|
||||
|
||||
func setLastSeenTimestamp(_ ts: Int) {
|
||||
UserDefaults.standard.set(ts, forKey: Self.lastSeenKey)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
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),
|
||||
@@ -58,16 +59,33 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
)
|
||||
self.sandboxes[sandboxId] = sandbox
|
||||
|
||||
// Add the WebView on top of the Capacitor WebView.
|
||||
// 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.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")
|
||||
@@ -87,7 +105,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.webView.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
sandbox.containerView.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
@@ -153,7 +171,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
|
||||
sandbox.webView.removeFromSuperview()
|
||||
sandbox.containerView.removeFromSuperview()
|
||||
sandbox.schemeHandler.cancelAll()
|
||||
}
|
||||
call.resolve()
|
||||
@@ -183,13 +201,19 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// MARK: - SandboxInstance
|
||||
|
||||
/// Manages a single sandboxed WKWebView instance.
|
||||
private class SandboxInstance: NSObject, WKScriptMessageHandler {
|
||||
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
|
||||
@@ -224,19 +248,54 @@ private class SandboxInstance: NSObject, WKScriptMessageHandler {
|
||||
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
|
||||
self.webView = WKWebView(frame: frame, configuration: config)
|
||||
// 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 = .white
|
||||
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 after super.init().
|
||||
// Register the message handler and navigation delegate after super.init().
|
||||
userContentController.add(self, name: "sandboxBridge")
|
||||
self.webView.navigationDelegate = self
|
||||
}
|
||||
|
||||
// Load the initial page via the custom scheme.
|
||||
let initialURL = URL(string: "\(self.customScheme)://app/index.html")!
|
||||
self.webView.load(URLRequest(url: initialURL))
|
||||
/// 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.
|
||||
@@ -270,6 +329,13 @@ private class SandboxInstance: NSObject, WKScriptMessageHandler {
|
||||
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:
|
||||
|
||||
@@ -13,10 +13,14 @@ let package = Package(
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorBarcodeScanner", path: "../../../node_modules/@capacitor/barcode-scanner"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
|
||||
.package(name: "CapgoCapacitorAutofillSavePassword", path: "../../../node_modules/@capgo/capacitor-autofill-save-password"),
|
||||
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -25,10 +29,14 @@ let package = Package(
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorBarcodeScanner", package: "CapacitorBarcodeScanner"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
|
||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorShare", package: "CapacitorShare"),
|
||||
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
|
||||
.product(name: "CapgoCapacitorAutofillSavePassword", package: "CapgoCapacitorAutofillSavePassword"),
|
||||
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"name": "agora",
|
||||
"private": true,
|
||||
"version": "2.6.1",
|
||||
"version": "2.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -15,12 +15,16 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "0.13.1",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/haptics": "^8.0.2",
|
||||
"@capacitor/keyboard": "^8.0.3",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-autofill-save-password": "^8.0.22",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -65,8 +69,10 @@
|
||||
"@milkdown/prose": "^7.20.0",
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -96,15 +102,19 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@scure/bip32": "^2.0.1",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.0.10",
|
||||
"@unhead/react": "^2.0.10",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
@@ -112,9 +122,15 @@
|
||||
"fflate": "^0.8.2",
|
||||
"hls.js": "^1.6.15",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"i18next": "^26.0.5",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"iso-3166": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.8.0",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
@@ -123,7 +139,9 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.3",
|
||||
"react-router-dom": "^6.26.2",
|
||||
@@ -148,7 +166,9 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"GZLTTH5DLM.pub.agora.app"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
[{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "pub.ditto.app",
|
||||
"sha256_cert_fingerprints": ["7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71"]
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "pub.agora.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71",
|
||||
"E5:B1:A9:13:C9:37:35:3C:A5:E7:27:89:C0:9D:3D:0D:A5:4F:F5:26:88:06:BD:24:46:21:AB:61:6B:CC:C5:E5"
|
||||
]
|
||||
}
|
||||
}
|
||||
}]
|
||||
]
|
||||
@@ -1,5 +1,127 @@
|
||||
# Changelog
|
||||
|
||||
## [2.8.0] - 2026-04-16
|
||||
|
||||
### 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
|
||||
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 981 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 226 KiB |
@@ -1,32 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
viewBox="-5 -10 100 100"
|
||||
id="svg6"
|
||||
width="100"
|
||||
height="100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs6" />
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="m 71.719615,49.36907 -0.62891,0.37109 c -0.12891,0.07031 -0.26172,0.14844 -0.39062,0.21875 -3.9883,10.309 -14.008,17.617 -25.699,17.617 -4.1211,0 -8.0312,-0.89844 -11.539,-2.5391 -0.12891,0.03906 -0.26172,0.07031 -0.39063,0.10156 l -0.35156,0.08984 h -0.02734 l -0.25,0.05859 -0.07813,0.01953 -0.10938,0.03125 c -0.55859,0.12891 -1.1289,0.26172 -1.6992,0.39062 -0.10156,0.03125 -0.19922,0.05078 -0.30078,0.07031 l -0.30078,0.10156 -0.18359,0.0078 c -0.26953,0.05859 -1.3086,0.26953 -1.3086,0.26953 -0.28906,0.05859 -0.55859,0.10937 -0.82813,0.17187 4.9805,3.3086 10.961,5.2305 17.371,5.2305 15.059,0 27.699,-10.602 30.828,-24.738 -0.75,0.48828 -1.5195,0.96875 -2.2891,1.4414 -0.59375,0.36328 -1.2031,0.72656 -1.8242,1.0859 z"
|
||||
id="path1"
|
||||
style="fill:#7c52e0;fill-opacity:1" />
|
||||
<path
|
||||
d="m 30.926615,29.47807 c 0.36328,-0.48828 0.75,-0.95312 1.1523,-1.3828 0.75781,-0.80469 0.71484,-2.0703 -0.08984,-2.8281 -0.80469,-0.75781 -2.0703,-0.71484 -2.8281,0.08984 -0.50781,0.53906 -0.99219,1.125 -1.4492,1.7383 -0.65625,0.88672 -0.47266,2.1406 0.41406,2.7969 0.35938,0.26562 0.77344,0.39453 1.1875,0.39453 0.61719,0 1.2227,-0.27734 1.6133,-0.80859 z"
|
||||
id="path2"
|
||||
style="fill:#7c52e0;fill-opacity:1" />
|
||||
<path
|
||||
d="m 26.742615,32.67807 c -1.0586,-0.3125 -2.1719,0.29687 -2.4805,1.3594 -0.55859,1.9062 -0.83984,3.9141 -0.83984,5.9609 0,2.3789 0.39062,4.7227 1.1602,6.9609 0.28516,0.82812 1.0625,1.3516 1.8906,1.3516 0.21484,0 0.43359,-0.03516 0.64844,-0.10938 1.043,-0.35938 1.6016,-1.4961 1.2422,-2.543 -0.625,-1.8203 -0.94141,-3.7227 -0.94141,-5.6602 0,-1.668 0.22656,-3.2969 0.67969,-4.8398 0.30859,-1.0586 -0.30078,-2.168 -1.3594,-2.4805 z"
|
||||
id="path3"
|
||||
style="fill:#7c52e0;fill-opacity:1" />
|
||||
<path
|
||||
d="m 14.691615,48.83807 c 0.10156,0.33984 0.19922,0.67969 0.32812,1.0195 0.42969,1.3516 0.94922,2.6484 1.5781,3.9102 0.10156,-0.01172 0.21094,-0.01172 0.32031,-0.01953 l 0.16016,-0.01172 0.80078,-0.07031 c 0.37109,-0.03906 0.67188,-0.07031 0.98047,-0.10156 0.51172,-0.05859 1.0195,-0.12109 1.5586,-0.19922 l 0.21875,-0.03125 c 0.07031,-0.01172 0.14062,-0.01953 0.21094,-0.03125 -1.2188,-2.2109 -2.1484,-4.6016 -2.7305,-7.1211 -0.16016,-0.71094 -0.30078,-1.4297 -0.39844,-2.1602 -0.19922,-1.3086 -0.30078,-2.6602 -0.30078,-4.0312 0,-0.89844 0.03906,-1.7812 0.12891,-2.6484 0.07031,-0.71094 0.16016,-1.4102 0.28906,-2.1016 2.25,-12.949 13.57,-22.828 27.16,-22.828 6.0508,0 11.648,1.9609 16.211,5.3008 0.57031,0.41016 1.1289,0.85938 1.6719,1.3203 1.6914,1.4219 3.2109,3.0703 4.5,4.8789 0.42969,0.60156 0.83984,1.2109 1.2188,1.8398 1.3203,2.1602 2.3398,4.5117 3.0195,7 0.23828,-0.17188 0.42969,-0.30859 0.62891,-0.46875 0.64844,-0.48047 1.2109,-0.92188 1.7383,-1.3398 0.28125,-0.23047 0.5,-0.41016 0.71094,-0.57812 0.10156,-0.07813 0.19141,-0.16016 0.28125,-0.23828 -0.42969,-1.3516 -0.96094,-2.6484 -1.5898,-3.8984 -0.14062,-0.32812 -0.30859,-0.64844 -0.48047,-0.96875 -0.32812,-0.64844 -0.69922,-1.2891 -1.0898,-1.9102 -1.6797,-2.7109 -3.7695,-5.1406 -6.1719,-7.2188 -0.57031,-0.48828 -1.1484,-0.96875 -1.7617,-1.4102 -0.55859,-0.42188 -1.1406,-0.82812 -1.7305,-1.2109 -4.9414,-3.2188 -10.852,-5.0898 -17.16,-5.0898 -14.961,0 -27.531,10.469 -30.75,24.469 -0.17188,0.67969 -0.30859,1.3711 -0.42188,2.0703 -0.12891,0.73828 -0.21875,1.5 -0.28906,2.2617 -0.07813,0.91016 -0.12109,1.8398 -0.12109,2.7812 0,2.3008 0.25,4.5508 0.71875,6.7109 0.17188,0.71484 0.35156,1.4258 0.5625,2.125 z"
|
||||
id="path4"
|
||||
style="fill:#7c52e0;fill-opacity:1" />
|
||||
<path
|
||||
d="m 90.441615,21.60007 c -2.1797,-5.3398 -9.4102,-7.3984 -21,-6.0391 1.8906,1.8906 3.5391,3.9688 4.9297,6.2109 0.28906,0.46094 0.55859,0.92187 0.80859,1.3789 5.5391,-0.12109 7.6094,1.0391 7.8398,1.4492 0.12891,0.46875 -0.55078,2.7305 -4.5898,6.4805 -0.01953,0.01953 -0.03125,0.03125 -0.03906,0.03906 -0.26172,0.23828 -0.51953,0.48047 -0.80078,0.71875 -0.19922,0.17969 -0.41016,0.35938 -0.62891,0.53906 -0.10938,0.10156 -0.21875,0.19141 -0.33984,0.28906 -0.23828,0.19922 -0.5,0.41016 -0.76172,0.62109 -0.12891,0.10156 -0.26172,0.21094 -0.39844,0.32031 -0.42969,0.33984 -0.89063,0.69141 -1.3711,1.0508 -0.26953,0.21094 -0.53906,0.41016 -0.82812,0.60938 -0.32031,0.23047 -0.64062,0.46875 -0.98047,0.69922 0,0.01172 -0.01172,0.01172 -0.01172,0.01172 -0.26953,0.19141 -0.55078,0.37891 -0.82812,0.57031 -0.28125,0.19141 -0.55859,0.37109 -0.85156,0.55859 -0.25,0.16016 -0.5,0.32812 -0.76172,0.48828 -6,3.8984 -13.48,7.7188 -21.379,10.922 -8.0117,3.2383 -15.871,5.6602 -22.93,7.0391 -0.30078,0.05859 -0.60156,0.12109 -0.89062,0.17188 -0.60938,0.12109 -1.2188,0.21875 -1.8203,0.32031 -0.07031,0.01172 -0.12891,0.01953 -0.19922,0.03125 h -0.01953 c -0.28906,0.05078 -0.57031,0.08984 -0.83984,0.12891 -0.30859,0.05078 -0.60938,0.08984 -0.91016,0.12891 -0.57031,0.07813 -1.1094,0.14844 -1.6406,0.21094 -0.35156,0.03906 -0.69141,0.07031 -1.0195,0.10156 -0.30078,0.03125 -0.58984,0.05078 -0.87891,0.07813 -0.48047,0.03125 -0.92969,0.05859 -1.3711,0.07813 -0.39844,0.01953 -0.78125,0.03125 -1.1484,0.03906 -5.5116996,0.10938 -7.5702996,-1.0391 -7.8007996,-1.4492 -0.12891,-0.48047 0.55078,-2.7383 4.6093996,-6.5 -0.12891,-0.48828 -0.26172,-1 -0.37891,-1.5391 -0.51953,-2.4219 -0.78906,-4.8906 -0.78906,-7.3594 0,-0.17969 0,-0.37109 0.01172,-0.55078 -9.2733996,7.082 -13.0229996,13.59 -10.8749996,18.949 1.7383,4.2695 6.7188,6.4492 14.5899996,6.4492 2.8594,0 6.1016,-0.28906 9.7109,-0.87109 0.17188,-0.03125 0.33984,-0.05859 0.51953,-0.08984 0.17188,-0.03125 0.35156,-0.05859 0.51953,-0.08984 l 1.2188,-0.21875 c 0.57031,-0.10156 1.1484,-0.21875 1.7305,-0.33984 0.53125,-0.10938 1.0508,-0.21094 1.5781,-0.32812 0.01172,0 0.03125,-0.01172 0.03906,-0.01172 0.05078,-0.01172 0.08984,-0.01953 0.14062,-0.03125 0.07031,-0.01172 0.12891,-0.03125 0.19922,-0.05078 0.57812,-0.12891 1.1602,-0.26172 1.7383,-0.39844 0.05078,-0.01172 0.10156,-0.03125 0.14844,-0.03906 0.21094,-0.05078 0.42188,-0.10156 0.64062,-0.14844 h 0.01172 c 6.0898,-1.5117 12.559,-3.6406 19.102,-6.2891 6.5508,-2.6602 12.68,-5.6289 18.109,-8.7812 0.21875,-0.12891 0.44141,-0.26172 0.66016,-0.39062 0.58984,-0.33984 1.1797,-0.69141 1.7617,-1.0508 1.5703,-0.96094 3.0586,-1.9297 4.4805,-2.9102 0.12891,-0.08984 0.26172,-0.17969 0.39062,-0.26953 11.242,-7.8477 15.941,-15.09 13.594,-20.938 z"
|
||||
id="path5"
|
||||
style="fill:#7c52e0;fill-opacity:1" />
|
||||
d="M13 2L3 14h8l-1 8 10-12h-8l1-8z"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 320 B |
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "Ditto",
|
||||
"short_name": "Ditto",
|
||||
"description": "A carnival, not a platform. Color, whimsy, games, and endless customization — the most fun you've had on the internet in years.",
|
||||
"name": "Agora",
|
||||
"short_name": "Agora",
|
||||
"description": "Power to the people. Organize, create, and connect across the open Nostr network.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#161b2e",
|
||||
"theme_color": "#7c3aed",
|
||||
"background_color": "#0a0c14",
|
||||
"theme_color": "#e85d3c",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Ditto Service Worker
|
||||
* Agora Service Worker
|
||||
*
|
||||
* Handles incoming Web Push notifications from the nostr-push server and
|
||||
* opens/focuses the app when the user taps a notification.
|
||||
@@ -14,17 +14,17 @@ self.addEventListener('push', (event) => {
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
payload = { title: 'Ditto', body: event.data.text() };
|
||||
payload = { title: 'Agora', body: event.data.text() };
|
||||
}
|
||||
|
||||
const title = payload.title ?? 'Ditto';
|
||||
const title = payload.title ?? 'Agora';
|
||||
const options = {
|
||||
body: payload.body ?? '',
|
||||
icon: payload.icon ?? '/icon-192.png',
|
||||
badge: payload.badge ?? '/icon-192.png',
|
||||
data: payload.data ?? {},
|
||||
requireInteraction: false,
|
||||
tag: payload.data?.subscription_id ?? 'ditto-notification',
|
||||
tag: payload.data?.subscription_id ?? 'agora-notification',
|
||||
renotify: true,
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
// Focus an existing Ditto tab if one is open
|
||||
// Focus an existing Agora tab if one is open
|
||||
for (const client of clientList) {
|
||||
if (new URL(client.url).origin === self.location.origin) {
|
||||
client.navigate('/notifications');
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
(function () {
|
||||
// Builtin themes — must match builtinThemes in src/themes.ts
|
||||
var builtins = {
|
||||
dark: { bg: 'hsl(228 20% 10%)', primary: 'hsl(258 70% 60%)' },
|
||||
light: { bg: 'hsl(270 50% 97%)', primary: 'hsl(270 65% 55%)' }
|
||||
dark: { bg: 'hsl(0 0% 10%)', primary: 'hsl(15 90% 52%)' },
|
||||
light: { bg: 'hsl(0 0% 100%)', primary: 'hsl(15 90% 48%)' }
|
||||
};
|
||||
|
||||
var theme = 'dark';
|
||||
|
||||
@@ -33,7 +33,7 @@ function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const result = {
|
||||
relays: [],
|
||||
name: 'Ditto',
|
||||
name: 'Agora',
|
||||
timeout: 300, // seconds
|
||||
};
|
||||
|
||||
@@ -54,9 +54,9 @@ function parseArgs() {
|
||||
|
||||
Options:
|
||||
--relay <url> Relay URL for NIP-46 communication (repeatable)
|
||||
Default: wss://relay.ditto.pub
|
||||
Default: wss://relay.agora.spot
|
||||
--name <name> Application name shown to the signer
|
||||
Default: Ditto
|
||||
Default: Agora
|
||||
--timeout <sec> How long to wait for signer approval (seconds)
|
||||
Default: 300 (5 minutes)
|
||||
--help, -h Show this help message
|
||||
@@ -66,7 +66,7 @@ Options:
|
||||
}
|
||||
|
||||
if (result.relays.length === 0) {
|
||||
result.relays.push('wss://relay.ditto.pub');
|
||||
result.relays.push('wss://relay.agora.spot');
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/** Local plugin class names to ensure are registered. */
|
||||
const LOCAL_PLUGINS = ['SandboxPlugin'];
|
||||
const LOCAL_PLUGINS = ['SandboxPlugin', 'DittoNotificationPlugin'];
|
||||
|
||||
const platforms = ['ios/App/App', 'android/app/src/main/assets'];
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// 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 } from "@capacitor/core";
|
||||
import { StatusBar, Style } from "@capacitor/status-bar";
|
||||
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";
|
||||
@@ -22,8 +21,10 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
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 { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
|
||||
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
@@ -48,8 +49,8 @@ const queryClient = new QueryClient({
|
||||
|
||||
/** Hardcoded fallback values. Always provides every required field. */
|
||||
const hardcodedConfig: AppConfig = {
|
||||
appName: "Ditto",
|
||||
appId: "ditto",
|
||||
appName: "Agora",
|
||||
appId: "agora",
|
||||
homePage: "feed",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
magicMouse: false,
|
||||
@@ -111,6 +112,8 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludePodcastTrailers: true,
|
||||
showDevelopment: true,
|
||||
feedIncludeDevelopment: true,
|
||||
showCommunities: true,
|
||||
feedIncludeCommunities: true,
|
||||
showBadges: true,
|
||||
showBadgeDefinitions: true,
|
||||
showProfileBadges: true,
|
||||
@@ -121,16 +124,17 @@ const hardcodedConfig: AppConfig = {
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
"wallet",
|
||||
"verified",
|
||||
"actions",
|
||||
"polls",
|
||||
"world",
|
||||
"badges",
|
||||
"feed",
|
||||
"notifications",
|
||||
"search",
|
||||
"blobbi",
|
||||
"badges",
|
||||
"emojis",
|
||||
"letters",
|
||||
"themes",
|
||||
"communities",
|
||||
"profile",
|
||||
"settings",
|
||||
"help",
|
||||
],
|
||||
nip85StatsPubkey:
|
||||
"5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea",
|
||||
@@ -151,45 +155,50 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
sidebarWidgets: [
|
||||
{ id: 'trends' },
|
||||
{ id: 'hot-posts' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse and validate build-time ditto.json overrides from the env string.
|
||||
* Parse and validate build-time app config overrides from the env string.
|
||||
* Returns an empty object when no config file was provided or validation fails.
|
||||
*/
|
||||
function parseDittoConfig(): DittoConfig {
|
||||
function parseBuildConfig(): BuildConfig {
|
||||
try {
|
||||
const json = JSON.parse(import.meta.env.DITTO_CONFIG);
|
||||
const encodedConfig = import.meta.env.APP_CONFIG ?? import.meta.env.DITTO_CONFIG;
|
||||
const json = JSON.parse(encodedConfig);
|
||||
if (!json) return {};
|
||||
return DittoConfigSchema.parse(json);
|
||||
return BuildConfigSchema.parse(json);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge hardcoded defaults with build-time ditto.json overrides.
|
||||
* Merge hardcoded defaults with build-time config overrides.
|
||||
* Deep-merges feedSettings so a partial override doesn't erase defaults.
|
||||
* Precedence (handled by AppProvider): user localStorage > build-time > hardcoded.
|
||||
*/
|
||||
const dittoConfig = parseDittoConfig();
|
||||
const buildConfig = parseBuildConfig();
|
||||
const defaultConfig: AppConfig = {
|
||||
...hardcodedConfig,
|
||||
...dittoConfig,
|
||||
feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings },
|
||||
...buildConfig,
|
||||
feedSettings: { ...hardcodedConfig.feedSettings, ...buildConfig.feedSettings },
|
||||
};
|
||||
|
||||
export function App() {
|
||||
useNsecPasteGuard();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize StatusBar for mobile apps
|
||||
// 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()) {
|
||||
StatusBar.setStyle({ style: Style.Dark }).catch(() => {
|
||||
// StatusBar may not be available on all platforms
|
||||
});
|
||||
StatusBar.setOverlaysWebView({ overlay: true }).catch(() => {
|
||||
// Ignore errors on unsupported platforms
|
||||
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
|
||||
// SystemBars may not be available on all platforms
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
@@ -200,12 +209,13 @@ export function App() {
|
||||
<SentryProvider>
|
||||
<PlausibleProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NostrLoginProvider storageKey="nostr:login">
|
||||
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<NativeNotifications />
|
||||
|
||||
<NWCProvider>
|
||||
<SparkWalletProvider>
|
||||
<DMProvider config={dmConfig}>
|
||||
<EmotionDevProvider>
|
||||
<TooltipProvider>
|
||||
@@ -215,6 +225,7 @@ export function App() {
|
||||
</TooltipProvider>
|
||||
</EmotionDevProvider>
|
||||
</DMProvider>
|
||||
</SparkWalletProvider>
|
||||
</NWCProvider>
|
||||
</NostrProvider>
|
||||
</NostrLoginProvider>
|
||||
|
||||
@@ -24,22 +24,16 @@ const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => (
|
||||
// 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 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 BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
|
||||
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
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 ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
@@ -55,38 +49,27 @@ 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 MusicFeedPage = lazy(() => import("./pages/MusicFeedPage").then(m => ({ default: m.MusicFeedPage })));
|
||||
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 VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
|
||||
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
|
||||
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 WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
|
||||
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")!;
|
||||
|
||||
/** Polls feed page with a FAB that opens the compose modal (poll mode via + menu). */
|
||||
function PollsFeedPage() {
|
||||
@@ -108,26 +91,6 @@ 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();
|
||||
@@ -161,7 +124,6 @@ export function AppRouter() {
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<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 />} />
|
||||
@@ -184,38 +146,7 @@ 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={<MusicFeedPage />} />
|
||||
<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
|
||||
@@ -229,41 +160,12 @@ export function AppRouter() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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="/themes" element={<ThemesPage />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
<Route path="/blobbi" element={<BlobbiPage />} />
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<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 />} />
|
||||
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
|
||||
@@ -277,6 +179,8 @@ export function AppRouter() {
|
||||
element={<Navigate to="/lists" replace />}
|
||||
/>
|
||||
<Route path="/i/*" element={<ExternalContentPage />} />
|
||||
<Route path="/actions" element={<ActionsPage />} />
|
||||
<Route path="/organizers" element={<OrganizersPage />} />
|
||||
|
||||
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
|
||||
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Loader2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Coins,
|
||||
X,
|
||||
Eye,
|
||||
Scroll,
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { formatCompactNumber, cn } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -46,14 +45,12 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
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 { useClaimMissionReward } from '../hooks/useClaimMissionReward';
|
||||
import { useRerollMission } from '../hooks/useRerollMission';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -62,8 +59,6 @@ interface BlobbiMissionsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companion: BlobbiCompanion;
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
hatchTasks: HatchTasksResult;
|
||||
evolveTasks: EvolveTasksResult;
|
||||
onOpenPostModal: () => void;
|
||||
@@ -146,16 +141,12 @@ function MissionTypeLegend() {
|
||||
// ─── Daily Missions Section ───────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsSectionProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
disabled?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function DailyMissionsSection({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
availableStages,
|
||||
disabled,
|
||||
defaultOpen = true,
|
||||
@@ -164,23 +155,17 @@ function DailyMissionsSection({
|
||||
|
||||
const {
|
||||
missions,
|
||||
todayClaimedReward,
|
||||
totalPotentialReward,
|
||||
bonusAvailable,
|
||||
bonusClaimed,
|
||||
bonusReward,
|
||||
todayXp,
|
||||
allComplete,
|
||||
bonusUnlocked,
|
||||
bonusXp,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
} = useDailyMissions({ availableStages });
|
||||
|
||||
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
);
|
||||
|
||||
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
|
||||
|
||||
const claimableCount = missions.filter((m) => m.completed && !m.claimed).length;
|
||||
const completedCount = missions.filter((m) => m.complete).length;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -194,13 +179,12 @@ function DailyMissionsSection({
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Summary pill — always visible */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Coins className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
|
||||
<span className="tabular-nums">
|
||||
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
|
||||
{completedCount} / {missions.length}
|
||||
</span>
|
||||
{claimableCount > 0 && (
|
||||
{allComplete && (
|
||||
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
|
||||
{claimableCount}
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -213,13 +197,11 @@ function DailyMissionsSection({
|
||||
<div className="pt-3">
|
||||
<DailyMissionsPanel
|
||||
missions={missions}
|
||||
onClaimReward={(id) => claimReward({ missionId: id })}
|
||||
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
|
||||
todayCoins={todayClaimedReward}
|
||||
disabled={disabled || isClaiming || isRerolling}
|
||||
bonusAvailable={bonusAvailable}
|
||||
bonusClaimed={bonusClaimed}
|
||||
bonusReward={bonusReward}
|
||||
todayXp={todayXp}
|
||||
disabled={disabled || isRerolling}
|
||||
bonusUnlocked={bonusUnlocked}
|
||||
bonusXp={bonusXp}
|
||||
noMissionsAvailable={noMissionsAvailable}
|
||||
rerollsRemaining={rerollsRemaining}
|
||||
isRerolling={isRerolling}
|
||||
@@ -442,8 +424,6 @@ export function BlobbiMissionsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
hatchTasks,
|
||||
evolveTasks,
|
||||
onOpenPostModal,
|
||||
@@ -527,8 +507,6 @@ export function BlobbiMissionsModal({
|
||||
|
||||
{/* 2. Daily Bounties */}
|
||||
<DailyMissionsSection
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
availableStages={availableStages}
|
||||
disabled={isProcessBusy}
|
||||
/>
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
* 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, claim button, and reroll.
|
||||
* 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,
|
||||
Coins,
|
||||
Gift,
|
||||
Sparkles,
|
||||
Gift,
|
||||
Egg,
|
||||
Trophy,
|
||||
RefreshCw,
|
||||
@@ -24,13 +24,13 @@ import {
|
||||
Music,
|
||||
Pill,
|
||||
CircleDot,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
import type { DailyMission, DailyMissionAction } from '../lib/daily-missions';
|
||||
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
|
||||
import type { DailyMissionAction } from '../lib/daily-missions';
|
||||
import type { DailyMissionView } from '../hooks/useDailyMissions';
|
||||
import {
|
||||
ExpandableMissionCard,
|
||||
MissionDescription,
|
||||
@@ -40,14 +40,12 @@ import {
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsPanelProps {
|
||||
missions: DailyMission[];
|
||||
onClaimReward: (missionId: string) => void;
|
||||
missions: DailyMissionView[];
|
||||
onRerollMission?: (missionId: string) => void;
|
||||
todayCoins: number;
|
||||
todayXp: number;
|
||||
disabled?: boolean;
|
||||
bonusAvailable?: boolean;
|
||||
bonusClaimed?: boolean;
|
||||
bonusReward?: number;
|
||||
bonusUnlocked?: boolean;
|
||||
bonusXp?: number;
|
||||
noMissionsAvailable?: boolean;
|
||||
rerollsRemaining?: number;
|
||||
isRerolling?: boolean;
|
||||
@@ -82,51 +80,34 @@ function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
|
||||
// ─── Bonus Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface BonusCardProps {
|
||||
isAvailable: boolean;
|
||||
isClaimed: boolean;
|
||||
reward: number;
|
||||
onClaim: () => void;
|
||||
disabled?: boolean;
|
||||
isUnlocked: boolean;
|
||||
xp: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpanded, onToggle }: BonusCardProps) {
|
||||
const progress = isClaimed ? 1 : isAvailable ? 1 : 0;
|
||||
|
||||
function BonusCard({ isUnlocked, xp, isExpanded, onToggle }: BonusCardProps) {
|
||||
return (
|
||||
<ExpandableMissionCard
|
||||
id="bonus"
|
||||
category="daily"
|
||||
icon={<Trophy className="size-5" />}
|
||||
title="Daily Champion"
|
||||
completed={isClaimed}
|
||||
progress={progress}
|
||||
completed={isUnlocked}
|
||||
progress={isUnlocked ? 1 : 0}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<MissionDescription>
|
||||
{isAvailable || isClaimed
|
||||
? 'Bonus reward for completing all daily missions!'
|
||||
{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-amber-600 dark:text-amber-400">
|
||||
<Coins className="size-3" />
|
||||
+{formatCompactNumber(reward)}
|
||||
<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>
|
||||
|
||||
{isAvailable && !isClaimed && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white h-8 text-xs"
|
||||
>
|
||||
<Trophy className="size-3.5 mr-1.5" />
|
||||
Claim Bonus {formatCompactNumber(reward)} Coins
|
||||
</Button>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
}
|
||||
@@ -147,7 +128,7 @@ function NoMissionsState() {
|
||||
);
|
||||
}
|
||||
|
||||
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
|
||||
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" />
|
||||
@@ -155,8 +136,8 @@ function AllClaimedState({ todayCoins }: { todayCoins: number }) {
|
||||
<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-amber-600 dark:text-amber-400">
|
||||
{formatCompactNumber(todayCoins)} coins
|
||||
<span className="font-medium text-violet-600 dark:text-violet-400">
|
||||
{formatCompactNumber(todayXp)} XP
|
||||
</span>{' '}
|
||||
— come back tomorrow!
|
||||
</p>
|
||||
@@ -187,13 +168,11 @@ function RerollCounter({ remaining }: { remaining: number }) {
|
||||
|
||||
export function DailyMissionsPanel({
|
||||
missions,
|
||||
onClaimReward,
|
||||
onRerollMission,
|
||||
todayCoins,
|
||||
todayXp,
|
||||
disabled,
|
||||
bonusAvailable = false,
|
||||
bonusClaimed = false,
|
||||
bonusReward = 50,
|
||||
bonusUnlocked = false,
|
||||
bonusXp = 50,
|
||||
noMissionsAvailable = false,
|
||||
rerollsRemaining = 0,
|
||||
isRerolling = false,
|
||||
@@ -202,10 +181,8 @@ export function DailyMissionsPanel({
|
||||
|
||||
if (noMissionsAvailable) return <NoMissionsState />;
|
||||
|
||||
const allRegularClaimed = missions.every((m) => m.claimed);
|
||||
const allDone = allRegularClaimed && bonusClaimed;
|
||||
|
||||
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
|
||||
const allComplete = missions.every((m) => m.complete);
|
||||
if (allComplete && bonusUnlocked) return <AllCompleteState todayXp={todayXp} />;
|
||||
|
||||
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
|
||||
|
||||
@@ -220,9 +197,8 @@ export function DailyMissionsPanel({
|
||||
|
||||
{/* Regular mission cards */}
|
||||
{missions.map((mission) => {
|
||||
const progress = mission.requiredCount > 0 ? mission.currentCount / mission.requiredCount : 0;
|
||||
const canClaim = mission.completed && !mission.claimed;
|
||||
const showReroll = onRerollMission && !mission.completed && !mission.claimed && canReroll;
|
||||
const progressFrac = mission.target > 0 ? mission.progress / mission.target : 0;
|
||||
const showReroll = onRerollMission && !mission.complete && canReroll;
|
||||
|
||||
return (
|
||||
<ExpandableMissionCard
|
||||
@@ -231,8 +207,8 @@ export function DailyMissionsPanel({
|
||||
category="daily"
|
||||
icon={<DailyMissionIcon action={mission.action} />}
|
||||
title={mission.title}
|
||||
completed={mission.claimed}
|
||||
progress={Math.min(progress, 1)}
|
||||
completed={mission.complete}
|
||||
progress={Math.min(progressFrac, 1)}
|
||||
isExpanded={expandedId === mission.id}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
@@ -240,19 +216,19 @@ export function DailyMissionsPanel({
|
||||
<MissionDescription>{mission.description}</MissionDescription>
|
||||
|
||||
{/* Progress */}
|
||||
{!mission.claimed && (
|
||||
{!mission.complete && (
|
||||
<MissionProgress
|
||||
current={mission.currentCount}
|
||||
required={mission.requiredCount}
|
||||
completed={mission.completed}
|
||||
current={mission.progress}
|
||||
required={mission.target}
|
||||
completed={mission.complete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reward + reroll row */}
|
||||
{/* XP + reroll row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
<Coins className="size-3" />
|
||||
{formatCompactNumber(mission.reward)}
|
||||
<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 && (
|
||||
@@ -277,7 +253,7 @@ export function DailyMissionsPanel({
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{mission.claimed && (
|
||||
{mission.complete && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
|
||||
<Check className="size-2.5" />
|
||||
Done
|
||||
@@ -285,20 +261,12 @@ export function DailyMissionsPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{canClaim && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClaimReward(mission.id);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-8 text-xs"
|
||||
>
|
||||
<Gift className="size-3.5 mr-1.5" />
|
||||
Claim {formatCompactNumber(mission.reward)} Coins
|
||||
</Button>
|
||||
{/* 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>
|
||||
);
|
||||
@@ -306,11 +274,8 @@ export function DailyMissionsPanel({
|
||||
|
||||
{/* Bonus card */}
|
||||
<BonusCard
|
||||
isAvailable={bonusAvailable}
|
||||
isClaimed={bonusClaimed}
|
||||
reward={bonusReward}
|
||||
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
|
||||
disabled={disabled}
|
||||
isUnlocked={bonusUnlocked}
|
||||
xp={bonusXp}
|
||||
isExpanded={expandedId === 'bonus'}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
|
||||
@@ -16,15 +16,12 @@ import {
|
||||
clampStat,
|
||||
applyStat,
|
||||
DIRECT_ACTION_METADATA,
|
||||
incrementInteractionTaskTags,
|
||||
type DirectAction,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
|
||||
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 { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
// Import NostrEvent type
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
@@ -149,13 +146,11 @@ export function useBlobbiDirectAction({
|
||||
// ─── Update Blobbi State Event (kind 31124) ───
|
||||
const nowStr = now.toString();
|
||||
|
||||
// If incubating or evolving, increment the interaction counter for tasks
|
||||
// If incubating or evolving, increment the interaction counter in evolution missions
|
||||
const companionState = canonical.companion.state;
|
||||
let updatedTags = canonical.allTags;
|
||||
if (companionState === 'incubating') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
|
||||
} else if (companionState === 'evolving') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
|
||||
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)
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -264,6 +269,14 @@ export function useStartIncubation({
|
||||
|
||||
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,
|
||||
@@ -418,23 +431,18 @@ export function useStopIncubation({
|
||||
|
||||
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: 'Incubation stopped',
|
||||
description: `${name} is no longer incubating. Task progress has been reset.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Failed to stop incubation',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -556,6 +564,14 @@ export function useStartEvolution({
|
||||
|
||||
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,
|
||||
@@ -695,6 +711,14 @@ export function useStopEvolution({
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -715,166 +739,4 @@ export function useStopEvolution({
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Sync Task Completions Hook ───────────────────────────────────────────────
|
||||
|
||||
/** Enable debug logging in development only */
|
||||
const DEBUG_TASK_SYNC = import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* Parameters for syncing task completions (works for both hatch and evolve).
|
||||
*/
|
||||
export interface UseSyncTaskCompletionsParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called to ensure companion is canonical */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task completions to sync (from useHatchTasks or useEvolveTasks).
|
||||
*/
|
||||
export interface TaskCompletionToSync {
|
||||
taskId: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of sync operation.
|
||||
*/
|
||||
export interface SyncTaskCompletionsResult {
|
||||
/** Task IDs that were synced (empty if nothing needed) */
|
||||
synced: string[];
|
||||
/** Whether sync was skipped (no diff) */
|
||||
skipped: boolean;
|
||||
/** Reason for skip (for debugging) */
|
||||
skipReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to sync persistent task completions to kind 31124 tags.
|
||||
* Works for both hatch (incubating) and evolve (evolving) processes.
|
||||
*
|
||||
* CRITICAL: This is a cache-only sync. It must be:
|
||||
* 1. Fully idempotent - calling multiple times with same data = no-op
|
||||
* 2. Diff-based - only publish when tags would actually change
|
||||
* 3. Safe - no last_interaction update (this is cache sync, not user action)
|
||||
* 4. Only sync PERSISTENT tasks - dynamic tasks must NEVER be synced
|
||||
*
|
||||
* Source of truth = computed task state from Nostr events.
|
||||
* Tags = cache layer for faster access.
|
||||
*/
|
||||
export function useSyncTaskCompletions({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
}: UseSyncTaskCompletionsParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (tasksToSync: TaskCompletionToSync[]): Promise<SyncTaskCompletionsResult> => {
|
||||
// ─── Early Guards ───
|
||||
if (!user?.pubkey) {
|
||||
return { synced: [], skipped: true, skipReason: 'no_user' };
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
return { synced: [], skipped: true, skipReason: 'no_companion' };
|
||||
}
|
||||
|
||||
// Must be in an active task process (incubating or evolving)
|
||||
if (companion.state !== 'incubating' && companion.state !== 'evolving') {
|
||||
return { synced: [], skipped: true, skipReason: 'not_in_task_process' };
|
||||
}
|
||||
|
||||
// ─── Compute Diff ───
|
||||
// Get cached completions from companion.tasksCompleted (parsed from tags)
|
||||
const cachedCompletions = new Set(companion.tasksCompleted);
|
||||
|
||||
// Get computed completions from tasks (works for both hatch and evolve)
|
||||
const computedCompletions = tasksToSync
|
||||
.filter(t => t.completed)
|
||||
.map(t => t.taskId);
|
||||
|
||||
// Find tasks that are computed as complete but NOT in cache
|
||||
const missingFromCache = computedCompletions.filter(id => !cachedCompletions.has(id));
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Diff check:', {
|
||||
cachedCompletions: Array.from(cachedCompletions),
|
||||
computedCompletions,
|
||||
missingFromCache,
|
||||
});
|
||||
}
|
||||
|
||||
// If no diff, skip entirely
|
||||
if (missingFromCache.length === 0) {
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Skipped: no diff between computed and cached');
|
||||
}
|
||||
return { synced: [], skipped: true, skipReason: 'no_diff' };
|
||||
}
|
||||
|
||||
// ─── Ensure Canonical ───
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) {
|
||||
return { synced: [], skipped: true, skipReason: 'canonical_failed' };
|
||||
}
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
// Re-check against canonical.allTags (may have updated since companion was parsed)
|
||||
const existingCompletionTags = new Set(
|
||||
canonical.allTags
|
||||
.filter(tag => tag[0] === 'task_completed')
|
||||
.map(tag => tag[1])
|
||||
);
|
||||
|
||||
// Filter to only truly missing tags
|
||||
const tagsToAdd = missingFromCache.filter(id => !existingCompletionTags.has(id));
|
||||
|
||||
if (tagsToAdd.length === 0) {
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Skipped: all tags already exist in canonical');
|
||||
}
|
||||
return { synced: [], skipped: true, skipReason: 'tags_already_exist' };
|
||||
}
|
||||
|
||||
// Add only the missing task_completed tags
|
||||
// CRITICAL: Do NOT update last_interaction - this is cache sync, not user action
|
||||
const updatedTags = [
|
||||
...canonical.allTags,
|
||||
...tagsToAdd.map(id => ['task_completed', id]),
|
||||
];
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Publishing:', {
|
||||
tagsToAdd,
|
||||
totalTags: updatedTags.length,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Publish ───
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: canonical.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Published successfully:', tagsToAdd);
|
||||
}
|
||||
|
||||
return { synced: tagsToAdd, skipped: false };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,7 +200,14 @@ export function useBlobbiHatch({
|
||||
console.log('[Hatch] Tag repairs applied:', repairResult.repairs);
|
||||
}
|
||||
|
||||
const newTags = repairResult.tags;
|
||||
// ─── 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
|
||||
|
||||
@@ -21,16 +21,13 @@ import {
|
||||
applyStat,
|
||||
hasMedicineEffectForEgg,
|
||||
hasHygieneEffectForEgg,
|
||||
incrementInteractionTaskTags,
|
||||
type InventoryAction,
|
||||
ACTION_METADATA,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
|
||||
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';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
/**
|
||||
* Request payload for using an item on a Blobbi companion
|
||||
@@ -243,13 +240,11 @@ export function useBlobbiUseInventoryItem({
|
||||
// ─── Update Blobbi State Event (kind 31124) ───
|
||||
const nowStr = now.toString();
|
||||
|
||||
// If incubating or evolving, increment the interaction counter for tasks
|
||||
// If incubating or evolving, increment the interaction counter in evolution missions
|
||||
const companionState = canonical.companion.state;
|
||||
let updatedTags = canonical.allTags;
|
||||
if (companionState === 'incubating') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
|
||||
} else if (companionState === 'evolving') {
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
|
||||
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)
|
||||
|
||||
@@ -1,242 +1,121 @@
|
||||
/**
|
||||
* useClaimMissionReward - Hook for claiming daily mission rewards
|
||||
*
|
||||
* Handles:
|
||||
* - Persisting coin rewards to kind 11125 Blobbonaut profile
|
||||
* - Updating localStorage mission state
|
||||
* - Idempotent claiming (prevents double-credit)
|
||||
* - Optimistic cache updates
|
||||
* 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 type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbonautTags,
|
||||
parseBlobbonautEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
isBonusMissionAvailable,
|
||||
isBonusMissionClaimed,
|
||||
BONUS_MISSION_DEFINITION,
|
||||
} from '../lib/daily-missions';
|
||||
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 ClaimMissionRequest {
|
||||
missionId: string;
|
||||
export interface AwardDailyXpRequest {
|
||||
/** Current missions state to calculate XP from */
|
||||
missions: MissionsContent;
|
||||
}
|
||||
|
||||
/** Special ID for claiming the bonus mission */
|
||||
export const BONUS_MISSION_ID = 'bonus_daily_complete';
|
||||
|
||||
export interface ClaimMissionResult {
|
||||
missionId: string;
|
||||
coinsEarned: number;
|
||||
newTotalCoins: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useClaimMissionReward] Failed to write state:', error);
|
||||
}
|
||||
export interface AwardDailyXpResult {
|
||||
xpAwarded: number;
|
||||
newTotalXp: number;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to claim daily mission rewards.
|
||||
*
|
||||
* This hook persists coin rewards to the kind 11125 Blobbonaut profile event,
|
||||
* ensuring rewards are stored on-chain rather than just in localStorage.
|
||||
*
|
||||
* @param currentProfile - The current Blobbonaut profile (required for coin updates)
|
||||
* @param updateProfileEvent - Callback to update the profile in the query cache
|
||||
* Hook to award XP for completed daily missions.
|
||||
*
|
||||
* @param updateProfileEvent - Callback to update profile in query cache
|
||||
*/
|
||||
export function useClaimMissionReward(
|
||||
currentProfile: BlobbonautProfile | null,
|
||||
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void
|
||||
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 ({ missionId }: ClaimMissionRequest): Promise<ClaimMissionResult> => {
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to claim rewards');
|
||||
}
|
||||
mutationFn: async ({ missions }: AwardDailyXpRequest): Promise<AwardDailyXpResult> => {
|
||||
if (!user?.pubkey) throw new Error('Must be logged in');
|
||||
|
||||
if (!currentProfile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
const xpToAward = totalDailyXp(missions);
|
||||
if (xpToAward <= 0) return { xpAwarded: 0, newTotalXp: 0 };
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
|
||||
// Ensure we have valid state for today
|
||||
if (needsDailyReset(missionsState)) {
|
||||
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins);
|
||||
}
|
||||
|
||||
// Handle bonus mission claim
|
||||
if (missionId === BONUS_MISSION_ID) {
|
||||
// Check if bonus is available
|
||||
if (!isBonusMissionAvailable(missionsState!)) {
|
||||
throw new Error('Bonus mission not available yet');
|
||||
}
|
||||
|
||||
// Check if already claimed
|
||||
if (isBonusMissionClaimed(missionsState!)) {
|
||||
throw new Error('Bonus reward already claimed');
|
||||
}
|
||||
|
||||
const coinsToAdd = BONUS_MISSION_DEFINITION.reward;
|
||||
const newTotalCoins = currentProfile.coins + coinsToAdd;
|
||||
|
||||
// Build updated tags with new coin balance
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
coins: newTotalCoins.toString(),
|
||||
});
|
||||
|
||||
// Publish updated profile event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update the query cache
|
||||
updateProfileEvent(event);
|
||||
|
||||
// Update localStorage to mark bonus as claimed
|
||||
const updatedState: DailyMissionsState = {
|
||||
...missionsState!,
|
||||
bonusClaimed: true,
|
||||
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
|
||||
};
|
||||
|
||||
writeMissionsState(updatedState);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, claimed: true, isBonus: true }
|
||||
}));
|
||||
|
||||
return {
|
||||
missionId,
|
||||
coinsEarned: coinsToAdd,
|
||||
newTotalCoins,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle regular mission claim
|
||||
const mission = missionsState!.missions.find(m => m.id === missionId);
|
||||
if (!mission) {
|
||||
throw new Error('Mission not found');
|
||||
}
|
||||
|
||||
// Check if already claimed (idempotency check)
|
||||
if (mission.claimed) {
|
||||
throw new Error('Reward already claimed');
|
||||
}
|
||||
|
||||
// Check if mission is completed
|
||||
if (!mission.completed) {
|
||||
throw new Error('Mission not completed yet');
|
||||
}
|
||||
|
||||
const coinsToAdd = mission.reward;
|
||||
const newTotalCoins = currentProfile.coins + coinsToAdd;
|
||||
|
||||
// Build updated tags with new coin balance
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
coins: newTotalCoins.toString(),
|
||||
// Fetch fresh profile from relays to avoid stale-read overwrites
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [KIND_BLOBBONAUT_PROFILE],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
|
||||
// Publish updated profile event to kind 11125
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
const freshProfile = prev ? parseBlobbonautEvent(prev) : undefined;
|
||||
const currentXp = freshProfile?.xp ?? 0;
|
||||
const newTotalXp = currentXp + xpToAward;
|
||||
|
||||
// Update the query cache optimistically
|
||||
updateProfileEvent(event);
|
||||
|
||||
// Now update localStorage to mark mission as claimed
|
||||
const updatedMissions = missionsState!.missions.map(m =>
|
||||
m.id === missionId ? { ...m, claimed: true } : m
|
||||
// Update XP and level tags on the fresh event's tags
|
||||
const updatedTags = updateBlobbonautTags(
|
||||
prev?.tags ?? [],
|
||||
buildXpTagUpdates(newTotalXp),
|
||||
);
|
||||
|
||||
const updatedState: DailyMissionsState = {
|
||||
...missionsState!,
|
||||
missions: updatedMissions,
|
||||
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
|
||||
};
|
||||
// Persist missions state to content field
|
||||
const content = serializeProfileContent(
|
||||
prev?.content ?? '',
|
||||
{ missions },
|
||||
);
|
||||
|
||||
writeMissionsState(updatedState);
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content,
|
||||
tags: updatedTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, claimed: true }
|
||||
}));
|
||||
updateProfileEvent(event);
|
||||
|
||||
return {
|
||||
missionId,
|
||||
coinsEarned: coinsToAdd,
|
||||
newTotalCoins,
|
||||
};
|
||||
return { xpAwarded: xpToAward, newTotalXp };
|
||||
},
|
||||
onSuccess: ({ coinsEarned }) => {
|
||||
// Invalidate profile query to ensure fresh data
|
||||
onSuccess: ({ xpAwarded }) => {
|
||||
if (user?.pubkey) {
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: 'Reward Claimed!',
|
||||
description: `You earned ${coinsEarned} coins.`,
|
||||
});
|
||||
if (xpAwarded > 0) {
|
||||
toast({
|
||||
title: 'XP Earned!',
|
||||
description: `You earned ${xpAwarded} XP from daily missions.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
// Don't show error for already claimed (user might have double-clicked)
|
||||
if (error.message === 'Reward already claimed' || error.message === 'Bonus reward already claimed') {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Failed to Claim Reward',
|
||||
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,201 +1,227 @@
|
||||
/**
|
||||
* useDailyMissions - Hook for managing Blobbi daily missions
|
||||
*
|
||||
* Provides:
|
||||
* - Daily mission state management with localStorage persistence
|
||||
* - Automatic daily reset
|
||||
* - Progress tracking functions
|
||||
* - Read-only access to mission state (claiming is handled by useClaimMissionReward)
|
||||
* - Stage-based filtering (only shows missions user can complete)
|
||||
* - Bonus mission tracking
|
||||
*
|
||||
* Note: Reward claiming should be done via useClaimMissionReward hook,
|
||||
* which persists coins to the kind 11125 Blobbonaut profile.
|
||||
* 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 } from 'react';
|
||||
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 DailyMissionsState,
|
||||
type DailyMission,
|
||||
type BlobbiStage,
|
||||
type DailyMissionAction,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
areAllMissionsCompleted,
|
||||
areAllMissionsClaimed,
|
||||
getTotalPotentialReward,
|
||||
getTodayClaimedReward,
|
||||
isBonusMissionAvailable,
|
||||
isBonusMissionClaimed,
|
||||
BONUS_MISSION_DEFINITION,
|
||||
getRerollsRemaining,
|
||||
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 {
|
||||
/** Current daily missions state */
|
||||
missions: DailyMission[];
|
||||
/** Whether all missions are completed */
|
||||
allCompleted: boolean;
|
||||
/** Whether all missions are claimed */
|
||||
allClaimed: boolean;
|
||||
/** Total potential reward for today (including bonus if available) */
|
||||
totalPotentialReward: number;
|
||||
/** Total claimed reward for today */
|
||||
todayClaimedReward: number;
|
||||
/** Lifetime total coins earned from daily missions */
|
||||
lifetimeCoinsEarned: number;
|
||||
/** Whether the bonus mission is available (all regular missions completed) */
|
||||
bonusAvailable: boolean;
|
||||
/** Whether the bonus mission has been claimed */
|
||||
bonusClaimed: boolean;
|
||||
/** Bonus mission reward amount */
|
||||
bonusReward: number;
|
||||
/** Whether user has no eligible missions (e.g., only eggs) */
|
||||
/** 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;
|
||||
/** Number of rerolls remaining for today */
|
||||
/** Rerolls remaining today */
|
||||
rerollsRemaining: number;
|
||||
/** Maximum rerolls allowed per day */
|
||||
/** Max rerolls per day */
|
||||
maxRerolls: number;
|
||||
/** Force refresh missions (for testing or manual reset) */
|
||||
/** Force refresh missions (testing) */
|
||||
forceReset: () => void;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useDailyMissions] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
|
||||
const { availableStages } = options;
|
||||
const { availableStages, profileContent } = options;
|
||||
const { user } = useCurrentUser();
|
||||
const pubkey = user?.pubkey;
|
||||
|
||||
// Read state directly from localStorage, with a version counter to trigger re-reads
|
||||
// Version counter to trigger re-reads from session store
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// Read from localStorage on every render when version changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is intentionally used to force re-read
|
||||
const state = useMemo(() => readMissionsState(), [version]);
|
||||
|
||||
// Wrapper to write state and update version
|
||||
const setState = useCallback((newState: DailyMissionsState) => {
|
||||
writeMissionsState(newState);
|
||||
setVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
// Listen for external updates from mutations (reroll, claim, progress tracking)
|
||||
// This re-reads localStorage when other hooks modify it directly
|
||||
// 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(() => {
|
||||
const handleExternalUpdate = () => {
|
||||
// Bump version to trigger a re-read from localStorage
|
||||
setVersion((v) => v + 1);
|
||||
};
|
||||
if (!pubkey || !profileContent) return;
|
||||
if (hydratedRef.current === pubkey) return; // already hydrated this session
|
||||
|
||||
window.addEventListener('daily-missions-updated', handleExternalUpdate);
|
||||
return () => window.removeEventListener('daily-missions-updated', handleExternalUpdate);
|
||||
// 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 key for availableStages to use in dependencies
|
||||
// Stable stages key for deps
|
||||
const stagesKey = availableStages?.sort().join(',') ?? '';
|
||||
|
||||
// Ensure we have valid state for today
|
||||
const currentState = useMemo(() => {
|
||||
// Check if we need to reset for a new day
|
||||
if (needsDailyReset(state)) {
|
||||
const previousCoins = state?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
|
||||
// Persist the reset state (this will trigger version bump via setState)
|
||||
writeMissionsState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state && state.rerollsRemaining === undefined) {
|
||||
const migratedState = {
|
||||
...state,
|
||||
rerollsRemaining: MAX_DAILY_REROLLS,
|
||||
};
|
||||
writeMissionsState(migratedState);
|
||||
return migratedState;
|
||||
}
|
||||
|
||||
return state!;
|
||||
// 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
|
||||
}, [state, pubkey, stagesKey]);
|
||||
}, [version, pubkey, stagesKey, hydrated]);
|
||||
|
||||
// Force reset missions (for testing)
|
||||
const forceReset = () => {
|
||||
const previousCoins = state?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
|
||||
setState(newState);
|
||||
};
|
||||
// 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]);
|
||||
|
||||
// Computed values
|
||||
const missions = currentState.missions;
|
||||
const allCompleted = areAllMissionsCompleted(currentState);
|
||||
const allClaimed = areAllMissionsClaimed(currentState);
|
||||
const bonusAvailable = isBonusMissionAvailable(currentState);
|
||||
const bonusClaimed = isBonusMissionClaimed(currentState);
|
||||
const bonusReward = BONUS_MISSION_DEFINITION.reward;
|
||||
const allComplete = raw ? areAllDailyComplete(raw) : false;
|
||||
const todayXp = raw ? totalDailyXp(raw) : 0;
|
||||
const bonusUnlocked = allComplete;
|
||||
const noMissionsAvailable = missions.length === 0;
|
||||
const rerollsRemaining = getRerollsRemaining(currentState);
|
||||
const maxRerolls = MAX_DAILY_REROLLS;
|
||||
|
||||
// Total potential includes bonus if regular missions exist
|
||||
const basePotentialReward = getTotalPotentialReward(currentState);
|
||||
const totalPotentialReward = missions.length > 0
|
||||
? basePotentialReward + bonusReward
|
||||
: 0;
|
||||
|
||||
// Today's claimed includes bonus if claimed
|
||||
const baseTodayClaimedReward = getTodayClaimedReward(currentState);
|
||||
const todayClaimedReward = baseTodayClaimedReward + (bonusClaimed ? bonusReward : 0);
|
||||
|
||||
const lifetimeCoinsEarned = currentState.totalCoinsEarned;
|
||||
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,
|
||||
allCompleted,
|
||||
allClaimed,
|
||||
totalPotentialReward,
|
||||
todayClaimedReward,
|
||||
lifetimeCoinsEarned,
|
||||
bonusAvailable,
|
||||
bonusClaimed,
|
||||
bonusReward,
|
||||
raw,
|
||||
allComplete,
|
||||
todayXp,
|
||||
bonusUnlocked,
|
||||
bonusXp: DAILY_BONUS_XP,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
maxRerolls,
|
||||
maxRerolls: MAX_DAILY_REROLLS,
|
||||
forceReset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
// src/blobbi/actions/hooks/useEvolveTasks.ts
|
||||
|
||||
/**
|
||||
* Hook to compute evolve task progress from Nostr events and current stats.
|
||||
*
|
||||
* CRITICAL ARCHITECTURE:
|
||||
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
|
||||
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
|
||||
*
|
||||
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
|
||||
* 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 { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
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,
|
||||
KIND_SHORT_TEXT_NOTE,
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
sanitizeToHashtag,
|
||||
type HatchTask,
|
||||
type TaskType,
|
||||
} from './useHatchTasks';
|
||||
@@ -33,29 +42,19 @@ import {
|
||||
/** Kind for custom profile tabs event */
|
||||
export const KIND_PROFILE_TABS = 16769;
|
||||
|
||||
/** Required themes for evolve task */
|
||||
export const EVOLVE_REQUIRED_THEMES = 3;
|
||||
|
||||
/** Required color moments for evolve task */
|
||||
export const EVOLVE_REQUIRED_COLOR_MOMENTS = 3;
|
||||
|
||||
/** Required posts for evolve task (lighter than hatch - just 1 evolve-specific post) */
|
||||
export const EVOLVE_REQUIRED_POSTS = 1;
|
||||
|
||||
/** Required interactions for evolve task */
|
||||
export const EVOLVE_REQUIRED_INTERACTIONS = 21;
|
||||
|
||||
/** Prefix text for Blobbi evolve post */
|
||||
export const BLOBBI_EVOLVE_POST_PREFIX = 'Hello Nostr! Posting to evolve';
|
||||
|
||||
/** Stat threshold for evolve dynamic task (all stats >= 80) */
|
||||
export const EVOLVE_STAT_THRESHOLD = 80;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
// 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.
|
||||
*/
|
||||
@@ -73,256 +72,159 @@ export interface EvolveTasksResult {
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a post is a valid Blobbi evolve post.
|
||||
* Must contain the evolve prefix and all required hashtags including the Blobbi name.
|
||||
*
|
||||
* @param event - The Nostr event to validate
|
||||
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
|
||||
*/
|
||||
export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolean {
|
||||
// Check content starts with evolve prefix
|
||||
if (!event.content.startsWith(BLOBBI_EVOLVE_POST_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for required hashtags in tags
|
||||
const hashtags = event.tags
|
||||
.filter(tag => tag[0] === 't')
|
||||
.map(tag => tag[1]?.toLowerCase());
|
||||
|
||||
// All required hashtags must be present
|
||||
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
|
||||
hashtags.includes(required.toLowerCase())
|
||||
);
|
||||
|
||||
if (!hasRequiredHashtags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blobbi name hashtag must also be present
|
||||
const blobbiHashtag = sanitizeToHashtag(blobbiName);
|
||||
return hashtags.includes(blobbiHashtag);
|
||||
}
|
||||
|
||||
// ─── Main Hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to compute evolve task progress from Nostr events and current stats.
|
||||
*
|
||||
* PERSISTENT TASKS (event-based, can be cached):
|
||||
* 1. Create 3 Themes (kind 36767)
|
||||
* 2. Create 3 Color Moments (kind 3367)
|
||||
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
|
||||
* 4. Interact 21 times (tracked via companion.tasks cache)
|
||||
* 5. Edit Profile once (kind 0 profile metadata OR kind 16769 custom tabs)
|
||||
*
|
||||
* DYNAMIC TASK (stat-based, NEVER cached):
|
||||
* 6. Maintain All Stats >= 80
|
||||
*
|
||||
* Hook to compute evolve task progress from evolution missions + Nostr event backfill.
|
||||
*
|
||||
* @param companion - The Blobbi companion (must be in evolving state)
|
||||
* @param interactionCount - Current interaction count from companion tasks cache
|
||||
* @param missions - Current MissionsContent from the session store
|
||||
*/
|
||||
export function useEvolveTasks(
|
||||
companion: BlobbiCompanion | null,
|
||||
interactionCount?: number
|
||||
missions: MissionsContent | undefined,
|
||||
): EvolveTasksResult {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
const stateStartedAt = companion?.stateStartedAt;
|
||||
const isEvolving = companion?.state === 'evolving';
|
||||
|
||||
// Query for all relevant events
|
||||
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, stateStartedAt],
|
||||
queryKey: ['evolve-tasks', pubkey],
|
||||
queryFn: async () => {
|
||||
if (!pubkey || !stateStartedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build filters for events we need
|
||||
if (!pubkey) return null;
|
||||
|
||||
const filters: NostrFilter[] = [
|
||||
// Theme definitions after start
|
||||
{
|
||||
kinds: [KIND_THEME_DEFINITION],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Color moments after start
|
||||
{
|
||||
kinds: [KIND_COLOR_MOMENT],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Posts after start (will filter for valid evolve posts)
|
||||
{
|
||||
kinds: [KIND_SHORT_TEXT_NOTE],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 50, // Only need 1 valid evolve post
|
||||
},
|
||||
// Custom profile tabs after start
|
||||
{
|
||||
kinds: [KIND_PROFILE_TABS],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1, // Only need 1
|
||||
},
|
||||
// Profile metadata after start (for Blobbi shape check + profile edit mission)
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1,
|
||||
},
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
// Execute all queries
|
||||
|
||||
const events = await nostr.query(filters);
|
||||
|
||||
// Categorize events
|
||||
const themeEvents = events.filter(e =>
|
||||
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const colorMomentEvents = events.filter(e =>
|
||||
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const postEvents = events.filter(e =>
|
||||
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const profileTabsEvents = events.filter(e =>
|
||||
e.kind === KIND_PROFILE_TABS && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
// Get latest profile after start
|
||||
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
|
||||
const profileAfter = profileEvents
|
||||
.filter(e => e.created_at >= stateStartedAt)
|
||||
.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
|
||||
|
||||
return {
|
||||
themeEvents,
|
||||
colorMomentEvents,
|
||||
postEvents,
|
||||
profileTabsEvents,
|
||||
profileAfter,
|
||||
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 && !!stateStartedAt && isEvolving,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
refetchInterval: 60_000, // Refetch every minute
|
||||
enabled: !!pubkey && isEvolving,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
// ─── Compute PERSISTENT Tasks ───
|
||||
const tasks: HatchTask[] = [];
|
||||
|
||||
// 1. Create 3 Themes (PERSISTENT)
|
||||
const themeCount = data?.themeEvents?.length ?? 0;
|
||||
const themesCompleted = themeCount >= EVOLVE_REQUIRED_THEMES;
|
||||
tasks.push({
|
||||
id: 'create_themes',
|
||||
name: 'Create Themes',
|
||||
description: `Create ${EVOLVE_REQUIRED_THEMES} custom themes`,
|
||||
current: Math.min(themeCount, EVOLVE_REQUIRED_THEMES),
|
||||
required: EVOLVE_REQUIRED_THEMES,
|
||||
completed: themesCompleted,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/themes',
|
||||
actionLabel: 'Create Theme',
|
||||
|
||||
// ─── 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,
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Create 3 Color Moments (PERSISTENT)
|
||||
const colorMomentCount = data?.colorMomentEvents?.length ?? 0;
|
||||
const colorMomentsCompleted = colorMomentCount >= EVOLVE_REQUIRED_COLOR_MOMENTS;
|
||||
tasks.push({
|
||||
id: 'color_moments',
|
||||
name: 'Color Moments',
|
||||
description: `Share ${EVOLVE_REQUIRED_COLOR_MOMENTS} color moments on espy`,
|
||||
current: Math.min(colorMomentCount, EVOLVE_REQUIRED_COLOR_MOMENTS),
|
||||
required: EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
completed: colorMomentsCompleted,
|
||||
type: 'persistent',
|
||||
action: 'external_link',
|
||||
actionTarget: 'https://espy.you/',
|
||||
actionLabel: 'Open espy',
|
||||
});
|
||||
|
||||
// 3. Create 1 Evolve Post (PERSISTENT) - lighter than hatch
|
||||
const blobbiName = companion?.name ?? '';
|
||||
const validPosts = data?.postEvents?.filter(e => isValidEvolvePost(e, blobbiName)) ?? [];
|
||||
const postCount = validPosts.length;
|
||||
const postsCompleted = postCount >= EVOLVE_REQUIRED_POSTS;
|
||||
tasks.push({
|
||||
id: 'create_posts',
|
||||
name: 'Share Evolution',
|
||||
description: 'Post about your Blobbi evolving',
|
||||
current: Math.min(postCount, EVOLVE_REQUIRED_POSTS),
|
||||
required: EVOLVE_REQUIRED_POSTS,
|
||||
completed: postsCompleted,
|
||||
type: 'persistent',
|
||||
action: 'open_modal',
|
||||
actionTarget: 'blobbi_post',
|
||||
actionLabel: 'Create Post',
|
||||
});
|
||||
|
||||
// 4. Interact 21 times (PERSISTENT)
|
||||
const interactions = interactionCount ?? 0;
|
||||
const interactionsCompleted = interactions >= EVOLVE_REQUIRED_INTERACTIONS;
|
||||
tasks.push({
|
||||
id: 'interactions',
|
||||
name: 'Interact with Blobbi',
|
||||
description: `Care for your Blobbi ${EVOLVE_REQUIRED_INTERACTIONS} times`,
|
||||
current: Math.min(interactions, EVOLVE_REQUIRED_INTERACTIONS),
|
||||
required: EVOLVE_REQUIRED_INTERACTIONS,
|
||||
completed: interactionsCompleted,
|
||||
type: 'persistent',
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// 5. Edit Profile once (PERSISTENT) — kind 0 profile metadata OR kind 16769 custom tabs
|
||||
const hasTabsEdit = (data?.profileTabsEvents?.length ?? 0) >= 1;
|
||||
const hasMetadataEdit = !!data?.profileAfter;
|
||||
const hasProfileEdit = hasTabsEdit || hasMetadataEdit;
|
||||
tasks.push({
|
||||
id: 'edit_profile',
|
||||
name: 'Edit Your Profile',
|
||||
description: 'Update your profile info or customize your profile tabs',
|
||||
current: hasProfileEdit ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasProfileEdit,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/settings/profile',
|
||||
actionLabel: 'Edit Profile',
|
||||
});
|
||||
|
||||
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
|
||||
// 7. Maintain All Stats >= 80
|
||||
|
||||
// ─── 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 =
|
||||
|
||||
const statsOk =
|
||||
hunger >= EVOLVE_STAT_THRESHOLD &&
|
||||
happiness >= EVOLVE_STAT_THRESHOLD &&
|
||||
health >= EVOLVE_STAT_THRESHOLD &&
|
||||
hygiene >= EVOLVE_STAT_THRESHOLD &&
|
||||
energy >= EVOLVE_STAT_THRESHOLD;
|
||||
|
||||
// Calculate minimum stat for progress display
|
||||
|
||||
const minStat = Math.min(hunger, happiness, health, hygiene, energy);
|
||||
|
||||
|
||||
tasks.push({
|
||||
id: 'maintain_stats',
|
||||
name: 'Peak Condition',
|
||||
@@ -330,18 +232,17 @@ export function useEvolveTasks(
|
||||
current: statsOk ? EVOLVE_STAT_THRESHOLD : minStat,
|
||||
required: EVOLVE_STAT_THRESHOLD,
|
||||
completed: statsOk,
|
||||
type: 'dynamic', // CRITICAL: Never persist this task
|
||||
// No action - just care for your Blobbi
|
||||
type: 'dynamic',
|
||||
});
|
||||
|
||||
// ─── Compute Completion States ───
|
||||
|
||||
// ─── 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,
|
||||
@@ -353,11 +254,4 @@ export function useEvolveTasks(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current interaction count for evolve from companion task cache.
|
||||
*/
|
||||
export function getEvolveInteractionCount(companion: BlobbiCompanion | null): number {
|
||||
if (!companion) return 0;
|
||||
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
|
||||
return interactionTask?.value ?? 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
// src/blobbi/actions/hooks/useHatchTasks.ts
|
||||
|
||||
/**
|
||||
* Hook to compute hatch task progress from Nostr events.
|
||||
*
|
||||
* CRITICAL ARCHITECTURE:
|
||||
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
|
||||
*
|
||||
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
|
||||
* All persistent tasks are computed dynamically from events with created_at >= state_started_at.
|
||||
*
|
||||
* Note: Egg stats no longer decay, so there are no dynamic tasks for hatching.
|
||||
* 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,9 +40,6 @@ export const KIND_PROFILE_METADATA = 0;
|
||||
/** Kind for short text notes */
|
||||
export const KIND_SHORT_TEXT_NOTE = 1;
|
||||
|
||||
/** Required interactions to complete the hatch interactions task */
|
||||
export const HATCH_REQUIRED_INTERACTIONS = 7;
|
||||
|
||||
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
|
||||
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
|
||||
|
||||
@@ -40,36 +47,20 @@ export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
|
||||
export const BLOBBI_POST_PREFIX = 'Posting to hatch';
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export { HATCH_REQUIRED_INTERACTIONS };
|
||||
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
|
||||
|
||||
/**
|
||||
* Sanitize a name into a valid hashtag format.
|
||||
* Must match the implementation in BlobbiPostModal.tsx.
|
||||
*/
|
||||
export function sanitizeToHashtag(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
// Remove emojis and special characters, keep letters, numbers, underscores
|
||||
.replace(/[^\p{L}\p{N}_]/gu, '')
|
||||
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
|
||||
.replace(/^(\d)/, 'blobbi$1')
|
||||
// Limit length
|
||||
.slice(0, 30)
|
||||
// Fallback if empty
|
||||
|| 'myblobbi';
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Task type classification.
|
||||
* - persistent: Based on Nostr events, can be cached in tags
|
||||
* - dynamic: Based on current stats, NEVER stored in tags
|
||||
* - persistent: Based on Nostr events or tallies, stored in evolution[]
|
||||
* - dynamic: Based on current stats, NEVER stored
|
||||
*/
|
||||
export type TaskType = 'persistent' | 'dynamic';
|
||||
|
||||
/**
|
||||
* Individual task definition.
|
||||
* Individual task view model for the UI.
|
||||
*/
|
||||
export interface HatchTask {
|
||||
id: string;
|
||||
@@ -81,7 +72,7 @@ export interface HatchTask {
|
||||
required: number;
|
||||
/** Whether the task is complete */
|
||||
completed: boolean;
|
||||
/** Task type - persistent (event-based) or dynamic (stat-based) */
|
||||
/** Task type - persistent or dynamic */
|
||||
type: TaskType;
|
||||
/** Action to perform (if applicable) */
|
||||
action?: 'navigate' | 'open_modal' | 'external_link';
|
||||
@@ -120,221 +111,152 @@ export function buildHatchPhrase(blobbiName: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a post is a valid Blobbi hatch post.
|
||||
* The post must contain the required phrase: "Posting to hatch {Name} #blobbi"
|
||||
* The user may add extra text before or after it.
|
||||
*
|
||||
* @param event - The Nostr event to validate
|
||||
* @param blobbiName - The Blobbi's name
|
||||
* Check if a post is a valid Blobbi-related post.
|
||||
*/
|
||||
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
|
||||
const phrase = buildHatchPhrase(blobbiName);
|
||||
|
||||
// The phrase must appear somewhere in the content
|
||||
if (!event.content.includes(phrase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for required hashtags in tags
|
||||
const hashtags = event.tags
|
||||
.filter(tag => tag[0] === 't')
|
||||
.map(tag => tag[1]?.toLowerCase());
|
||||
|
||||
// All required hashtags must be present as t tags
|
||||
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
|
||||
hashtags.includes(required.toLowerCase())
|
||||
export function isValidHatchPost(event: NostrEvent): boolean {
|
||||
const hasBlobbiTag = event.tags.some(
|
||||
tag => tag[0] === 't' && tag[1]?.toLowerCase() === 'blobbi',
|
||||
);
|
||||
|
||||
return hasRequiredHashtags;
|
||||
if (hasBlobbiTag) return true;
|
||||
return /#blobbi\b/i.test(event.content);
|
||||
}
|
||||
|
||||
// Legacy function name for backwards compatibility
|
||||
export const isValidBlobbiPost = isValidHatchPost;
|
||||
|
||||
// ─── Main Hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to compute hatch task progress from Nostr events and current stats.
|
||||
*
|
||||
* PERSISTENT TASKS (event-based, can be cached):
|
||||
* 1. Create Theme (kind 36767) - ≥1 event after start
|
||||
* 2. Color Moment (kind 3367) - ≥1 event after start
|
||||
* 3. Create Post (kind 1) - ≥1 valid Blobbi hatch post after start
|
||||
* 4. Interactions - 7 total (tracked via companion.tasks cache)
|
||||
*
|
||||
* Note: Egg stats no longer decay, so the "maintain stats" dynamic task
|
||||
* has been removed. The baby/adult evolve equivalent is still in useEvolveTasks.
|
||||
*
|
||||
* Hook to compute hatch task progress from evolution missions + Nostr event backfill.
|
||||
*
|
||||
* @param companion - The Blobbi companion (must be incubating)
|
||||
* @param interactionCount - Current interaction count from companion tasks cache
|
||||
* @param missions - Current MissionsContent from the session store
|
||||
*/
|
||||
export function useHatchTasks(
|
||||
companion: BlobbiCompanion | null,
|
||||
interactionCount?: number
|
||||
missions: MissionsContent | undefined,
|
||||
): HatchTasksResult {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
const stateStartedAt = companion?.stateStartedAt;
|
||||
const isIncubating = companion?.state === 'incubating';
|
||||
|
||||
// Query for all relevant events
|
||||
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, stateStartedAt],
|
||||
queryKey: ['hatch-tasks', pubkey],
|
||||
queryFn: async () => {
|
||||
if (!pubkey || !stateStartedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build filters for events we need
|
||||
if (!pubkey) return null;
|
||||
|
||||
const filters: NostrFilter[] = [
|
||||
// Theme definitions after start
|
||||
{
|
||||
kinds: [KIND_THEME_DEFINITION],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Color moments after start
|
||||
{
|
||||
kinds: [KIND_COLOR_MOMENT],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
},
|
||||
// Posts after start (will filter for valid Blobbi posts)
|
||||
{
|
||||
kinds: [KIND_SHORT_TEXT_NOTE],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 50, // Reasonable limit
|
||||
},
|
||||
// Profile metadata - need both before and after start
|
||||
// Get latest before start
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
until: stateStartedAt,
|
||||
limit: 1,
|
||||
},
|
||||
// Get latest after start
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1,
|
||||
},
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
// Execute all queries
|
||||
|
||||
const events = await nostr.query(filters);
|
||||
|
||||
// Categorize events
|
||||
const themeEvents = events.filter(e =>
|
||||
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const colorMomentEvents = events.filter(e =>
|
||||
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const postEvents = events.filter(e =>
|
||||
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
// Separate profile events into before and after
|
||||
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
|
||||
const profileBefore = profileEvents
|
||||
.filter(e => e.created_at < stateStartedAt)
|
||||
.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
const profileAfter = profileEvents
|
||||
.filter(e => e.created_at >= stateStartedAt)
|
||||
.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
|
||||
|
||||
return {
|
||||
themeEvents,
|
||||
colorMomentEvents,
|
||||
postEvents,
|
||||
profileBefore,
|
||||
profileAfter,
|
||||
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 && !!stateStartedAt && isIncubating,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
refetchInterval: 60_000, // Refetch every minute
|
||||
enabled: !!pubkey && isIncubating,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
// ─── Compute PERSISTENT Tasks ───
|
||||
const tasks: HatchTask[] = [];
|
||||
|
||||
// 1. Create Theme (PERSISTENT)
|
||||
const hasTheme = (data?.themeEvents?.length ?? 0) >= 1;
|
||||
tasks.push({
|
||||
id: 'create_theme',
|
||||
name: 'Create Theme',
|
||||
description: 'Create a custom theme for your profile',
|
||||
current: hasTheme ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasTheme,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/themes',
|
||||
actionLabel: 'Create Theme',
|
||||
|
||||
// ─── 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,
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Color Moment (PERSISTENT)
|
||||
const hasColorMoment = (data?.colorMomentEvents?.length ?? 0) >= 1;
|
||||
tasks.push({
|
||||
id: 'color_moment',
|
||||
name: 'Color Moment',
|
||||
description: 'Share a color moment on espy',
|
||||
current: hasColorMoment ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasColorMoment,
|
||||
type: 'persistent',
|
||||
action: 'external_link',
|
||||
actionTarget: 'https://espy.you/',
|
||||
actionLabel: 'Open espy',
|
||||
});
|
||||
|
||||
// 3. Create Post (PERSISTENT)
|
||||
const blobbiName = companion?.name ?? '';
|
||||
const validPosts = data?.postEvents?.filter(e => isValidHatchPost(e, blobbiName)) ?? [];
|
||||
const hasValidPost = validPosts.length >= 1;
|
||||
tasks.push({
|
||||
id: 'create_post',
|
||||
name: 'Create Post',
|
||||
description: 'Share a post about hatching your Blobbi',
|
||||
current: hasValidPost ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasValidPost,
|
||||
type: 'persistent',
|
||||
action: 'open_modal',
|
||||
actionTarget: 'blobbi_post',
|
||||
actionLabel: 'Create Post',
|
||||
});
|
||||
|
||||
// 5. Interactions (PERSISTENT)
|
||||
const interactions = interactionCount ?? 0;
|
||||
const interactionsCompleted = interactions >= HATCH_REQUIRED_INTERACTIONS;
|
||||
tasks.push({
|
||||
id: 'interactions',
|
||||
name: 'Interact with Blobbi',
|
||||
description: `Care for your Blobbi ${HATCH_REQUIRED_INTERACTIONS} times`,
|
||||
current: Math.min(interactions, HATCH_REQUIRED_INTERACTIONS),
|
||||
required: HATCH_REQUIRED_INTERACTIONS,
|
||||
completed: interactionsCompleted,
|
||||
type: 'persistent',
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// ─── Compute Completion States ───
|
||||
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 persistentTasksComplete = tasks.every(t => t.completed);
|
||||
const dynamicTaskComplete = true; // No dynamic tasks for hatching
|
||||
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
|
||||
|
||||
|
||||
return {
|
||||
tasks,
|
||||
persistentTasksComplete,
|
||||
@@ -346,15 +268,6 @@ export function useHatchTasks(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current interaction count from companion task cache.
|
||||
*/
|
||||
export function getInteractionCount(companion: BlobbiCompanion | null): number {
|
||||
if (!companion) return 0;
|
||||
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
|
||||
return interactionTask?.value ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tasks to only persistent tasks (for tag sync).
|
||||
* CRITICAL: Dynamic tasks must NEVER be synced to tags.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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,11 +1,7 @@
|
||||
/**
|
||||
* useRerollMission - Hook for rerolling daily missions
|
||||
*
|
||||
* Handles:
|
||||
* - Replacing a mission with a new one from the pool
|
||||
* - Tracking reroll usage (max 3 per day)
|
||||
* - Respecting stage-based mission filtering
|
||||
* - Persisting state to localStorage
|
||||
* useRerollMission - Replace a daily mission with a new one from the pool
|
||||
*
|
||||
* Updates the in-memory session store.
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
@@ -13,17 +9,12 @@ 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 {
|
||||
type DailyMissionsState,
|
||||
type DailyMission,
|
||||
type BlobbiStage,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
rerollMission,
|
||||
canRerollMission,
|
||||
getRerollsRemaining,
|
||||
} from '../lib/daily-missions';
|
||||
readMissionsFromStorage,
|
||||
writeMissionsToStorage,
|
||||
} from '../lib/daily-mission-tracker';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,118 +25,51 @@ export interface RerollMissionRequest {
|
||||
|
||||
export interface RerollMissionResult {
|
||||
oldMissionId: string;
|
||||
newMission: DailyMission;
|
||||
newMissionId: string;
|
||||
rerollsRemaining: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
const state = JSON.parse(stored) as DailyMissionsState;
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state.rerollsRemaining === undefined) {
|
||||
state.rerollsRemaining = 3; // MAX_DAILY_REROLLS
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useRerollMission] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to reroll a daily mission.
|
||||
*
|
||||
* Replaces the specified mission with a new one from the pool,
|
||||
* respecting stage-based filtering and avoiding duplicates.
|
||||
*/
|
||||
export function useRerollMission() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ missionId, availableStages }: RerollMissionRequest): Promise<RerollMissionResult> => {
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to reroll missions');
|
||||
}
|
||||
if (!user?.pubkey) throw new Error('Must be logged in');
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
|
||||
// Ensure we have valid state for today
|
||||
if (needsDailyReset(missionsState)) {
|
||||
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins, availableStages);
|
||||
}
|
||||
const current = readMissionsFromStorage(user.pubkey);
|
||||
if (!current) throw new Error('No missions state');
|
||||
|
||||
// Check if reroll is allowed
|
||||
if (!canRerollMission(missionsState!, missionId)) {
|
||||
const rerollsLeft = getRerollsRemaining(missionsState!);
|
||||
if (rerollsLeft <= 0) {
|
||||
throw new Error('No rerolls remaining today');
|
||||
}
|
||||
|
||||
const mission = missionsState!.missions.find(m => m.id === missionId);
|
||||
if (mission?.completed || mission?.claimed) {
|
||||
throw new Error('Cannot reroll completed or claimed missions');
|
||||
}
|
||||
|
||||
throw new Error('Cannot reroll this mission');
|
||||
}
|
||||
const updated = rerollMission(current, missionId, availableStages);
|
||||
if (!updated) throw new Error('Cannot reroll this mission');
|
||||
|
||||
// Perform the reroll
|
||||
const result = rerollMission(missionsState!, missionId, availableStages);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('No replacement missions available. All alternative missions may already be in your daily list.');
|
||||
}
|
||||
writeMissionsToStorage(updated, user.pubkey);
|
||||
|
||||
// Persist the updated state
|
||||
writeMissionsState(result.state);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: {
|
||||
missionId,
|
||||
rerolled: true,
|
||||
newMissionId: result.newMission.id,
|
||||
}
|
||||
// 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,
|
||||
newMission: result.newMission,
|
||||
rerollsRemaining: getRerollsRemaining(result.state),
|
||||
newMissionId,
|
||||
rerollsRemaining: updated.rerolls,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ newMission, rerollsRemaining }) => {
|
||||
const rerollText = rerollsRemaining === 1
|
||||
? '1 reroll left'
|
||||
: rerollsRemaining === 0
|
||||
? 'No rerolls left'
|
||||
: `${rerollsRemaining} rerolls left`;
|
||||
|
||||
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: ${newMission.title}. ${rerollText}.`,
|
||||
description: `New mission: ${def?.title ?? newMissionId}. ${rerollText}.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
|
||||
@@ -30,7 +30,6 @@ export {
|
||||
useStopIncubation,
|
||||
useStartEvolution,
|
||||
useStopEvolution,
|
||||
useSyncTaskCompletions,
|
||||
} from './hooks/useBlobbiIncubation';
|
||||
export type {
|
||||
StartIncubationMode,
|
||||
@@ -43,8 +42,6 @@ export type {
|
||||
StartEvolutionResult,
|
||||
UseStopEvolutionParams,
|
||||
StopEvolutionResult,
|
||||
UseSyncTaskCompletionsParams,
|
||||
TaskCompletionToSync,
|
||||
} from './hooks/useBlobbiIncubation';
|
||||
|
||||
export { useActiveTaskProcess, filterPersistentTasks as filterPersistentTasksFromProcess, filterDynamicTasks } from './hooks/useActiveTaskProcess';
|
||||
@@ -52,9 +49,7 @@ export type { TaskProcessType, TaskProcessConfig, ActiveTaskProcessResult } from
|
||||
|
||||
export {
|
||||
useHatchTasks,
|
||||
getInteractionCount,
|
||||
filterPersistentTasks,
|
||||
sanitizeToHashtag,
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
@@ -66,15 +61,11 @@ export type { HatchTask, HatchTasksResult, TaskType } from './hooks/useHatchTask
|
||||
|
||||
export {
|
||||
useEvolveTasks,
|
||||
getEvolveInteractionCount,
|
||||
isValidEvolvePost,
|
||||
KIND_PROFILE_TABS,
|
||||
EVOLVE_REQUIRED_THEMES,
|
||||
EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
EVOLVE_REQUIRED_POSTS,
|
||||
EVOLVE_REQUIRED_INTERACTIONS,
|
||||
EVOLVE_STAT_THRESHOLD,
|
||||
BLOBBI_EVOLVE_POST_PREFIX,
|
||||
} from './hooks/useEvolveTasks';
|
||||
export type { EvolveTasksResult } from './hooks/useEvolveTasks';
|
||||
|
||||
@@ -119,7 +110,6 @@ export {
|
||||
type ResolvedInventoryItem,
|
||||
type EggStatPreview,
|
||||
type ItemUsabilityResult,
|
||||
type IncrementInteractionResult,
|
||||
// Constants
|
||||
ACTION_TO_ITEM_TYPE,
|
||||
ACTION_METADATA,
|
||||
@@ -150,25 +140,70 @@ export {
|
||||
hasHygieneEffectForEgg,
|
||||
canUseItemForStage,
|
||||
getActionForItem,
|
||||
incrementInteractionTaskTags,
|
||||
} from './lib/blobbi-action-utils';
|
||||
|
||||
// Daily Missions
|
||||
export { useDailyMissions } from './hooks/useDailyMissions';
|
||||
export type { UseDailyMissionsResult } from './hooks/useDailyMissions';
|
||||
export { useClaimMissionReward } from './hooks/useClaimMissionReward';
|
||||
export type { ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
|
||||
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 {
|
||||
DailyMission,
|
||||
DailyMissionAction,
|
||||
DailyMissionDefinition,
|
||||
DailyMissionsState,
|
||||
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,
|
||||
|
||||
@@ -545,88 +545,4 @@ export function previewCleanForEgg(
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Interaction Task Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/** Enable debug logging in development only */
|
||||
const DEBUG_INTERACTION_TASK = import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* Result of incrementing interaction task tags
|
||||
*/
|
||||
export interface IncrementInteractionResult {
|
||||
/** Updated tags array */
|
||||
updatedTags: string[][];
|
||||
/** New interaction count after increment */
|
||||
newCount: number;
|
||||
/** Whether the task is now complete */
|
||||
isCompleted: boolean;
|
||||
/** Previous count before increment */
|
||||
previousCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the interaction task counter in the tags array.
|
||||
*
|
||||
* This is used by both useBlobbiDirectAction and useBlobbiUseInventoryItem
|
||||
* to track progress on interaction tasks for both hatch and evolve.
|
||||
*
|
||||
* CRITICAL: This function is called during actual user actions (not retroactive sync).
|
||||
* It always increments by 1 because each call represents a real interaction.
|
||||
*
|
||||
* Tag format:
|
||||
* - Progress: ["task", "interactions:N"]
|
||||
* - Completion: ["task_completed", "interactions"]
|
||||
*
|
||||
* Idempotency notes:
|
||||
* - This is NOT idempotent by design - each call = one interaction
|
||||
* - Duplicate task_completed tags are prevented by filtering before add
|
||||
* - Multiple task:interactions tags are prevented by filtering before add
|
||||
*
|
||||
* @param currentTags - Current tags array from the Blobbi state
|
||||
* @param requiredInteractions - Threshold for completion (7 for hatch, 21 for evolve)
|
||||
* @returns Updated tags array with incremented interaction count
|
||||
*/
|
||||
export function incrementInteractionTaskTags(
|
||||
currentTags: string[][],
|
||||
requiredInteractions: number
|
||||
): IncrementInteractionResult {
|
||||
// Get current interaction count from task tags
|
||||
const interactionTag = currentTags.find(tag =>
|
||||
tag[0] === 'task' && tag[1]?.startsWith('interactions:')
|
||||
);
|
||||
const previousCount = interactionTag
|
||||
? parseInt(interactionTag[1].split(':')[1] || '0', 10)
|
||||
: 0;
|
||||
const newCount = previousCount + 1;
|
||||
|
||||
// Check if already completed (task_completed tag exists)
|
||||
const alreadyCompleted = currentTags.some(tag =>
|
||||
tag[0] === 'task_completed' && tag[1] === 'interactions'
|
||||
);
|
||||
|
||||
// Remove old interaction task tag (prevent duplicates) and add new one
|
||||
let updatedTags = currentTags.filter(tag =>
|
||||
!(tag[0] === 'task' && tag[1]?.startsWith('interactions:'))
|
||||
);
|
||||
updatedTags = [...updatedTags, ['task', `interactions:${newCount}`]];
|
||||
|
||||
// Mark as completed if reached required count AND not already marked
|
||||
const isCompleted = newCount >= requiredInteractions;
|
||||
if (isCompleted && !alreadyCompleted) {
|
||||
// Only add if not already present (handled by filter, but double-check)
|
||||
updatedTags = [...updatedTags, ['task_completed', 'interactions']];
|
||||
}
|
||||
|
||||
if (DEBUG_INTERACTION_TASK) {
|
||||
console.log('[InteractionTask] Increment:', {
|
||||
previousCount,
|
||||
newCount,
|
||||
requiredInteractions,
|
||||
isCompleted,
|
||||
alreadyCompleted,
|
||||
addedCompletionTag: isCompleted && !alreadyCompleted,
|
||||
});
|
||||
}
|
||||
|
||||
return { updatedTags, newCount, isCompleted, previousCount };
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ export const ACTION_XP: Record<BlobbiAction, number> = {
|
||||
...DIRECT_ACTION_XP,
|
||||
};
|
||||
|
||||
/**
|
||||
* XP awarded for cleaning up poop.
|
||||
*/
|
||||
export const POOP_CLEANUP_XP = 5;
|
||||
|
||||
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,109 +1,167 @@
|
||||
/**
|
||||
* Daily Mission Tracker - Standalone progress tracking utility
|
||||
*
|
||||
* This module provides a simple way to track daily mission progress
|
||||
* without requiring React hooks or context. It directly manipulates
|
||||
* localStorage for immediate persistence.
|
||||
*
|
||||
* This approach allows action hooks (which may be called outside of
|
||||
* the daily missions hook context) to record progress.
|
||||
*
|
||||
* 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 {
|
||||
type DailyMissionsState,
|
||||
type DailyMissionAction,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
updateMissionProgress,
|
||||
createDailyMissionsContent,
|
||||
trackTally,
|
||||
trackEvent,
|
||||
trackEvolutionTally,
|
||||
trackEvolutionEvent,
|
||||
} from './daily-missions';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
// ─── In-Memory Session Store ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the current daily missions state from localStorage
|
||||
* Pubkey-scoped session cache. Each logged-in user gets their own entry.
|
||||
* Cleared on page refresh (intentional — kind 11125 is the persistent store).
|
||||
*/
|
||||
function readState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const sessionStore = new Map<string, MissionsContent>();
|
||||
|
||||
function key(pubkey: string | undefined): string {
|
||||
return pubkey ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the daily missions state to localStorage
|
||||
*/
|
||||
function writeState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[DailyMissionTracker] Failed to write state:', error);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid state for today, creating one if necessary
|
||||
*/
|
||||
function ensureCurrentState(pubkey?: string): DailyMissionsState {
|
||||
const current = readState();
|
||||
|
||||
if (needsDailyReset(current)) {
|
||||
const previousCoins = current?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins);
|
||||
writeState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
return current!;
|
||||
function notify(detail?: Record<string, unknown>): void {
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail }));
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record progress for a daily mission action.
|
||||
* This function can be called from anywhere (hooks, event handlers, etc.)
|
||||
* and will immediately persist to localStorage.
|
||||
*
|
||||
* @param action - The action type that was performed
|
||||
* @param count - Number of times the action was performed (default: 1)
|
||||
* @param pubkey - Optional user pubkey for personalized mission selection
|
||||
* Record a tally-based action (feed, clean, interact, etc.).
|
||||
*/
|
||||
export function trackDailyMissionProgress(
|
||||
action: DailyMissionAction,
|
||||
count: number = 1,
|
||||
pubkey?: string
|
||||
pubkey?: string,
|
||||
): void {
|
||||
const current = ensureCurrentState(pubkey);
|
||||
const updated = updateMissionProgress(current, action, count);
|
||||
writeState(updated);
|
||||
|
||||
// Dispatch a custom event so React components can re-render if needed
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { action, count } }));
|
||||
const current = ensureCurrent(pubkey);
|
||||
const updated = trackTally(current, action, count);
|
||||
sessionStore.set(key(pubkey), updated);
|
||||
notify({ action, count });
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to track multiple actions at once.
|
||||
* Useful when an action should count toward multiple missions.
|
||||
*
|
||||
* @param actions - Array of actions to track
|
||||
* @param pubkey - Optional user pubkey
|
||||
* 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
|
||||
pubkey?: string,
|
||||
): void {
|
||||
let current = ensureCurrentState(pubkey);
|
||||
|
||||
let current = ensureCurrent(pubkey);
|
||||
for (const action of actions) {
|
||||
current = updateMissionProgress(current, action, 1);
|
||||
current = trackTally(current, action, 1);
|
||||
}
|
||||
|
||||
writeState(current);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { actions } }));
|
||||
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,36 +1,45 @@
|
||||
/**
|
||||
* Daily Missions System for Blobbi
|
||||
*
|
||||
* This module defines the daily mission pool, selection logic, and types.
|
||||
* Daily missions are separate from hatch/evolve missions and provide
|
||||
* daily engagement loops with coin rewards.
|
||||
*
|
||||
* 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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mission action types that can trigger progress
|
||||
* Actions that can trigger daily mission progress.
|
||||
* Tally actions increment a counter. Event actions append an event ID.
|
||||
*/
|
||||
export type DailyMissionAction =
|
||||
| 'interact' // Any interaction (feed, clean, play, etc.)
|
||||
| 'feed' // Feeding action specifically
|
||||
| 'clean' // Cleaning action specifically
|
||||
| 'sing' // Sing direct action
|
||||
| 'play_music' // Play music direct action
|
||||
| 'sleep' // Put Blobbi to sleep
|
||||
| 'take_photo' // Take a photo of Blobbi
|
||||
| 'medicine'; // Give medicine to Blobbi
|
||||
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)
|
||||
|
||||
/**
|
||||
* Blobbi stage type for filtering missions
|
||||
*/
|
||||
/** 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
|
||||
* Definition of a daily mission in the pool.
|
||||
* This is the static template -- not the runtime state.
|
||||
*/
|
||||
export interface DailyMissionDefinition {
|
||||
/** Unique identifier for this mission type */
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Display title */
|
||||
title: string;
|
||||
@@ -39,277 +48,160 @@ export interface DailyMissionDefinition {
|
||||
/** Action that triggers progress */
|
||||
action: DailyMissionAction;
|
||||
/** Number of times the action must be performed */
|
||||
requiredCount: number;
|
||||
/** Coin reward for completing this mission */
|
||||
reward: number;
|
||||
/** Selection weight (higher = more likely to be selected) */
|
||||
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 (if empty/undefined, requires baby or adult) */
|
||||
/** Required stages to show this mission */
|
||||
requiredStages?: BlobbiStage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A daily mission instance with progress tracking
|
||||
*/
|
||||
export interface DailyMission extends DailyMissionDefinition {
|
||||
/** Current progress (how many times the action has been performed today) */
|
||||
currentCount: number;
|
||||
/** Whether the mission has been completed */
|
||||
completed: boolean;
|
||||
/** Whether the reward has been claimed */
|
||||
claimed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored state for daily missions (persisted in localStorage)
|
||||
*/
|
||||
export interface DailyMissionsState {
|
||||
/** The date string (YYYY-MM-DD) when these missions were generated */
|
||||
date: string;
|
||||
/** The selected missions for this day */
|
||||
missions: DailyMission[];
|
||||
/** Total coins earned from daily missions (lifetime) */
|
||||
totalCoinsEarned: number;
|
||||
/** Whether the bonus mission has been claimed today */
|
||||
bonusClaimed?: boolean;
|
||||
/** Number of rerolls remaining for today (resets daily, max 3) */
|
||||
rerollsRemaining?: number;
|
||||
}
|
||||
|
||||
// ─── 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pool of available daily missions.
|
||||
* Weights determine selection frequency:
|
||||
* - High weight (10): Common missions (interact, feed, clean)
|
||||
* - Medium weight (6): Regular missions (sing, play music, sleep)
|
||||
* - Low weight (2): Uncommon missions (change shape)
|
||||
* - Rare weight (1): Rare missions (take photo)
|
||||
*/
|
||||
export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// BABY/ADULT ONLY MISSIONS
|
||||
// These actions are NOT available for eggs
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Interact Missions (Baby/Adult only) ───────────────────────────────────
|
||||
// ── Baby/Adult only ──────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'interact_3',
|
||||
title: 'Quick Care',
|
||||
id: 'interact_3', title: 'Quick Care',
|
||||
description: 'Interact with your Blobbi 3 times',
|
||||
action: 'interact',
|
||||
requiredCount: 3,
|
||||
reward: 30,
|
||||
weight: 10,
|
||||
action: 'interact', target: 3, tracking: 'tally', xp: 15, weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'interact_6',
|
||||
title: 'Attentive Caretaker',
|
||||
id: 'interact_6', title: 'Attentive Caretaker',
|
||||
description: 'Interact with your Blobbi 6 times',
|
||||
action: 'interact',
|
||||
requiredCount: 6,
|
||||
reward: 50,
|
||||
weight: 8,
|
||||
action: 'interact', target: 6, tracking: 'tally', xp: 30, weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Feed Missions (Baby/Adult only) ───────────────────────────────────────
|
||||
{
|
||||
id: 'feed_1',
|
||||
title: 'Snack Time',
|
||||
id: 'feed_1', title: 'Snack Time',
|
||||
description: 'Feed your Blobbi once',
|
||||
action: 'feed',
|
||||
requiredCount: 1,
|
||||
reward: 25,
|
||||
weight: 10,
|
||||
action: 'feed', target: 1, tracking: 'tally', xp: 10, weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'feed_2',
|
||||
title: 'Hungry Blobbi',
|
||||
id: 'feed_2', title: 'Hungry Blobbi',
|
||||
description: 'Feed your Blobbi 2 times',
|
||||
action: 'feed',
|
||||
requiredCount: 2,
|
||||
reward: 45,
|
||||
weight: 8,
|
||||
action: 'feed', target: 2, tracking: 'tally', xp: 20, weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'feed_3',
|
||||
title: 'Feast Day',
|
||||
id: 'feed_3', title: 'Feast Day',
|
||||
description: 'Feed your Blobbi 3 times',
|
||||
action: 'feed',
|
||||
requiredCount: 3,
|
||||
reward: 60,
|
||||
weight: 5,
|
||||
action: 'feed', target: 3, tracking: 'tally', xp: 35, weight: 5,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Sleep Missions (Baby/Adult only) ──────────────────────────────────────
|
||||
{
|
||||
id: 'sleep_1',
|
||||
title: 'Nap Time',
|
||||
id: 'sleep_1', title: 'Nap Time',
|
||||
description: 'Put your Blobbi to sleep',
|
||||
action: 'sleep',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Photo Missions (Baby/Adult only) ──────────────────────────────────────
|
||||
{
|
||||
id: 'take_photo_1',
|
||||
title: 'Snapshot',
|
||||
description: 'Take a polaroid photo of your Blobbi',
|
||||
action: 'take_photo',
|
||||
requiredCount: 1,
|
||||
reward: 55,
|
||||
weight: 4,
|
||||
action: 'sleep', target: 1, tracking: 'tally', xp: 15, weight: 6,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'take_photo_2',
|
||||
title: 'Photo Album',
|
||||
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',
|
||||
requiredCount: 2,
|
||||
reward: 70,
|
||||
weight: 2,
|
||||
action: 'take_photo', target: 2, tracking: 'event', xp: 40, weight: 2,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EGG + BABY + ADULT MISSIONS
|
||||
// These actions are available for ALL stages including eggs
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Clean Missions (All stages) ───────────────────────────────────────────
|
||||
// ── All stages ───────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'clean_1',
|
||||
title: 'Quick Cleanup',
|
||||
id: 'clean_1', title: 'Quick Cleanup',
|
||||
description: 'Clean your Blobbi once',
|
||||
action: 'clean',
|
||||
requiredCount: 1,
|
||||
reward: 25,
|
||||
weight: 10,
|
||||
action: 'clean', target: 1, tracking: 'tally', xp: 10, weight: 10,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'clean_2',
|
||||
title: 'Squeaky Clean',
|
||||
id: 'clean_2', title: 'Squeaky Clean',
|
||||
description: 'Clean your Blobbi 2 times',
|
||||
action: 'clean',
|
||||
requiredCount: 2,
|
||||
reward: 45,
|
||||
weight: 6,
|
||||
action: 'clean', target: 2, tracking: 'tally', xp: 20, weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Sing Missions (All stages) ────────────────────────────────────────────
|
||||
{
|
||||
id: 'sing_1',
|
||||
title: 'Sing Along',
|
||||
id: 'sing_1', title: 'Sing Along',
|
||||
description: 'Sing a song to your Blobbi',
|
||||
action: 'sing',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
action: 'sing', target: 1, tracking: 'tally', xp: 15, weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'sing_2',
|
||||
title: 'Karaoke Session',
|
||||
id: 'sing_2', title: 'Karaoke Session',
|
||||
description: 'Sing 2 songs to your Blobbi',
|
||||
action: 'sing',
|
||||
requiredCount: 2,
|
||||
reward: 50,
|
||||
weight: 3,
|
||||
action: 'sing', target: 2, tracking: 'tally', xp: 25, weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Play Music Missions (All stages) ──────────────────────────────────────
|
||||
{
|
||||
id: 'play_music_1',
|
||||
title: 'DJ Time',
|
||||
id: 'play_music_1', title: 'DJ Time',
|
||||
description: 'Play a song for your Blobbi',
|
||||
action: 'play_music',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
action: 'play_music', target: 1, tracking: 'tally', xp: 15, weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'play_music_2',
|
||||
title: 'Music Marathon',
|
||||
id: 'play_music_2', title: 'Music Marathon',
|
||||
description: 'Play 2 songs for your Blobbi',
|
||||
action: 'play_music',
|
||||
requiredCount: 2,
|
||||
reward: 50,
|
||||
weight: 3,
|
||||
action: 'play_music', target: 2, tracking: 'tally', xp: 25, weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Medicine Missions (All stages) ────────────────────────────────────────
|
||||
// Medicine rewards are higher since medicine costs coins to use
|
||||
{
|
||||
id: 'medicine_1',
|
||||
title: 'Health Check',
|
||||
id: 'medicine_1', title: 'Health Check',
|
||||
description: 'Give medicine to your Blobbi',
|
||||
action: 'medicine',
|
||||
requiredCount: 1,
|
||||
reward: 60,
|
||||
weight: 5,
|
||||
action: 'medicine', target: 1, tracking: 'tally', xp: 20, weight: 5,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'medicine_2',
|
||||
title: 'Doctor Visit',
|
||||
id: 'medicine_2', title: 'Doctor Visit',
|
||||
description: 'Give medicine to your Blobbi 2 times',
|
||||
action: 'medicine',
|
||||
requiredCount: 2,
|
||||
reward: 70,
|
||||
weight: 3,
|
||||
action: 'medicine', target: 2, tracking: 'tally', xp: 35, weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
// ─── Lookup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the current date string in YYYY-MM-DD format (local timezone)
|
||||
*/
|
||||
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')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a seed number from a date string and optional user pubkey.
|
||||
* Used for deterministic daily mission selection.
|
||||
*/
|
||||
function generateDailySeed(dateString: string, pubkey?: string): number {
|
||||
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
/** Whether the missions content needs a daily reset */
|
||||
export function needsDailyReset(missions: MissionsContent | undefined): boolean {
|
||||
if (!missions) return true;
|
||||
return missions.date !== getTodayDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeded random number generator (Mulberry32)
|
||||
*/
|
||||
// ─── Selection ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Seeded PRNG (Mulberry32) */
|
||||
function seededRandom(seed: number): () => number {
|
||||
return function() {
|
||||
return function () {
|
||||
let t = seed += 0x6D2B79F5;
|
||||
t = Math.imul(t ^ t >>> 15, t | 1);
|
||||
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
||||
@@ -317,392 +209,245 @@ function seededRandom(seed: number): () => number {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mission is available for the given stages.
|
||||
* Missions with no requiredStages default to requiring baby or adult.
|
||||
*/
|
||||
function isMissionAvailableForStages(
|
||||
mission: DailyMissionDefinition,
|
||||
availableStages: BlobbiStage[]
|
||||
): boolean {
|
||||
const requiredStages = mission.requiredStages ?? ['baby', 'adult'];
|
||||
return requiredStages.some((stage) => availableStages.includes(stage));
|
||||
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 from the pool using weighted random selection.
|
||||
* Uses a seeded random generator for deterministic daily selection.
|
||||
*
|
||||
* @param count - Number of missions to select
|
||||
* @param dateString - Date string for seeding (YYYY-MM-DD)
|
||||
* @param pubkey - Optional user pubkey for seeding
|
||||
* @param availableStages - Stages the user has available (filters eligible missions)
|
||||
* 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[]
|
||||
availableStages?: BlobbiStage[],
|
||||
): DailyMissionDefinition[] {
|
||||
const seed = generateDailySeed(dateString, pubkey);
|
||||
const random = seededRandom(seed);
|
||||
|
||||
// Filter pool by available stages (default to baby/adult if not specified)
|
||||
const stagesToCheck = availableStages ?? ['baby', 'adult'];
|
||||
const eligibleMissions = DAILY_MISSION_POOL.filter((m) =>
|
||||
isMissionAvailableForStages(m, stagesToCheck)
|
||||
);
|
||||
|
||||
// If no missions are available for the user's stages, return empty
|
||||
if (eligibleMissions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create a copy of the eligible pool
|
||||
const available = [...eligibleMissions];
|
||||
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) {
|
||||
// Calculate total weight of remaining missions
|
||||
const totalWeight = available.reduce((sum, m) => sum + m.weight, 0);
|
||||
|
||||
// Pick a random value in [0, totalWeight)
|
||||
let pick = random() * totalWeight;
|
||||
|
||||
// Find the mission that corresponds to this pick
|
||||
let selectedIndex = 0;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < available.length; i++) {
|
||||
pick -= available[i].weight;
|
||||
if (pick <= 0) {
|
||||
selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
if (pick <= 0) { idx = i; break; }
|
||||
}
|
||||
|
||||
// Add to selected and remove from available
|
||||
selected.push(available[selectedIndex]);
|
||||
available.splice(selectedIndex, 1);
|
||||
selected.push(available[idx]);
|
||||
available.splice(idx, 1);
|
||||
}
|
||||
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh DailyMission from a definition
|
||||
*/
|
||||
export function createMissionFromDefinition(def: DailyMissionDefinition): DailyMission {
|
||||
return {
|
||||
...def,
|
||||
currentCount: 0,
|
||||
completed: false,
|
||||
claimed: false,
|
||||
};
|
||||
// ─── 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 the initial daily missions state for a new day
|
||||
*/
|
||||
export function createDailyMissionsState(
|
||||
/** Create a fresh MissionsContent for a new day, preserving evolution missions */
|
||||
export function createDailyMissionsContent(
|
||||
dateString: string,
|
||||
existingEvolution: Mission[],
|
||||
pubkey?: string,
|
||||
previousTotalCoins: number = 0,
|
||||
availableStages?: BlobbiStage[]
|
||||
): DailyMissionsState {
|
||||
const definitions = selectDailyMissions(3, dateString, pubkey, availableStages);
|
||||
availableStages?: BlobbiStage[],
|
||||
): MissionsContent {
|
||||
const defs = selectDailyMissions(DAILY_MISSION_COUNT, dateString, pubkey, availableStages);
|
||||
return {
|
||||
date: dateString,
|
||||
missions: definitions.map(createMissionFromDefinition),
|
||||
totalCoinsEarned: previousTotalCoins,
|
||||
rerollsRemaining: MAX_DAILY_REROLLS,
|
||||
daily: defs.map(createMission),
|
||||
evolution: existingEvolution,
|
||||
rerolls: MAX_DAILY_REROLLS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the daily missions need to be reset (new day)
|
||||
*/
|
||||
export function needsDailyReset(state: DailyMissionsState | null): boolean {
|
||||
if (!state) return true;
|
||||
return state.date !== getTodayDateString();
|
||||
}
|
||||
// ─── Progress Tracking ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update mission progress for a given action
|
||||
* Increment tally for all daily missions matching the given action.
|
||||
* Returns a new missions content (immutable).
|
||||
*/
|
||||
export function updateMissionProgress(
|
||||
state: DailyMissionsState,
|
||||
export function trackTally(
|
||||
missions: MissionsContent,
|
||||
action: DailyMissionAction,
|
||||
incrementBy: number = 1
|
||||
): DailyMissionsState {
|
||||
const updatedMissions = state.missions.map((mission) => {
|
||||
// Skip if not the matching action or already completed
|
||||
if (mission.action !== action || mission.completed) {
|
||||
return mission;
|
||||
}
|
||||
|
||||
const newCount = Math.min(mission.currentCount + incrementBy, mission.requiredCount);
|
||||
const nowCompleted = newCount >= mission.requiredCount;
|
||||
|
||||
return {
|
||||
...mission,
|
||||
currentCount: newCount,
|
||||
completed: nowCompleted,
|
||||
};
|
||||
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 {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
};
|
||||
return { ...missions, daily: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim reward for a completed mission
|
||||
* Append an event ID to a daily mission.
|
||||
* Deduplicates by event ID. Returns new missions content.
|
||||
*/
|
||||
export function claimMissionReward(
|
||||
state: DailyMissionsState,
|
||||
missionId: string
|
||||
): { state: DailyMissionsState; coinsEarned: number } {
|
||||
let coinsEarned = 0;
|
||||
|
||||
const updatedMissions = state.missions.map((mission) => {
|
||||
if (mission.id !== missionId) return mission;
|
||||
|
||||
// Can only claim if completed and not yet claimed
|
||||
if (!mission.completed || mission.claimed) return mission;
|
||||
|
||||
coinsEarned = mission.reward;
|
||||
return {
|
||||
...mission,
|
||||
claimed: true,
|
||||
};
|
||||
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 {
|
||||
state: {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
totalCoinsEarned: state.totalCoinsEarned + coinsEarned,
|
||||
},
|
||||
coinsEarned,
|
||||
};
|
||||
return { ...missions, daily: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total potential reward for all daily missions
|
||||
* Track progress for an evolution mission by tally.
|
||||
*/
|
||||
export function getTotalPotentialReward(state: DailyMissionsState): number {
|
||||
return state.missions.reduce((sum, m) => sum + m.reward, 0);
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total claimed reward for today
|
||||
* Append an event ID to an evolution mission.
|
||||
*/
|
||||
export function getTodayClaimedReward(state: DailyMissionsState): number {
|
||||
return state.missions
|
||||
.filter((m) => m.claimed)
|
||||
.reduce((sum, m) => sum + m.reward, 0);
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all daily missions are completed
|
||||
*/
|
||||
export function areAllMissionsCompleted(state: DailyMissionsState): boolean {
|
||||
return state.missions.every((m) => m.completed);
|
||||
// ─── Completion Queries ──────────────────────────────────────────────────────
|
||||
|
||||
/** Whether all daily missions are complete */
|
||||
export function areAllDailyComplete(missions: MissionsContent): boolean {
|
||||
return missions.daily.length > 0 && missions.daily.every(isMissionComplete);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all daily missions are claimed
|
||||
*/
|
||||
export function areAllMissionsClaimed(state: DailyMissionsState): boolean {
|
||||
return state.missions.every((m) => m.claimed);
|
||||
/** Whether all evolution missions are complete */
|
||||
export function areAllEvolutionComplete(missions: MissionsContent): boolean {
|
||||
return missions.evolution.length > 0 && missions.evolution.every(isMissionComplete);
|
||||
}
|
||||
|
||||
// ─── Bonus Mission ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The bonus mission that becomes available after completing all regular missions.
|
||||
* This is a special mission that rewards extra coins for daily completion.
|
||||
*/
|
||||
export const BONUS_MISSION_DEFINITION: DailyMissionDefinition = {
|
||||
id: 'bonus_daily_complete',
|
||||
title: 'Daily Champion',
|
||||
description: 'Complete all daily missions to claim this bonus reward',
|
||||
action: 'interact', // Not actually used - bonus is auto-completed
|
||||
requiredCount: 1,
|
||||
reward: 80,
|
||||
weight: 0, // Not part of random selection
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the bonus mission is available (all regular missions completed)
|
||||
*/
|
||||
export function isBonusMissionAvailable(state: DailyMissionsState): boolean {
|
||||
// Bonus is available if there are regular missions and all are completed
|
||||
return state.missions.length > 0 && areAllMissionsCompleted(state);
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the bonus mission has been claimed today
|
||||
*/
|
||||
export function isBonusMissionClaimed(state: DailyMissionsState): boolean {
|
||||
return state.bonusClaimed ?? false;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the bonus mission reward
|
||||
*/
|
||||
export function claimBonusMissionReward(
|
||||
state: DailyMissionsState
|
||||
): { state: DailyMissionsState; coinsEarned: number } {
|
||||
// Can only claim if bonus is available and not yet claimed
|
||||
if (!isBonusMissionAvailable(state) || isBonusMissionClaimed(state)) {
|
||||
return { state, coinsEarned: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
bonusClaimed: true,
|
||||
totalCoinsEarned: state.totalCoinsEarned + BONUS_MISSION_DEFINITION.reward,
|
||||
},
|
||||
coinsEarned: BONUS_MISSION_DEFINITION.reward,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Mission Reroll ───────────────────────────────────────────────────────────
|
||||
// ─── Reroll ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the number of rerolls remaining for today.
|
||||
* Returns MAX_DAILY_REROLLS if not set (for backward compatibility with old state).
|
||||
*/
|
||||
export function getRerollsRemaining(state: DailyMissionsState): number {
|
||||
// If rerollsRemaining is not set (old state), default to max
|
||||
if (state.rerollsRemaining === undefined || state.rerollsRemaining === null) {
|
||||
return MAX_DAILY_REROLLS;
|
||||
}
|
||||
return state.rerollsRemaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user can reroll a mission
|
||||
*/
|
||||
export function canRerollMission(state: DailyMissionsState, missionId: string): boolean {
|
||||
const rerollsRemaining = getRerollsRemaining(state);
|
||||
if (rerollsRemaining <= 0) return false;
|
||||
|
||||
// Find the mission
|
||||
const mission = state.missions.find((m) => m.id === missionId);
|
||||
if (!mission) return false;
|
||||
|
||||
// Cannot reroll completed or claimed missions
|
||||
if (mission.completed || mission.claimed) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a replacement mission that:
|
||||
* - Is not already in the current mission list
|
||||
* - Is not the mission being replaced (avoid immediately giving back the same)
|
||||
* - Respects the user's available stages
|
||||
*
|
||||
* Uses weighted random selection from eligible missions.
|
||||
* Select a replacement mission not already in the current set.
|
||||
* Uses Math.random (rerolls should feel random, not deterministic).
|
||||
*/
|
||||
export function selectReplacementMission(
|
||||
currentMissions: DailyMission[],
|
||||
missionToReplace: DailyMission,
|
||||
availableStages?: BlobbiStage[]
|
||||
currentMissions: Mission[],
|
||||
missionToReplaceId: string,
|
||||
availableStages?: BlobbiStage[],
|
||||
): DailyMissionDefinition | null {
|
||||
// Default to baby/adult if no stages provided (most common case)
|
||||
const stagesToCheck = availableStages && availableStages.length > 0
|
||||
? availableStages
|
||||
: ['baby', 'adult'] as BlobbiStage[];
|
||||
|
||||
// Get IDs of missions that cannot be selected (current active missions)
|
||||
const excludedIds = new Set<string>();
|
||||
|
||||
// Exclude all current missions EXCEPT the one being replaced
|
||||
for (const m of currentMissions) {
|
||||
if (m.id !== missionToReplace.id) {
|
||||
excludedIds.add(m.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter pool to eligible missions
|
||||
const eligibleMissions = DAILY_MISSION_POOL.filter((m) => {
|
||||
// Must not be an already-active mission (except the one being replaced)
|
||||
if (excludedIds.has(m.id)) return false;
|
||||
// Must not be the same mission being replaced
|
||||
if (m.id === missionToReplace.id) return false;
|
||||
// Must be available for user's stages
|
||||
if (!isMissionAvailableForStages(m, stagesToCheck)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// If no eligible missions, return null
|
||||
if (eligibleMissions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use Math.random() for non-deterministic selection (rerolls should feel random)
|
||||
const totalWeight = eligibleMissions.reduce((sum, m) => sum + m.weight, 0);
|
||||
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 mission of eligibleMissions) {
|
||||
pick -= mission.weight;
|
||||
if (pick <= 0) {
|
||||
return mission;
|
||||
}
|
||||
for (const def of eligible) {
|
||||
pick -= def.weight;
|
||||
if (pick <= 0) return def;
|
||||
}
|
||||
|
||||
// Fallback to first eligible (shouldn't happen)
|
||||
return eligibleMissions[0];
|
||||
return eligible[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reroll a mission, replacing it with a new one from the pool.
|
||||
* Returns the updated state and the new mission, or null if reroll failed.
|
||||
* Reroll a daily mission. Returns updated missions content or null if not possible.
|
||||
*/
|
||||
export function rerollMission(
|
||||
state: DailyMissionsState,
|
||||
missions: MissionsContent,
|
||||
missionId: string,
|
||||
availableStages?: BlobbiStage[]
|
||||
): { state: DailyMissionsState; newMission: DailyMission } | null {
|
||||
// Check if reroll is allowed
|
||||
if (!canRerollMission(state, missionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the mission index
|
||||
const missionIndex = state.missions.findIndex((m) => m.id === missionId);
|
||||
if (missionIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oldMission = state.missions[missionIndex];
|
||||
|
||||
// Select a replacement
|
||||
const replacement = selectReplacementMission(state.missions, oldMission, availableStages);
|
||||
if (!replacement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create the new mission instance
|
||||
const newMission = createMissionFromDefinition(replacement);
|
||||
|
||||
// Update the missions array
|
||||
const updatedMissions = [...state.missions];
|
||||
updatedMissions[missionIndex] = newMission;
|
||||
|
||||
// Decrement rerolls remaining
|
||||
const newRerollsRemaining = getRerollsRemaining(state) - 1;
|
||||
|
||||
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 {
|
||||
state: {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
rerollsRemaining: newRerollsRemaining,
|
||||
},
|
||||
newMission,
|
||||
...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';
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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); };
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export const DEFAULT_COMPANION_CONFIG: CompanionConfig = {
|
||||
pause2Duration: 100, // Short pause before falling
|
||||
|
||||
// Truly stuck behavior
|
||||
trulyStuckChance: 0.30, // 30% chance to be truly stuck (needs user drag)
|
||||
trulyStuckChance: 0.10, // 10% chance to be truly stuck (needs user drag)
|
||||
|
||||
fallDuration: 450, // Fall after getting loose
|
||||
landingDuration: 200, // Brief squash on landing
|
||||
|
||||
@@ -45,15 +45,12 @@ import {
|
||||
applyStat,
|
||||
hasMedicineEffectForEgg,
|
||||
hasHygieneEffectForEgg,
|
||||
incrementInteractionTaskTags,
|
||||
type InventoryAction,
|
||||
ACTION_METADATA,
|
||||
} from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { trackMultipleDailyMissionActions } from '@/blobbi/actions/lib/daily-mission-tracker';
|
||||
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '@/blobbi/actions/lib/daily-mission-tracker';
|
||||
import type { DailyMissionAction } from '@/blobbi/actions/lib/daily-missions';
|
||||
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useEvolveTasks';
|
||||
|
||||
import type { UseItemFunction } from './BlobbiActionsContextDef';
|
||||
|
||||
@@ -353,13 +350,11 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
// ─── Update Blobbi State Event (kind 31124) ───
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Handle interaction counter for tasks
|
||||
// If incubating or evolving, increment the interaction counter in evolution missions
|
||||
const companionState = companion.state;
|
||||
let updatedTags = companion.allTags;
|
||||
if (companionState === 'incubating') {
|
||||
updatedTags = incrementInteractionTaskTags(companion.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
|
||||
} else if (companionState === 'evolving') {
|
||||
updatedTags = incrementInteractionTaskTags(companion.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
|
||||
const updatedTags = companion.allTags;
|
||||
if (companionState === 'incubating' || companionState === 'evolving') {
|
||||
trackEvolutionMissionTally('interactions', 1, user?.pubkey);
|
||||
}
|
||||
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
|
||||
@@ -79,6 +79,11 @@ export interface EnsureCanonicalResult {
|
||||
* to avoid restoring stale/legacy values after migration.
|
||||
*/
|
||||
profileAllTags: string[][];
|
||||
/**
|
||||
* The previous profile event, for passing as `prev` to publishEvent
|
||||
* to preserve `published_at` on replaceable events.
|
||||
*/
|
||||
profileEvent: NostrEvent;
|
||||
/**
|
||||
* The latest profile storage to use.
|
||||
* Use this as the base for storage modifications.
|
||||
@@ -347,6 +352,7 @@ export function useBlobbiMigration() {
|
||||
allTags: migrationResult.event.tags,
|
||||
content: migrationResult.event.content,
|
||||
profileAllTags: migrationResult.profileTags,
|
||||
profileEvent: migrationResult.profileEvent,
|
||||
profileStorage: migrationResult.profileStorage,
|
||||
};
|
||||
}
|
||||
@@ -358,6 +364,7 @@ export function useBlobbiMigration() {
|
||||
allTags: companion.allTags,
|
||||
content: companion.event.content,
|
||||
profileAllTags: profile.allTags,
|
||||
profileEvent: profile.event,
|
||||
profileStorage: profile.storage,
|
||||
};
|
||||
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
|
||||
|
||||
@@ -316,8 +316,16 @@ export interface BlobbonautProfile {
|
||||
coins: number;
|
||||
/** Petting level (interaction counter) */
|
||||
pettingLevel: number;
|
||||
/** Player lifetime XP (source of truth for progression) */
|
||||
xp: number;
|
||||
/** Player level (derived from xp, stored as queryable mirror) */
|
||||
level: number;
|
||||
/** Current room the player is in (persisted for cross-session continuity) */
|
||||
room: string | undefined;
|
||||
/** Purchased items storage */
|
||||
storage: StorageItem[];
|
||||
/** Raw content string for missions JSON */
|
||||
content: string;
|
||||
/** All tags preserved for republishing */
|
||||
allTags: string[][];
|
||||
}
|
||||
@@ -982,7 +990,11 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
|
||||
has: getTagValues(tags, 'has'),
|
||||
coins: parseNumericTag(tags, 'coins') ?? 0,
|
||||
pettingLevel: pettingLevelValue,
|
||||
xp: parseNumericTag(tags, 'xp') ?? 0,
|
||||
level: parseNumericTag(tags, 'level') ?? 1,
|
||||
room: getTagValue(tags, 'room') ?? undefined,
|
||||
storage: parseStorageTags(tags),
|
||||
content: event.content,
|
||||
allTags: tags,
|
||||
};
|
||||
}
|
||||
@@ -1140,6 +1152,10 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
|
||||
*/
|
||||
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
|
||||
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
|
||||
// Progression tags
|
||||
'xp', 'level',
|
||||
// Room persistence
|
||||
'room',
|
||||
// Legacy player progress tags (preserved for compatibility)
|
||||
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
|
||||
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Missions Content Model
|
||||
*
|
||||
* Defines the JSON shape stored in the kind 11125 content field.
|
||||
* Two mission categories:
|
||||
* - daily: reset each day, tally-based or event-based
|
||||
* - evolution: persist across sessions until stage transition completes
|
||||
*
|
||||
* Tally missions track a `count` (no event IDs).
|
||||
* Event missions track an `events` array of Nostr event IDs.
|
||||
* Completion is derived: count >= target or events.length >= target.
|
||||
*/
|
||||
|
||||
// ─── Mission Entry Types ─────────────────────────────────────────────────────
|
||||
|
||||
/** A mission tracked by a simple counter (feed, clean, interact, etc.) */
|
||||
export interface TallyMission {
|
||||
id: string;
|
||||
target: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** A mission tracked by Nostr event IDs (post, photo, theme, etc.) */
|
||||
export interface EventMission {
|
||||
id: string;
|
||||
target: number;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
/** Union of both mission shapes */
|
||||
export type Mission = TallyMission | EventMission;
|
||||
|
||||
/** Type guard: mission tracks events */
|
||||
export function isEventMission(m: Mission): m is EventMission {
|
||||
return 'events' in m;
|
||||
}
|
||||
|
||||
/** Type guard: mission tracks a tally */
|
||||
export function isTallyMission(m: Mission): m is TallyMission {
|
||||
return 'count' in m;
|
||||
}
|
||||
|
||||
/** Check if a mission is complete */
|
||||
export function isMissionComplete(m: Mission): boolean {
|
||||
if (isEventMission(m)) return m.events.length >= m.target;
|
||||
return m.count >= m.target;
|
||||
}
|
||||
|
||||
/** Get current progress numerator */
|
||||
export function missionProgress(m: Mission): number {
|
||||
if (isEventMission(m)) return m.events.length;
|
||||
return m.count;
|
||||
}
|
||||
|
||||
// ─── Content Shape ───────────────────────────────────────────────────────────
|
||||
|
||||
/** The full missions object stored in kind 11125 content JSON */
|
||||
export interface MissionsContent {
|
||||
date: string; // YYYY-MM-DD for daily reset detection
|
||||
daily: Mission[]; // 3 daily missions, reset each day
|
||||
evolution: Mission[]; // active evolution missions, cleared on stage transition
|
||||
rerolls: number; // daily rerolls remaining (resets with date)
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level content JSON for kind 11125.
|
||||
* Currently only `missions`. Future keys can be added alongside.
|
||||
*/
|
||||
export interface ProfileContent {
|
||||
missions?: MissionsContent;
|
||||
}
|
||||
|
||||
// ─── Parse / Serialize ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the kind 11125 content field into a typed ProfileContent.
|
||||
* Returns an empty object for empty/invalid content. Never throws.
|
||||
*/
|
||||
export function parseProfileContent(content: string): ProfileContent {
|
||||
if (!content || !content.trim()) return {};
|
||||
try {
|
||||
const raw = JSON.parse(content);
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {};
|
||||
const result: ProfileContent = {};
|
||||
if (raw.missions && typeof raw.missions === 'object') {
|
||||
result.missions = parseMissionsContent(raw.missions);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize ProfileContent back to a JSON string for publishing.
|
||||
* Preserves any unknown top-level keys from the existing content.
|
||||
*/
|
||||
export function serializeProfileContent(
|
||||
existingContent: string,
|
||||
updates: Partial<ProfileContent>,
|
||||
): string {
|
||||
let base: Record<string, unknown> = {};
|
||||
if (existingContent && existingContent.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(existingContent);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
base = parsed;
|
||||
}
|
||||
} catch {
|
||||
// corrupt content -- start fresh but don't lose updates
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ ...base, ...updates });
|
||||
}
|
||||
|
||||
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function parseMissionsContent(raw: Record<string, unknown>): MissionsContent | undefined {
|
||||
if (typeof raw.date !== 'string') return undefined;
|
||||
return {
|
||||
date: raw.date,
|
||||
daily: parseMissionArray(raw.daily),
|
||||
evolution: parseMissionArray(raw.evolution),
|
||||
rerolls: typeof raw.rerolls === 'number' ? Math.max(0, Math.floor(raw.rerolls)) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMissionArray(raw: unknown): Mission[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const result: Mission[] = [];
|
||||
for (const entry of raw) {
|
||||
const m = parseSingleMission(entry);
|
||||
if (m) result.push(m);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSingleMission(raw: unknown): Mission | undefined {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
if (typeof obj.id !== 'string' || typeof obj.target !== 'number') return undefined;
|
||||
|
||||
// Event-based mission
|
||||
if (Array.isArray(obj.events)) {
|
||||
return {
|
||||
id: obj.id,
|
||||
target: Math.max(1, Math.floor(obj.target)),
|
||||
events: obj.events.filter((e): e is string => typeof e === 'string'),
|
||||
};
|
||||
}
|
||||
|
||||
// Tally-based mission
|
||||
if (typeof obj.count === 'number') {
|
||||
return {
|
||||
id: obj.id,
|
||||
target: Math.max(1, Math.floor(obj.target)),
|
||||
count: Math.max(0, Math.floor(obj.count)),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Progression System
|
||||
*
|
||||
* Player-level XP and leveling. XP lives on kind 11125 as tags.
|
||||
* Level is derived from XP. Unlocks are derived from level.
|
||||
* No nested objects, no JSON content, no multi-game maps.
|
||||
*/
|
||||
|
||||
// ─── XP Thresholds ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cumulative XP required to reach each level.
|
||||
* Index 0 = level 1 (0 XP), index 1 = level 2 (100 XP), etc.
|
||||
* Levels beyond the table cap at the last entry.
|
||||
*/
|
||||
const XP_THRESHOLDS: readonly number[] = [
|
||||
0, // Level 1
|
||||
100, // Level 2
|
||||
250, // Level 3
|
||||
500, // Level 4
|
||||
850, // Level 5
|
||||
1300, // Level 6
|
||||
1900, // Level 7
|
||||
2650, // Level 8
|
||||
3600, // Level 9
|
||||
4800, // Level 10
|
||||
6300, // Level 11
|
||||
8100, // Level 12
|
||||
10200, // Level 13
|
||||
12700, // Level 14
|
||||
15600, // Level 15
|
||||
19000, // Level 16
|
||||
23000, // Level 17
|
||||
27600, // Level 18
|
||||
33000, // Level 19
|
||||
39200, // Level 20
|
||||
];
|
||||
|
||||
export const MAX_LEVEL = XP_THRESHOLDS.length;
|
||||
|
||||
// ─── Level Calculation ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive level from cumulative XP.
|
||||
* Walks the threshold table to find the highest level the XP qualifies for.
|
||||
*/
|
||||
export function xpToLevel(xp: number): number {
|
||||
const safeXp = Math.max(0, Math.floor(xp));
|
||||
for (let i = XP_THRESHOLDS.length - 1; i >= 0; i--) {
|
||||
if (safeXp >= XP_THRESHOLDS[i]) {
|
||||
return i + 1; // levels are 1-indexed
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cumulative XP required to reach a given level.
|
||||
*/
|
||||
export function levelToXp(level: number): number {
|
||||
const idx = Math.max(0, Math.min(level - 1, XP_THRESHOLDS.length - 1));
|
||||
return XP_THRESHOLDS[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress within the current level as a fraction [0, 1].
|
||||
* Returns 1 at max level.
|
||||
*/
|
||||
export function xpProgress(xp: number): number {
|
||||
const level = xpToLevel(xp);
|
||||
if (level >= MAX_LEVEL) return 1;
|
||||
const currentThreshold = XP_THRESHOLDS[level - 1];
|
||||
const nextThreshold = XP_THRESHOLDS[level];
|
||||
const range = nextThreshold - currentThreshold;
|
||||
if (range <= 0) return 1;
|
||||
return Math.min(1, (xp - currentThreshold) / range);
|
||||
}
|
||||
|
||||
/**
|
||||
* XP remaining to reach the next level. 0 at max level.
|
||||
*/
|
||||
export function xpToNextLevel(xp: number): number {
|
||||
const level = xpToLevel(xp);
|
||||
if (level >= MAX_LEVEL) return 0;
|
||||
return XP_THRESHOLDS[level] - xp;
|
||||
}
|
||||
|
||||
// ─── Unlocks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Unlocks {
|
||||
/** Maximum number of Blobbis the player can own */
|
||||
maxBlobbis: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive unlocks from level. Pure function, no stored state.
|
||||
*/
|
||||
export function getUnlocks(level: number): Unlocks {
|
||||
let maxBlobbis = 1;
|
||||
if (level >= 5) maxBlobbis = 2;
|
||||
if (level >= 10) maxBlobbis = 3;
|
||||
if (level >= 15) maxBlobbis = 4;
|
||||
if (level >= 20) maxBlobbis = 5;
|
||||
return { maxBlobbis };
|
||||
}
|
||||
|
||||
// ─── Tag Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build XP and level tag updates for kind 11125.
|
||||
* Level is always derived from XP -- never set independently.
|
||||
*/
|
||||
export function buildXpTagUpdates(xp: number): Record<string, string> {
|
||||
return {
|
||||
xp: Math.max(0, Math.floor(xp)).toString(),
|
||||
level: xpToLevel(xp).toString(),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import type { EggVisualBlobbi } from '../types/egg.types';
|
||||
import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation';
|
||||
import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer';
|
||||
@@ -184,10 +185,12 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
// Tour interactive steps: forward click to tour controller
|
||||
if (onTourEggClick && (tourVisualState === 'glowing_waiting_click' || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' || tourVisualState === 'crack_stage_3')) {
|
||||
setIsTapWiggling(true);
|
||||
impactLight();
|
||||
onTourEggClick();
|
||||
return;
|
||||
}
|
||||
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
|
||||
impactLight();
|
||||
setIsTapWiggling(true);
|
||||
}, [isTapWiggling, cracking, onTourEggClick, tourVisualState]);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { impactLight, impactMedium, impactHeavy, notificationSuccess } from '@/lib/haptics';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
@@ -187,7 +188,7 @@ export function BlobbiHatchingCeremony({
|
||||
// Baby companion (same visual data but stage=baby)
|
||||
const babyCompanion = useMemo((): BlobbiCompanion | null => {
|
||||
if (!eggCompanion) return null;
|
||||
return { ...eggCompanion, stage: 'baby', state: 'active' };
|
||||
return { ...eggCompanion, stage: 'baby', state: 'evolving' };
|
||||
}, [eggCompanion]);
|
||||
|
||||
const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b';
|
||||
@@ -210,7 +211,7 @@ export function BlobbiHatchingCeremony({
|
||||
ownerPubkey: user?.pubkey ?? '',
|
||||
name: existingCompanion.name,
|
||||
stage: 'egg',
|
||||
state: 'active',
|
||||
state: (existingCompanion.state === 'incubating' ? 'incubating' : 'active') as 'incubating' | 'active',
|
||||
seed: existingCompanion.seed ?? '',
|
||||
stats: {
|
||||
hunger: existingCompanion.stats.hunger ?? STAT_MAX,
|
||||
@@ -386,7 +387,8 @@ export function BlobbiHatchingCeremony({
|
||||
|
||||
const babyTags = updateBlobbiTags(tags, {
|
||||
stage: 'baby',
|
||||
state: 'active',
|
||||
state: 'evolving',
|
||||
state_started_at: nowStr,
|
||||
hunger: STAT_MAX.toString(),
|
||||
happiness: STAT_MAX.toString(),
|
||||
health: STAT_MAX.toString(),
|
||||
@@ -412,15 +414,19 @@ export function BlobbiHatchingCeremony({
|
||||
const handleEggClick = useCallback(() => {
|
||||
if (phase === 'egg') {
|
||||
triggerShake('animate-egg-onboard-shake-light');
|
||||
impactLight();
|
||||
setPhase('crack_1');
|
||||
} else if (phase === 'crack_1') {
|
||||
triggerShake('animate-egg-onboard-shake-medium');
|
||||
impactMedium();
|
||||
setPhase('crack_2');
|
||||
} else if (phase === 'crack_2') {
|
||||
triggerShake('animate-egg-onboard-shake-heavy');
|
||||
impactHeavy();
|
||||
setPhase('crack_3');
|
||||
} else if (phase === 'crack_3') {
|
||||
// Final click -> hatch!
|
||||
notificationSuccess();
|
||||
setPhase('hatching');
|
||||
setShowFlash(true);
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ export interface BlobbiEggPreview {
|
||||
name: string;
|
||||
/** Life stage - always 'egg' for previews */
|
||||
stage: 'egg';
|
||||
/** Activity state - always 'active' for new eggs */
|
||||
state: 'active';
|
||||
/** Activity state - new eggs start incubating; older eggs may be 'active' */
|
||||
state: 'incubating' | 'active';
|
||||
/** Visual traits derived from seed */
|
||||
visualTraits: BlobbiVisualTraits;
|
||||
/** Default stats for a new egg */
|
||||
@@ -79,7 +79,7 @@ export function generateEggPreview(
|
||||
seed,
|
||||
name,
|
||||
stage: 'egg',
|
||||
state: 'active',
|
||||
state: 'incubating',
|
||||
visualTraits,
|
||||
stats: { ...DEFAULT_EGG_STATS },
|
||||
createdAt,
|
||||
@@ -148,6 +148,7 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] {
|
||||
['energy', preview.stats.energy.toString()],
|
||||
['last_interaction', now],
|
||||
['last_decay_at', now],
|
||||
['state_started_at', now],
|
||||
// Visual trait tags - ensures deterministic rendering
|
||||
['base_color', visualTraits.baseColor],
|
||||
['secondary_color', visualTraits.secondaryColor],
|
||||
@@ -190,8 +191,8 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) {
|
||||
startIncubation: undefined, // Deprecated field, no longer used
|
||||
adultType: undefined, // Eggs don't have adult type
|
||||
|
||||
// Task-related fields (not applicable to previews)
|
||||
stateStartedAt: undefined,
|
||||
// Task-related fields
|
||||
stateStartedAt: preview.createdAt,
|
||||
tasks: [],
|
||||
tasksCompleted: [],
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* BlobbiRoomHero — Shared Blobbi visual display used in every room.
|
||||
*
|
||||
* Renders: stats crown (arced) + Blobbi visual + name.
|
||||
* Does NOT clip or constrain — fills available flex space.
|
||||
* Top padding accounts for the floating room header overlay.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle,
|
||||
Footprints, Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { BlobbiReactionState } from '@/blobbi/actions';
|
||||
import type { BlobbiRoomId } from '../lib/room-config';
|
||||
import { ROOM_META, DEFAULT_ROOM_ORDER, getRoomIndex } from '../lib/room-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Stat colour maps ─────────────────────────────────────────────────────────
|
||||
|
||||
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
|
||||
hunger: 'orange',
|
||||
happiness: 'yellow',
|
||||
health: 'green',
|
||||
hygiene: 'blue',
|
||||
energy: 'violet',
|
||||
};
|
||||
|
||||
const STAT_COLORS: Record<string, string> = {
|
||||
orange: 'text-orange-500', yellow: 'text-yellow-500', green: 'text-green-500',
|
||||
blue: 'text-blue-500', violet: 'text-violet-500',
|
||||
};
|
||||
|
||||
const STAT_BG_COLORS: Record<string, string> = {
|
||||
orange: 'bg-orange-500/10', yellow: 'bg-yellow-500/10', green: 'bg-green-500/10',
|
||||
blue: 'bg-blue-500/10', violet: 'bg-violet-500/10',
|
||||
};
|
||||
|
||||
const STAT_RING_HEX: Record<string, string> = {
|
||||
orange: '#f97316', yellow: '#eab308', green: '#22c55e',
|
||||
blue: '#3b82f6', violet: '#8b5cf6',
|
||||
};
|
||||
|
||||
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
|
||||
hunger: Utensils, happiness: Gamepad2, health: Heart, hygiene: Droplets, energy: Zap,
|
||||
};
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlobbiRoomHeroProps {
|
||||
companion: BlobbiCompanion;
|
||||
currentStats: {
|
||||
hunger: number;
|
||||
happiness: number;
|
||||
health: number;
|
||||
hygiene: number;
|
||||
energy: number;
|
||||
};
|
||||
isSleeping: boolean;
|
||||
isEgg: boolean;
|
||||
statusRecipe: BlobbiVisualRecipe | undefined;
|
||||
statusRecipeLabel: string | undefined;
|
||||
effectiveEmotion: BlobbiEmotion;
|
||||
hasDevOverride: boolean;
|
||||
blobbiReaction: BlobbiReactionState;
|
||||
isActiveFloatingCompanion: boolean;
|
||||
isUpdatingCompanion: boolean;
|
||||
handleSetAsCompanion: () => Promise<void>;
|
||||
heroRef: React.RefObject<HTMLDivElement | null>;
|
||||
heroWidth: number;
|
||||
/** Current room (for indicator below name) */
|
||||
roomId: BlobbiRoomId;
|
||||
/** Room order for dot indicators */
|
||||
roomOrder?: BlobbiRoomId[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiRoomHero({
|
||||
companion,
|
||||
currentStats,
|
||||
isSleeping,
|
||||
isEgg,
|
||||
statusRecipe,
|
||||
statusRecipeLabel,
|
||||
effectiveEmotion,
|
||||
hasDevOverride,
|
||||
blobbiReaction,
|
||||
isActiveFloatingCompanion,
|
||||
isUpdatingCompanion,
|
||||
handleSetAsCompanion,
|
||||
heroRef,
|
||||
heroWidth,
|
||||
roomId,
|
||||
roomOrder = DEFAULT_ROOM_ORDER,
|
||||
className,
|
||||
}: BlobbiRoomHeroProps) {
|
||||
const roomMeta = ROOM_META[roomId];
|
||||
const roomIndex = getRoomIndex(roomId, roomOrder);
|
||||
if (isActiveFloatingCompanion) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-4 text-center flex-1 px-4', className)}>
|
||||
<Footprints className="size-12 text-muted-foreground/30" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{companion.name} is out exploring right now.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSetAsCompanion}
|
||||
disabled={isUpdatingCompanion}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-6 py-3 rounded-full text-white font-semibold transition-all duration-300 ease-out text-sm',
|
||||
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
|
||||
isUpdatingCompanion && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
|
||||
>
|
||||
{isUpdatingCompanion ? <Loader2 className="size-4 animate-spin" /> : <Footprints className="size-4" />}
|
||||
<span>Bring {companion.name} home</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={heroRef}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center pt-10 px-4 sm:px-6 flex-1 min-h-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="relative flex flex-col items-center">
|
||||
<StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} />
|
||||
|
||||
<div
|
||||
className="relative transition-all duration-500"
|
||||
style={!isSleeping ? {
|
||||
animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`,
|
||||
} : undefined}
|
||||
>
|
||||
<div className="absolute inset-0 -m-16 sm:-m-20 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
|
||||
<BlobbiStageVisual
|
||||
companion={companion}
|
||||
size="lg"
|
||||
animated={!isSleeping}
|
||||
reaction={blobbiReaction}
|
||||
recipe={hasDevOverride ? undefined : statusRecipe}
|
||||
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
|
||||
emotion={effectiveEmotion}
|
||||
className={isEgg
|
||||
? 'size-36 min-[400px]:size-44 sm:size-56 md:size-64 lg:size-72'
|
||||
: 'size-48 min-[400px]:size-60 sm:size-72 md:size-80 lg:size-96'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEgg && (
|
||||
<h2
|
||||
className="text-xl sm:text-2xl md:text-3xl font-bold text-center mt-1"
|
||||
style={{ color: companion.visualTraits.baseColor }}
|
||||
>
|
||||
{companion.name}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Room indicator */}
|
||||
<div className="flex flex-col items-center mt-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<roomMeta.icon className="size-3.5 sm:size-4 text-foreground/50" />
|
||||
<span className="text-xs sm:text-sm font-semibold text-foreground/60">{roomMeta.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{roomOrder.map((id, i) => (
|
||||
<div
|
||||
key={id}
|
||||
className={cn(
|
||||
'rounded-full transition-all duration-300',
|
||||
i === roomIndex ? 'w-4 h-1 bg-primary' : 'w-1 h-1 bg-muted-foreground/20',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stats Crown ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatsCrown({
|
||||
companion,
|
||||
currentStats,
|
||||
heroWidth,
|
||||
}: {
|
||||
companion: BlobbiCompanion;
|
||||
currentStats: BlobbiRoomHeroProps['currentStats'];
|
||||
heroWidth: number;
|
||||
}) {
|
||||
const allStats = useMemo(() =>
|
||||
getVisibleStats(companion.stage).map(stat => ({
|
||||
stat,
|
||||
value: currentStats[stat] ?? 100,
|
||||
status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100),
|
||||
color: STAT_COLOR_MAP[stat],
|
||||
})),
|
||||
[companion.stage, currentStats]);
|
||||
|
||||
if (allStats.length === 0) return null;
|
||||
|
||||
const count = allStats.length;
|
||||
const isSmall = heroWidth < 400;
|
||||
const arcSpread = isSmall
|
||||
? (count <= 2 ? 80 : count <= 3 ? 110 : 140)
|
||||
: (count <= 2 ? 90 : count <= 3 ? 130 : 160);
|
||||
const arcHalf = arcSpread / 2;
|
||||
const angles = count === 1
|
||||
? [0]
|
||||
: allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-end justify-center w-full mb-4 sm:mb-8" style={{ height: 40 }}>
|
||||
{allStats.map((s, i) => {
|
||||
const angleDeg = angles[i];
|
||||
const angleRad = (angleDeg * Math.PI) / 180;
|
||||
const radius = Math.min(200, Math.max(110, (heroWidth - 340) / (640 - 340) * (200 - 110) + 110));
|
||||
const x = Math.sin(angleRad) * radius;
|
||||
const y = Math.cos(angleRad) * radius - radius;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.stat}
|
||||
className="absolute transition-all duration-500"
|
||||
style={{
|
||||
transform: 'translate(-50%, 0)',
|
||||
left: `calc(50% + ${x.toFixed(1)}px)`,
|
||||
bottom: `${y.toFixed(1)}px`,
|
||||
}}
|
||||
>
|
||||
<StatIndicator stat={s.stat} value={s.value} color={s.color} status={s.status} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat Indicator ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatIndicator({
|
||||
stat,
|
||||
value,
|
||||
color,
|
||||
status = 'normal',
|
||||
}: {
|
||||
stat: string;
|
||||
value: number | undefined;
|
||||
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
|
||||
status?: 'normal' | 'warning' | 'critical';
|
||||
}) {
|
||||
const displayValue = value ?? 0;
|
||||
const isLow = status === 'warning' || status === 'critical';
|
||||
const ringHex = STAT_RING_HEX[color];
|
||||
const IconComponent = STAT_ICON_MAP[stat];
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
|
||||
STAT_BG_COLORS[color],
|
||||
status === 'critical' && 'animate-pulse',
|
||||
)}>
|
||||
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted/15" />
|
||||
<circle
|
||||
cx="18" cy="18" r="15" fill="none" strokeWidth="2.5" strokeLinecap="round"
|
||||
stroke={ringHex}
|
||||
strokeDasharray={`${displayValue * 0.94} 100`}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="relative">
|
||||
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
|
||||
{isLow && (
|
||||
<AlertTriangle
|
||||
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* BlobbiRoomShell — Outer layout for room-based navigation.
|
||||
*
|
||||
* Manages: room navigation (arrows + dots), sleep overlay, poop state.
|
||||
* Renders children in a flex column with the hero above and children below.
|
||||
* The parent decides what bottom bar to render based on the active room.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, useRef as useReactRef, type CSSProperties } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
|
||||
import {
|
||||
type BlobbiRoomId,
|
||||
ROOM_META,
|
||||
DEFAULT_ROOM_ORDER,
|
||||
getNextRoom,
|
||||
getPreviousRoom,
|
||||
} from '../lib/room-config';
|
||||
import {
|
||||
generateInitialPoops,
|
||||
removePoop,
|
||||
type PoopInstance,
|
||||
} from '../lib/poop-system';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PoopState {
|
||||
poops: PoopInstance[];
|
||||
shovelMode: boolean;
|
||||
setShovelMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onRemovePoop: (poopId: string) => void;
|
||||
}
|
||||
|
||||
interface BlobbiRoomShellProps {
|
||||
/** Current active room */
|
||||
roomId: BlobbiRoomId;
|
||||
/** Called when user navigates to a different room */
|
||||
onChangeRoom: (roomId: BlobbiRoomId) => void;
|
||||
/** Whether the Blobbi is sleeping (darkens the room) */
|
||||
isSleeping: boolean;
|
||||
/** Hero element (BlobbiRoomHero) rendered in the flex-1 area */
|
||||
hero: React.ReactNode;
|
||||
/** Bottom bar content (per-room actions + carousel) */
|
||||
children: React.ReactNode;
|
||||
/** Optional content between hero and bottom bar (inline music/sing) */
|
||||
middleSlot?: React.ReactNode;
|
||||
/** Room order (defaults to DEFAULT_ROOM_ORDER) */
|
||||
roomOrder?: BlobbiRoomId[];
|
||||
/** Poop generation params */
|
||||
hunger: number;
|
||||
lastFeedTimestamp: number | undefined;
|
||||
/** Expose poop state to children via render prop or context */
|
||||
poopStateRef?: React.MutableRefObject<PoopState | null>;
|
||||
/** Called when a poop is cleaned. Parent handles toast/XP persistence. */
|
||||
onPoopCleaned?: () => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimum horizontal swipe distance (px) to trigger room change */
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
|
||||
export function BlobbiRoomShell({
|
||||
roomId,
|
||||
onChangeRoom,
|
||||
isSleeping,
|
||||
hero,
|
||||
children,
|
||||
middleSlot,
|
||||
roomOrder = DEFAULT_ROOM_ORDER,
|
||||
hunger,
|
||||
lastFeedTimestamp,
|
||||
poopStateRef,
|
||||
onPoopCleaned,
|
||||
}: BlobbiRoomShellProps) {
|
||||
const goLeft = useCallback(() => {
|
||||
onChangeRoom(getPreviousRoom(roomId, roomOrder));
|
||||
}, [roomId, roomOrder, onChangeRoom]);
|
||||
|
||||
const goRight = useCallback(() => {
|
||||
onChangeRoom(getNextRoom(roomId, roomOrder));
|
||||
}, [roomId, roomOrder, onChangeRoom]);
|
||||
|
||||
const leftDest = ROOM_META[getPreviousRoom(roomId, roomOrder)];
|
||||
const rightDest = ROOM_META[getNextRoom(roomId, roomOrder)];
|
||||
|
||||
// ─── Touch swipe ───
|
||||
const touchStartX = useReactRef<number | null>(null);
|
||||
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
}, [touchStartX]);
|
||||
|
||||
const onTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
if (touchStartX.current === null) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
touchStartX.current = null;
|
||||
if (Math.abs(dx) < SWIPE_THRESHOLD) return;
|
||||
impactLight();
|
||||
if (dx > 0) goLeft();
|
||||
else goRight();
|
||||
}, [touchStartX, goLeft, goRight]);
|
||||
|
||||
// ─── Poop system (ephemeral) ───
|
||||
const [poops, setPoops] = useState<PoopInstance[]>([]);
|
||||
const [shovelMode, setShovelMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPoops(generateInitialPoops(hunger, lastFeedTimestamp));
|
||||
// Only on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onRemovePoop = useCallback((poopId: string) => {
|
||||
setPoops(prev => {
|
||||
const { remaining } = removePoop(prev, poopId);
|
||||
if (remaining.length < prev.length) {
|
||||
onPoopCleaned?.();
|
||||
}
|
||||
if (remaining.length === 0) setShovelMode(false);
|
||||
return remaining;
|
||||
});
|
||||
}, [onPoopCleaned]);
|
||||
|
||||
const poopState: PoopState = useMemo(() => ({
|
||||
poops, shovelMode, setShovelMode, onRemovePoop,
|
||||
}), [poops, shovelMode, onRemovePoop]);
|
||||
|
||||
if (poopStateRef) poopStateRef.current = poopState;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col flex-1 min-h-0 relative"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Room content */}
|
||||
<div className="flex-1 min-h-0 flex flex-col relative">
|
||||
{hero}
|
||||
{middleSlot}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Sleep overlay */}
|
||||
{isSleeping && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 pointer-events-none transition-opacity duration-700"
|
||||
style={{ background: 'radial-gradient(ellipse at 50% 40%, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.45) 100%)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<button
|
||||
onClick={goLeft}
|
||||
className={cn(
|
||||
'group absolute left-1 top-1/2 -translate-y-1/2 z-40',
|
||||
'flex items-center justify-center',
|
||||
'size-10 sm:size-12 rounded-full',
|
||||
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
'cursor-pointer select-none',
|
||||
)}
|
||||
aria-label={`Go to ${leftDest.label}`}
|
||||
>
|
||||
<ChevronLeft
|
||||
className="size-7 sm:size-8 shrink-0"
|
||||
strokeWidth={4}
|
||||
style={{ animation: 'room-arrow-nudge-left 2.5s ease-in-out infinite' } as CSSProperties}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goRight}
|
||||
className={cn(
|
||||
'group absolute right-1 top-1/2 -translate-y-1/2 z-40',
|
||||
'flex items-center justify-center',
|
||||
'size-10 sm:size-12 rounded-full',
|
||||
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
'cursor-pointer select-none',
|
||||
)}
|
||||
aria-label={`Go to ${rightDest.label}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className="size-7 sm:size-8 shrink-0"
|
||||
strokeWidth={4}
|
||||
style={{ animation: 'room-arrow-nudge-right 2.5s ease-in-out infinite' } as CSSProperties}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* ItemCarousel — Single-focus carousel for room items.
|
||||
*
|
||||
* Fixed-size slots prevent layout reflow on item switch.
|
||||
* Mobile: focused item only. Desktop: prev/next previews.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CarouselEntry {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
interface ItemCarouselProps {
|
||||
items: CarouselEntry[];
|
||||
onUse: (id: string) => void;
|
||||
activeItemId?: string | null;
|
||||
disabled?: boolean;
|
||||
onFocusChange?: (entry: CarouselEntry) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ItemCarousel({
|
||||
items,
|
||||
onUse,
|
||||
activeItemId,
|
||||
disabled,
|
||||
onFocusChange,
|
||||
className,
|
||||
}: ItemCarouselProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const count = items.length;
|
||||
|
||||
// Reset index when items change to avoid out-of-bounds access
|
||||
useEffect(() => {
|
||||
setIndex(0);
|
||||
}, [items]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setIndex(i => {
|
||||
const n = (i - 1 + count) % count;
|
||||
onFocusChange?.(items[n]);
|
||||
return n;
|
||||
});
|
||||
}, [count, items, onFocusChange]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setIndex(i => {
|
||||
const n = (i + 1) % count;
|
||||
onFocusChange?.(items[n]);
|
||||
return n;
|
||||
});
|
||||
}, [count, items, onFocusChange]);
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center h-[4.5rem] sm:h-[5.5rem]', className)}>
|
||||
<p className="text-xs text-muted-foreground/50">Nothing here yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = items[index];
|
||||
const prevItem = items[(index - 1 + count) % count];
|
||||
const nextItem = items[(index + 1) % count];
|
||||
const isThisActive = activeItemId === current.id;
|
||||
const showPreviews = count >= 3;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)}>
|
||||
<button
|
||||
onClick={prev}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
disabled && 'opacity-30 pointer-events-none',
|
||||
)}
|
||||
aria-label="Previous item"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
|
||||
{showPreviews && (
|
||||
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
|
||||
<div className="opacity-20 scale-[0.6]">
|
||||
<span className="text-2xl leading-none block">{prevItem.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onUse(current.id)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center shrink-0 overflow-hidden',
|
||||
'w-20 h-[4.5rem] sm:w-24 sm:h-[5.5rem] rounded-2xl',
|
||||
'transition-colors duration-200',
|
||||
'hover:bg-accent/20 active:scale-95',
|
||||
isThisActive && 'bg-accent/40',
|
||||
disabled && !isThisActive && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<span className="text-4xl sm:text-5xl leading-none">{current.icon}</span>
|
||||
<span className="text-[10px] sm:text-xs font-medium text-foreground/70 mt-0.5 w-16 sm:w-20 text-center truncate">
|
||||
{current.label}
|
||||
</span>
|
||||
{isThisActive && <Loader2 className="size-3.5 animate-spin text-primary absolute bottom-0.5" />}
|
||||
</button>
|
||||
|
||||
{showPreviews && (
|
||||
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
|
||||
<div className="opacity-20 scale-[0.6]">
|
||||
<span className="text-2xl leading-none block">{nextItem.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
disabled && 'opacity-30 pointer-events-none',
|
||||
)}
|
||||
aria-label="Next item"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* RoomActionButton — Unified circular action button for room bottom bars.
|
||||
*
|
||||
* Responsive: size-14/size-20 circle, size-7/size-9 icons.
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RoomActionButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
color: string;
|
||||
glowHex: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
badge?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RoomActionButton({
|
||||
icon,
|
||||
label,
|
||||
color,
|
||||
glowHex,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
badge,
|
||||
className,
|
||||
}: RoomActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 transition-all duration-300 ease-out shrink-0',
|
||||
'hover:-translate-y-1 hover:scale-110 active:scale-95',
|
||||
disabled && 'opacity-50 pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn('size-14 sm:size-20 rounded-full flex items-center justify-center', color)}
|
||||
style={{
|
||||
background: `radial-gradient(circle at 40% 35%, color-mix(in srgb, ${glowHex} 14%, transparent), color-mix(in srgb, ${glowHex} 4%, transparent) 70%)`,
|
||||
}}
|
||||
>
|
||||
{loading ? <Loader2 className="size-7 sm:size-9 animate-spin" /> : icon}
|
||||
</div>
|
||||
{badge && <div className="absolute -top-0.5 -right-0.5">{badge}</div>}
|
||||
</div>
|
||||
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// src/blobbi/rooms/index.ts — barrel export
|
||||
|
||||
export {
|
||||
type BlobbiRoomId,
|
||||
type BlobbiRoomMeta,
|
||||
ROOM_META,
|
||||
DEFAULT_ROOM_ORDER,
|
||||
DEFAULT_INITIAL_ROOM,
|
||||
isValidRoomId,
|
||||
getNextRoom,
|
||||
getPreviousRoom,
|
||||
getRoomIndex,
|
||||
} from './lib/room-config';
|
||||
|
||||
export { ROOM_BOTTOM_BAR_CLASS } from './lib/room-layout';
|
||||
|
||||
export {
|
||||
type PoopInstance,
|
||||
XP_PER_POOP,
|
||||
generateInitialPoops,
|
||||
getPoopsInRoom,
|
||||
removePoop,
|
||||
hasAnyPoop,
|
||||
} from './lib/poop-system';
|
||||
|
||||
export { RoomActionButton } from './components/RoomActionButton';
|
||||
export { ItemCarousel, type CarouselEntry } from './components/ItemCarousel';
|
||||
export { BlobbiRoomHero, type BlobbiRoomHeroProps } from './components/BlobbiRoomHero';
|
||||
export { BlobbiRoomShell, type PoopState } from './components/BlobbiRoomShell';
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Ephemeral poop system.
|
||||
*
|
||||
* Generated on page mount based on hunger + time since last feed.
|
||||
* No persistence -- purely local React state.
|
||||
*/
|
||||
|
||||
import type { BlobbiRoomId } from './room-config';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PoopInstance {
|
||||
id: string;
|
||||
room: BlobbiRoomId;
|
||||
source: 'overfeed' | 'time';
|
||||
createdAt: number;
|
||||
position: { bottom: number; left: number };
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const OVERFEED_THRESHOLD = 95;
|
||||
const HOURS_PER_POOP = 2;
|
||||
export const XP_PER_POOP = 5;
|
||||
|
||||
const POOP_ELIGIBLE_ROOMS: BlobbiRoomId[] = ['care', 'kitchen', 'home', 'rest'];
|
||||
|
||||
const SAFE_POSITIONS: Array<{ bottom: number; left: number }> = [
|
||||
{ bottom: 22, left: 8 },
|
||||
{ bottom: 18, left: 78 },
|
||||
{ bottom: 28, left: 14 },
|
||||
{ bottom: 25, left: 82 },
|
||||
{ bottom: 15, left: 20 },
|
||||
{ bottom: 20, left: 72 },
|
||||
];
|
||||
|
||||
// ─── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
let _idCounter = 0;
|
||||
function nextPoopId(): string {
|
||||
return `poop_${++_idCounter}_${Date.now()}`;
|
||||
}
|
||||
|
||||
function pickPosition(index: number): { bottom: number; left: number } {
|
||||
return SAFE_POSITIONS[index % SAFE_POSITIONS.length];
|
||||
}
|
||||
|
||||
export function generateInitialPoops(
|
||||
hunger: number,
|
||||
lastFeedTimestamp: number | undefined,
|
||||
): PoopInstance[] {
|
||||
const poops: PoopInstance[] = [];
|
||||
const now = Date.now();
|
||||
let posIndex = 0;
|
||||
|
||||
if (hunger >= OVERFEED_THRESHOLD) {
|
||||
poops.push({
|
||||
id: nextPoopId(),
|
||||
room: 'kitchen',
|
||||
source: 'overfeed',
|
||||
createdAt: now,
|
||||
position: pickPosition(posIndex++),
|
||||
});
|
||||
}
|
||||
|
||||
if (lastFeedTimestamp) {
|
||||
const hoursSinceFeed = (now - lastFeedTimestamp) / (1000 * 60 * 60);
|
||||
const count = Math.min(Math.floor(hoursSinceFeed / HOURS_PER_POOP), 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const room = POOP_ELIGIBLE_ROOMS[Math.floor(Math.random() * POOP_ELIGIBLE_ROOMS.length)];
|
||||
poops.push({
|
||||
id: nextPoopId(),
|
||||
room,
|
||||
source: 'time',
|
||||
createdAt: now - i * 1000,
|
||||
position: pickPosition(posIndex++),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return poops;
|
||||
}
|
||||
|
||||
export function getPoopsInRoom(poops: PoopInstance[], room: BlobbiRoomId): PoopInstance[] {
|
||||
return poops.filter(p => p.room === room);
|
||||
}
|
||||
|
||||
export function removePoop(
|
||||
poops: PoopInstance[],
|
||||
poopId: string,
|
||||
): { remaining: PoopInstance[]; xpReward: number } {
|
||||
const remaining = poops.filter(p => p.id !== poopId);
|
||||
return {
|
||||
remaining,
|
||||
xpReward: remaining.length < poops.length ? XP_PER_POOP : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAnyPoop(poops: PoopInstance[]): boolean {
|
||||
return poops.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Blobbi Room System — IDs, metadata, ordering, navigation.
|
||||
*
|
||||
* Room order is data, not control flow, so it can be customised per-user later.
|
||||
* The kind 11125 profile has a `room` tag for cross-session continuity.
|
||||
* Currently read on mount but not yet written back on room change (session-local only).
|
||||
*/
|
||||
|
||||
import { Home, Refrigerator, Cross, Moon, Shirt, type LucideIcon } from 'lucide-react';
|
||||
|
||||
// ─── Room IDs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type BlobbiRoomId = 'home' | 'kitchen' | 'care' | 'rest' | 'closet';
|
||||
|
||||
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlobbiRoomMeta {
|
||||
id: BlobbiRoomId;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const ROOM_META: Record<BlobbiRoomId, BlobbiRoomMeta> = {
|
||||
home: {
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
description: 'Main living room',
|
||||
icon: Home,
|
||||
},
|
||||
kitchen: {
|
||||
id: 'kitchen',
|
||||
label: 'Kitchen',
|
||||
description: 'Feed your Blobbi',
|
||||
icon: Refrigerator,
|
||||
},
|
||||
care: {
|
||||
id: 'care',
|
||||
label: 'Care Room',
|
||||
description: 'Hygiene, care, and medicine',
|
||||
icon: Cross,
|
||||
},
|
||||
rest: {
|
||||
id: 'rest',
|
||||
label: 'Bedroom',
|
||||
description: 'Rest and recharge',
|
||||
icon: Moon,
|
||||
},
|
||||
closet: {
|
||||
id: 'closet',
|
||||
label: 'Closet',
|
||||
description: 'Wardrobe and accessories',
|
||||
icon: Shirt,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Default Order ────────────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_ROOM_ORDER: BlobbiRoomId[] = [
|
||||
'care',
|
||||
'kitchen',
|
||||
'home',
|
||||
'rest',
|
||||
// 'closet', — re-enable when wardrobe is ready
|
||||
];
|
||||
|
||||
export const DEFAULT_INITIAL_ROOM: BlobbiRoomId = 'home';
|
||||
|
||||
/** Validate a string as a room ID (for parsing persisted values) */
|
||||
export function isValidRoomId(value: string | undefined): value is BlobbiRoomId {
|
||||
return !!value && value in ROOM_META;
|
||||
}
|
||||
|
||||
// ─── Navigation ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function getNextRoom(
|
||||
current: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): BlobbiRoomId {
|
||||
const idx = order.indexOf(current);
|
||||
if (idx === -1) return order[0];
|
||||
return order[(idx + 1) % order.length];
|
||||
}
|
||||
|
||||
export function getPreviousRoom(
|
||||
current: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): BlobbiRoomId {
|
||||
const idx = order.indexOf(current);
|
||||
if (idx === -1) return order[order.length - 1];
|
||||
return order[(idx - 1 + order.length) % order.length];
|
||||
}
|
||||
|
||||
export function getRoomIndex(
|
||||
room: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): number {
|
||||
return order.indexOf(room);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Shared layout constants for Blobbi room components.
|
||||
*/
|
||||
|
||||
/**
|
||||
* CSS class for the bottom action bar in every room.
|
||||
*
|
||||
* On mobile (max-sidebar), adds extra bottom padding to clear the
|
||||
* fixed bottom navigation bar. On desktop (sidebar:), uses normal padding.
|
||||
*/
|
||||
export const ROOM_BOTTOM_BAR_CLASS =
|
||||
'relative z-10 px-3 sm:px-6 pt-1 pb-4 sm:pb-6 max-sidebar:pb-[calc(var(--bottom-nav-height)+env(safe-area-inset-bottom,0px)+1rem)]';
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Footprints, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SIZE_PRESETS = {
|
||||
sm: {
|
||||
wrapper: 'flex flex-col items-center gap-3 py-6',
|
||||
icon: 'size-10 text-muted-foreground/30',
|
||||
name: 'text-sm font-semibold',
|
||||
description: 'text-xs text-muted-foreground',
|
||||
button: 'flex items-center gap-2 px-4 py-2 rounded-full text-white text-xs font-semibold transition-all hover:-translate-y-0.5 hover:scale-105 active:scale-95',
|
||||
buttonIcon: 'size-3.5',
|
||||
buttonLabel: (_name: string) => 'Bring home',
|
||||
descriptionText: (_name: string) => 'Out exploring with you',
|
||||
},
|
||||
md: {
|
||||
wrapper: 'flex flex-col items-center justify-center gap-6 text-center',
|
||||
icon: 'size-16 text-muted-foreground/30',
|
||||
name: '', // not shown separately in md — name is inline in description
|
||||
description: 'text-muted-foreground text-sm',
|
||||
button: 'flex items-center justify-center gap-2.5 px-8 py-3.5 rounded-full text-white font-semibold transition-all duration-300 ease-out hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
|
||||
buttonIcon: 'size-5',
|
||||
buttonLabel: (name: string) => `Bring ${name} home`,
|
||||
descriptionText: (name: string) => `${name} is out exploring right now.`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface BlobbiAwayStateProps {
|
||||
/** The Blobbi's name. */
|
||||
name: string;
|
||||
/** Visual size preset. 'md' for full page, 'sm' for widget. */
|
||||
size?: 'sm' | 'md';
|
||||
/** Whether the companion update is in progress. */
|
||||
isUpdating: boolean;
|
||||
/** Callback to bring the Blobbi home (unset as floating companion). */
|
||||
onBringHome: () => void;
|
||||
}
|
||||
|
||||
/** Shared "out exploring" state shown when a Blobbi is the active floating companion. */
|
||||
export function BlobbiAwayState({ name, size = 'md', isUpdating, onBringHome }: BlobbiAwayStateProps) {
|
||||
const preset = SIZE_PRESETS[size];
|
||||
|
||||
return (
|
||||
<div className={preset.wrapper}>
|
||||
<Footprints className={preset.icon} />
|
||||
{size === 'sm' && <span className={preset.name}>{name}</span>}
|
||||
<p className={preset.description}>{preset.descriptionText(name)}</p>
|
||||
<button
|
||||
onClick={onBringHome}
|
||||
disabled={isUpdating}
|
||||
className={cn(preset.button, isUpdating && 'opacity-50 pointer-events-none')}
|
||||
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
|
||||
>
|
||||
{isUpdating
|
||||
? <Loader2 className={cn(preset.buttonIcon, 'animate-spin')} />
|
||||
: <Footprints className={preset.buttonIcon} />}
|
||||
<span>{preset.buttonLabel(name)}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { AlertTriangle, Utensils, Gamepad2, Heart, Droplets, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const STAT_COLORS: Record<string, string> = {
|
||||
orange: 'text-orange-500',
|
||||
yellow: 'text-yellow-500',
|
||||
green: 'text-green-500',
|
||||
blue: 'text-blue-500',
|
||||
violet: 'text-violet-500',
|
||||
};
|
||||
|
||||
const STAT_BG_COLORS: Record<string, string> = {
|
||||
orange: 'bg-orange-500/10',
|
||||
yellow: 'bg-yellow-500/10',
|
||||
green: 'bg-green-500/10',
|
||||
blue: 'bg-blue-500/10',
|
||||
violet: 'bg-violet-500/10',
|
||||
};
|
||||
|
||||
const STAT_RING_HEX: Record<string, string> = {
|
||||
orange: '#f97316',
|
||||
yellow: '#eab308',
|
||||
green: '#22c55e',
|
||||
blue: '#3b82f6',
|
||||
violet: '#8b5cf6',
|
||||
};
|
||||
|
||||
/** Lucide icon component for each stat. */
|
||||
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
|
||||
hunger: Utensils,
|
||||
happiness: Gamepad2,
|
||||
health: Heart,
|
||||
hygiene: Droplets,
|
||||
energy: Zap,
|
||||
};
|
||||
|
||||
// ── Size presets ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SIZE_PRESETS = {
|
||||
sm: {
|
||||
container: 'size-9',
|
||||
icon: 'size-3.5',
|
||||
strokeWidth: 3,
|
||||
alertSize: 'size-2.5',
|
||||
alertPos: '-top-1 -right-1.5',
|
||||
},
|
||||
md: {
|
||||
container: 'size-[4.5rem] sm:size-20',
|
||||
icon: 'size-6 sm:size-7',
|
||||
strokeWidth: 2.5,
|
||||
alertSize: 'size-3.5',
|
||||
alertPos: '-top-1.5 -right-2',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StatIndicatorProps {
|
||||
stat: string;
|
||||
value: number | undefined;
|
||||
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
|
||||
status?: 'normal' | 'warning' | 'critical';
|
||||
/** Visual size preset. Default: 'md'. */
|
||||
size?: 'sm' | 'md';
|
||||
/** When provided, renders as a clickable button. */
|
||||
onClick?: () => void;
|
||||
/** Disable the button (only relevant when onClick is set). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function StatIndicator({
|
||||
stat,
|
||||
value,
|
||||
color,
|
||||
status = 'normal',
|
||||
size = 'md',
|
||||
onClick,
|
||||
disabled,
|
||||
}: StatIndicatorProps) {
|
||||
const displayValue = value ?? 0;
|
||||
const isLow = status === 'warning' || status === 'critical';
|
||||
const ringHex = STAT_RING_HEX[color];
|
||||
const IconComponent = STAT_ICON_MAP[stat];
|
||||
const preset = SIZE_PRESETS[size];
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
{/* Progress ring */}
|
||||
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth={preset.strokeWidth} className="text-muted/15" />
|
||||
<circle
|
||||
cx="18" cy="18" r="15" fill="none" strokeWidth={preset.strokeWidth} strokeLinecap="round"
|
||||
stroke={ringHex}
|
||||
strokeDasharray={`${displayValue * 0.94} 100`}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
{/* Icon with warning badge */}
|
||||
<div className="relative">
|
||||
{IconComponent && <IconComponent className={cn(preset.icon, STAT_COLORS[color])} strokeWidth={2.5} />}
|
||||
{isLow && (
|
||||
<AlertTriangle
|
||||
className={cn('absolute', preset.alertPos, preset.alertSize, status === 'critical' ? 'text-red-500' : 'text-amber-500')}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const baseClass = cn(
|
||||
'relative rounded-full flex items-center justify-center',
|
||||
preset.container,
|
||||
STAT_BG_COLORS[color],
|
||||
status === 'critical' && 'animate-pulse',
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
baseClass,
|
||||
'transition-transform hover:scale-110 active:scale-95',
|
||||
disabled && 'opacity-40 pointer-events-none',
|
||||
)}
|
||||
aria-label={`${stat} ${displayValue}%`}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClass}>{inner}</div>;
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
ChevronRight,
|
||||
Egg,
|
||||
Sparkles,
|
||||
Coins,
|
||||
CircleDot,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
@@ -29,7 +28,7 @@ import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HatchTask } from '@/blobbi/actions/hooks/useHatchTasks';
|
||||
import type { DailyMission } from '@/blobbi/actions/lib/daily-missions';
|
||||
import type { DailyMissionView } from '@/blobbi/actions/hooks/useDailyMissions';
|
||||
|
||||
// ─── Card Item Types ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -49,8 +48,8 @@ interface DailyCardItem {
|
||||
description: string;
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
reward: number;
|
||||
claimed: boolean;
|
||||
xp: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
type CardItem = TaskCardItem | DailyCardItem;
|
||||
@@ -65,7 +64,7 @@ interface MissionSurfaceCardProps {
|
||||
/** Process type for badge label */
|
||||
processType: 'hatch' | 'evolve' | null;
|
||||
/** Daily missions */
|
||||
dailyMissions: DailyMission[];
|
||||
dailyMissions: DailyMissionView[];
|
||||
/** Called when user taps "View all" */
|
||||
onViewAll: () => void;
|
||||
/** Called when user dismisses the card */
|
||||
@@ -97,22 +96,22 @@ function buildTaskCards(
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDailyCards(missions: DailyMission[]): DailyCardItem[] {
|
||||
// Show unclaimed missions first, then claimed ones
|
||||
const unclaimed = missions.filter((m) => !m.claimed);
|
||||
const toShow = unclaimed.length > 0 ? unclaimed : [];
|
||||
function buildDailyCards(missions: DailyMissionView[]): DailyCardItem[] {
|
||||
// Show incomplete missions first
|
||||
const incomplete = missions.filter((m) => !m.complete);
|
||||
const toShow = incomplete.length > 0 ? incomplete : [];
|
||||
|
||||
return toShow.map((m) => ({
|
||||
kind: 'daily',
|
||||
badge: 'Daily',
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
progress: m.requiredCount > 0
|
||||
? Math.min(100, Math.round((m.currentCount / m.requiredCount) * 100))
|
||||
progress: m.target > 0
|
||||
? Math.min(100, Math.round((m.progress / m.target) * 100))
|
||||
: 0,
|
||||
progressLabel: `${m.currentCount}/${m.requiredCount}`,
|
||||
reward: m.reward,
|
||||
claimed: m.claimed,
|
||||
progressLabel: `${m.progress}/${m.target}`,
|
||||
xp: m.xp,
|
||||
complete: m.complete,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -279,10 +278,9 @@ export function MissionSurfaceCard({
|
||||
<span className="text-[10px] text-muted-foreground font-mono shrink-0">
|
||||
{card.progressLabel}
|
||||
</span>
|
||||
{card.kind === 'daily' && !card.claimed && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-amber-600 dark:text-amber-400 font-medium shrink-0">
|
||||
<Coins className="size-2.5" />
|
||||
{card.reward}
|
||||
{card.kind === 'daily' && !card.complete && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-violet-600 dark:text-violet-400 font-medium shrink-0">
|
||||
{card.xp} XP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -876,3 +876,51 @@ export const ACTION_EMOTION_MAP: Record<ActionType, BlobbiEmotion> = {
|
||||
export function getActionEmotion(action: ActionType): BlobbiEmotion {
|
||||
return ACTION_EMOTION_MAP[action];
|
||||
}
|
||||
|
||||
// ─── Feed Attenuation ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Produce a lighter version of a visual recipe suitable for feed cards.
|
||||
*
|
||||
* Feed Blobbis are rendered at a smaller size (size-48/56 vs size-64+) and
|
||||
* need to remain readable at a glance. This function keeps all facial parts
|
||||
* (eyes, mouth, eyebrows) and extras untouched — they are already sized
|
||||
* relative to the SVG viewBox — but reduces body-effect particle counts
|
||||
* and removes flies to prevent visual clutter at small sizes.
|
||||
*
|
||||
* The input recipe is produced by the same `resolveStatusRecipe()` used
|
||||
* by the room view, so thresholds and priorities are identical.
|
||||
*/
|
||||
export function attenuateRecipeForFeed(recipe: BlobbiVisualRecipe): BlobbiVisualRecipe {
|
||||
// Empty / no body effects → return as-is (stable reference path)
|
||||
if (!recipe.bodyEffects) return recipe;
|
||||
|
||||
const { bodyEffects, ...rest } = recipe;
|
||||
const attenuated: BodyEffectsRecipe = {};
|
||||
|
||||
// Dirt marks: reduce count by ~40%, lower intensity cap
|
||||
if (bodyEffects.dirtMarks?.enabled) {
|
||||
attenuated.dirtMarks = {
|
||||
...bodyEffects.dirtMarks,
|
||||
count: Math.max(1, Math.ceil((bodyEffects.dirtMarks.count ?? 3) * 0.6)),
|
||||
intensity: Math.min(bodyEffects.dirtMarks.intensity ?? 0.6, 0.55),
|
||||
};
|
||||
}
|
||||
|
||||
// Stink clouds: reduce count, remove flies entirely
|
||||
if (bodyEffects.stinkClouds?.enabled) {
|
||||
attenuated.stinkClouds = {
|
||||
...bodyEffects.stinkClouds,
|
||||
count: Math.max(1, Math.ceil((bodyEffects.stinkClouds.count ?? 3) * 0.5)),
|
||||
flies: false,
|
||||
flyCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Anger rise: pass through unchanged (single overlay, scales with SVG)
|
||||
if (bodyEffects.angerRise) {
|
||||
attenuated.angerRise = bodyEffects.angerRise;
|
||||
}
|
||||
|
||||
return { ...rest, bodyEffects: attenuated };
|
||||
}
|
||||
|
||||
@@ -297,11 +297,10 @@ export function AdvancedSettings() {
|
||||
<div className="px-3 pt-3 pb-4 space-y-4">
|
||||
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Request to Vanish</h3>
|
||||
<h3 className="text-sm font-medium">Delete Account</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Permanently request all relays to delete your data, including your profile,
|
||||
posts, reactions, and direct messages. This action is irreversible and legally
|
||||
binding in some jurisdictions (NIP-62).
|
||||
Permanently delete your data from the network, including your profile,
|
||||
posts, reactions, and direct messages. This action is irreversible.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -310,7 +309,7 @@ export function AdvancedSettings() {
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setVanishDialogOpen(true)}
|
||||
>
|
||||
Request to Vanish
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AgoraLogoProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
function LightningBolt({ size }: { size: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M13 2L3 14h8l-1 8 10-12h-8l1-8z"
|
||||
fill="white"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Agora badge icon used across app chrome. */
|
||||
export function AgoraLogo({ className, size = 40 }: AgoraLogoProps) {
|
||||
const boltSize = Math.max(12, Math.round(size * 0.56));
|
||||
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Agora"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
className={cn(
|
||||
'relative rounded-full bg-gradient-to-br from-primary to-primary/80 shadow-lg flex items-center justify-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-full bg-primary/25 blur-md" aria-hidden />
|
||||
<div className="relative">
|
||||
<LightningBolt size={boltSize} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { NsitePreviewDialog } from '@/components/NsitePreviewDialog';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAddrEvent } from '@/hooks/useEvent';
|
||||
import { NostrURI } from '@/lib/NostrURI';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Get a tag value by name. */
|
||||
@@ -104,9 +105,13 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
|
||||
const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App';
|
||||
const about = metadata.about;
|
||||
const picture = metadata.picture;
|
||||
const banner = metadata.banner;
|
||||
const websiteUrl = getWebsiteUrl(event.tags, metadata);
|
||||
// Sanitize image URLs to reject non-https schemes (http IP leaks, data: URIs,
|
||||
// etc.). The CSP \`img-src\` already blocks most of these, but sanitizing
|
||||
// defense-in-depth matches the treatment of the website URL below and keeps
|
||||
// the component safe if it is ever rendered outside the app's own CSP.
|
||||
const picture = sanitizeUrl(metadata.picture);
|
||||
const banner = sanitizeUrl(metadata.banner);
|
||||
const websiteUrl = sanitizeUrl(getWebsiteUrl(event.tags, metadata));
|
||||
const hashtags = getAllTags(event.tags, 't');
|
||||
|
||||
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
|
||||
|
||||
@@ -217,7 +217,7 @@ function useApplyBackground(theme: Theme, customTheme: ThemeConfig | undefined,
|
||||
|
||||
/**
|
||||
* Hook to dynamically recolor the favicon to match the current primary color.
|
||||
* Uses the same mask approach as DittoLogo: loads the SVG, draws it as a mask
|
||||
* Uses a mask approach with /logo.svg: loads the SVG, draws it as a mask
|
||||
* on a canvas filled with the primary color, and sets the result as the favicon.
|
||||
*/
|
||||
function useApplyFavicon(theme: Theme, customTheme: ThemeConfig | undefined, themes: ThemesConfig | undefined) {
|
||||
|
||||
@@ -3,17 +3,41 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { parseBlobbiEvent } from '@/blobbi/core/lib/blobbi';
|
||||
import { calculateProjectedDecay } from '@/blobbi/core/hooks/useProjectedBlobbiState';
|
||||
import { resolveStatusRecipe, attenuateRecipeForFeed, EMPTY_RECIPE } from '@/blobbi/ui/lib/status-reactions';
|
||||
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
|
||||
export function BlobbiStateCard({ event }: { event: NostrEvent }) {
|
||||
const companion = useMemo(() => parseBlobbiEvent(event), [event]);
|
||||
|
||||
if (!companion) return null;
|
||||
const isSleeping = companion?.state === 'sleeping';
|
||||
const isEgg = companion?.stage === 'egg';
|
||||
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
// ── Project stats forward in time, then resolve visual recipe ──
|
||||
// Feed cards show a snapshot, not a live ticker, so we call the pure
|
||||
// calculateProjectedDecay() once per render instead of using the
|
||||
// interval-based useProjectedBlobbiState hook. This gives us the
|
||||
// same decay math the room view uses (applyBlobbiDecay under the
|
||||
// hood) without any per-card setInterval overhead.
|
||||
const { recipe: feedRecipe, recipeLabel: feedRecipeLabel } = useMemo(() => {
|
||||
if (!companion || isEgg) return { recipe: EMPTY_RECIPE, recipeLabel: 'neutral' };
|
||||
|
||||
const { stats } = calculateProjectedDecay(companion);
|
||||
|
||||
const result = resolveStatusRecipe(stats);
|
||||
|
||||
// Attenuate body effects for feed-card size, then apply sleep overlay
|
||||
const attenuated = attenuateRecipeForFeed(result.recipe);
|
||||
const final = isSleeping ? buildSleepingRecipe(attenuated) : attenuated;
|
||||
|
||||
return { recipe: final, recipeLabel: isSleeping ? 'sleeping' : result.label };
|
||||
}, [companion, isEgg, isSleeping]);
|
||||
|
||||
if (!companion) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center py-4">
|
||||
{/* Blobbi visual — same as /blobbi hero */}
|
||||
{/* Blobbi visual — reflects current condition */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
|
||||
<BlobbiStageVisual
|
||||
@@ -21,6 +45,8 @@ export function BlobbiStateCard({ event }: { event: NostrEvent }) {
|
||||
size="lg"
|
||||
animated={!isSleeping}
|
||||
lookMode="forward"
|
||||
recipe={feedRecipe}
|
||||
recipeLabel={feedRecipeLabel}
|
||||
className="size-48 sm:size-56"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { usePublishRSVP } from '@/hooks/usePublishRSVP';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// --- Helpers ---
|
||||
@@ -159,7 +160,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
const location = locationRaw ? parseLocation(locationRaw) : undefined;
|
||||
const summary = getTag(event.tags, 'summary');
|
||||
const hashtags = getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean);
|
||||
const links = getAllTags(event.tags, 'r').map(([, v]) => v).filter(Boolean);
|
||||
const links = getAllTags(event.tags, 'r').map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
|
||||
|
||||
const eventCoord = useMemo(() => getEventCoord(event), [event]);
|
||||
const dateStr = useMemo(() => formatDetailDate(event), [event]);
|
||||
|
||||
@@ -136,6 +136,7 @@ const KIND_LABELS: Record<number, string> = {
|
||||
34236: 'a divine',
|
||||
34550: 'a community',
|
||||
35128: 'an nsite',
|
||||
36639: 'an action',
|
||||
36767: 'a theme',
|
||||
36787: 'a track',
|
||||
37381: 'a Magic deck',
|
||||
@@ -164,6 +165,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
1618: GitPullRequest,
|
||||
15128: Rocket,
|
||||
35128: Rocket,
|
||||
36639: Zap,
|
||||
10008: Award,
|
||||
30008: Award,
|
||||
30009: Award,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Crown, Shield, Users } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { parseCommunityEvent, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CommunityCardProps {
|
||||
/** The kind 34550 community definition event. */
|
||||
event: NostrEvent;
|
||||
/** Whether the current user founded this community. */
|
||||
isFounded?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact card for displaying a community in a list.
|
||||
* Shows image, name, description snippet, founder info, and moderator count.
|
||||
*/
|
||||
export function CommunityCard({ event, isFounded, className }: CommunityCardProps) {
|
||||
const community = useMemo(() => parseCommunityEvent(event), [event]);
|
||||
|
||||
const founderAuthor = useAuthor(event.pubkey);
|
||||
const founderMeta = founderAuthor.data?.metadata;
|
||||
const founderAvatarShape = getAvatarShape(founderMeta);
|
||||
const founderName = founderMeta?.display_name || founderMeta?.name || genUserName(event.pubkey);
|
||||
const founderProfileUrl = useProfileUrl(event.pubkey, founderMeta);
|
||||
|
||||
if (!community) return null;
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
pubkey: event.pubkey,
|
||||
identifier: community.dTag,
|
||||
});
|
||||
|
||||
const rankCount = community.ranks.length - 1; // Exclude rank 0
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${naddr}`}
|
||||
className={cn(
|
||||
'group block rounded-xl border border-border hover:border-primary/30 transition-all hover:shadow-md overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Image banner */}
|
||||
{community.image ? (
|
||||
<div className="relative h-28 overflow-hidden bg-muted">
|
||||
<img
|
||||
src={community.image}
|
||||
alt={community.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-28 bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<Users className="size-10 text-primary/20" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Name + founder badge */}
|
||||
<div className="flex items-start gap-2">
|
||||
<h3 className="text-sm font-semibold truncate flex-1 group-hover:text-primary transition-colors">
|
||||
{community.name}
|
||||
</h3>
|
||||
{isFounded && (
|
||||
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
|
||||
<Crown className="size-2.5" />
|
||||
Founder
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{community.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
||||
{community.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer: founder + stats */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Link
|
||||
to={founderProfileUrl}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-1.5 min-w-0"
|
||||
>
|
||||
<Avatar shape={founderAvatarShape} className="size-5">
|
||||
<AvatarImage src={founderMeta?.picture} />
|
||||
<AvatarFallback className="text-[8px] bg-muted">
|
||||
{founderName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[11px] text-muted-foreground truncate hover:underline">
|
||||
{founderName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{community.moderatorPubkeys.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Shield className="size-3" />
|
||||
{community.moderatorPubkeys.length}
|
||||
</span>
|
||||
)}
|
||||
{rankCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Users className="size-3" />
|
||||
{rankCount} rank{rankCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -2,19 +2,17 @@ import { useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users, Share2, Globe } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -42,46 +40,15 @@ function parseCommunityEvent(event: NostrEvent) {
|
||||
return { name, description, image, moderators, relays };
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function ModeratorRow({ pubkey }: { pubkey: string }) {
|
||||
const { data } = useAuthor(pubkey);
|
||||
const metadata: NostrMetadata | undefined = data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
return (
|
||||
<Link to={profileUrl} className="flex items-center gap-3 group py-1.5">
|
||||
<Avatar shape={avatarShape} className="size-9 ring-2 ring-background">
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate group-hover:underline">{name}</p>
|
||||
{metadata?.nip05 && (
|
||||
<p className="text-xs text-muted-foreground truncate">{metadata.nip05}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">Moderator</Badge>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
const { toast } = useToast();
|
||||
const { name, description, image, moderators, relays } = useMemo(
|
||||
const { name, description, image } = useMemo(
|
||||
() => parseCommunityEvent(event),
|
||||
[event],
|
||||
);
|
||||
|
||||
// Batch-fetch moderator profiles
|
||||
useAuthors(moderators);
|
||||
|
||||
// Owner
|
||||
const ownerAuthor = useAuthor(event.pubkey);
|
||||
const ownerMetadata = ownerAuthor.data?.metadata;
|
||||
@@ -92,7 +59,7 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
// Extract website URL from description if present
|
||||
const descriptionUrl = useMemo(() => {
|
||||
const urlMatch = description.match(/https?:\/\/[^\s]+/);
|
||||
return urlMatch?.[0];
|
||||
return sanitizeUrl(urlMatch?.[0]);
|
||||
}, [description]);
|
||||
|
||||
// Description text without trailing URL (if the URL is the last thing)
|
||||
@@ -143,18 +110,8 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick stats + share */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{moderators.length} moderator{moderators.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{relays.length > 0 && (
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<Globe className="size-3.5" />
|
||||
{relays.length} relay{relays.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Share button */}
|
||||
<div className="flex items-center">
|
||||
<Button variant="outline" size="icon" className="ml-auto size-8 shrink-0" onClick={handleShare}>
|
||||
<Share2 className="size-3.5" />
|
||||
</Button>
|
||||
@@ -197,22 +154,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Moderators */}
|
||||
{moderators.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Users className="size-3.5" />
|
||||
Moderators
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
{moderators.map((pk) => (
|
||||
<ModeratorRow key={pk} pubkey={pk} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
Crown,
|
||||
MessageCircle,
|
||||
Shield,
|
||||
Share2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { parseCommunityEvent, type CommunityMember } from '@/lib/communityUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ── Calendar event kinds (NIP-52) ─────────────────────────────────────────────
|
||||
const CALENDAR_EVENT_KINDS = [31922, 31923];
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: string; size?: 'sm' | 'md' }) {
|
||||
const { data } = useAuthor(pubkey);
|
||||
const metadata: NostrMetadata | undefined = data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
const avatarCls = size === 'sm' ? 'size-8' : 'size-10';
|
||||
const fallbackCls = size === 'sm' ? 'text-xs' : '';
|
||||
|
||||
return (
|
||||
<Link to={profileUrl} className="flex items-center gap-3 group py-1">
|
||||
<Avatar shape={avatarShape} className={cn(avatarCls, 'ring-2 ring-background')}>
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className={cn('bg-muted text-muted-foreground', fallbackCls)}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('font-medium truncate group-hover:underline', size === 'sm' ? 'text-sm' : 'text-[15px]')}>{name}</p>
|
||||
{metadata?.nip05 && (
|
||||
<p className="text-xs text-muted-foreground truncate">{metadata.nip05}</p>
|
||||
)}
|
||||
</div>
|
||||
{label && (
|
||||
<Badge variant="secondary" className="ml-auto capitalize text-xs shrink-0">{label}</Badge>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-[180px] w-full rounded-lg" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyCardSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
const navigate = useNavigate();
|
||||
const { nostr } = useNostr();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Parse community definition
|
||||
const community = useMemo(() => parseCommunityEvent(event), [event]);
|
||||
const name = community?.name ?? 'Unnamed Community';
|
||||
const description = community?.description ?? '';
|
||||
const image = community?.image;
|
||||
const communityATag = community?.aTag ?? '';
|
||||
|
||||
// Extract website URL from description
|
||||
const descriptionUrl = useMemo(() => {
|
||||
const urlMatch = description.match(/https?:\/\/[^\s]+/);
|
||||
return sanitizeUrl(urlMatch?.[0]);
|
||||
}, [description]);
|
||||
|
||||
const descriptionText = useMemo(() => {
|
||||
if (!descriptionUrl) return description;
|
||||
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
|
||||
}, [description, descriptionUrl]);
|
||||
|
||||
// ── Members ─────────────────────────────────────────────────────────────────
|
||||
const { data: membership, isLoading: membersLoading } = useCommunityMembers(community);
|
||||
|
||||
// Batch-fetch profiles for all members
|
||||
const allMemberPubkeys = useMemo(
|
||||
() => membership?.members.map((m) => m.pubkey) ?? [],
|
||||
[membership],
|
||||
);
|
||||
useAuthors(allMemberPubkeys);
|
||||
|
||||
// Group members by rank
|
||||
const membersByRank = useMemo(() => {
|
||||
if (!membership || !community) return [];
|
||||
const groups = new Map<number, CommunityMember[]>();
|
||||
for (const m of membership.members) {
|
||||
const list = groups.get(m.rank) ?? [];
|
||||
list.push(m);
|
||||
groups.set(m.rank, list);
|
||||
}
|
||||
// Build ordered groups with labels
|
||||
const result: { rank: number; label: string; members: CommunityMember[] }[] = [];
|
||||
const sortedRanks = Array.from(groups.keys()).sort((a, b) => a - b);
|
||||
for (const rank of sortedRanks) {
|
||||
const members = groups.get(rank)!;
|
||||
let label: string;
|
||||
if (rank === 0) {
|
||||
label = 'Leadership';
|
||||
} else {
|
||||
// Find the badge a-tag for this rank from community definition
|
||||
const tier = community.ranks.find((r) => r.rank === rank);
|
||||
// Use the badge d-tag suffix as a label hint, or fall back to "Rank N"
|
||||
if (tier?.badgeATag) {
|
||||
const parts = tier.badgeATag.split(':');
|
||||
const dTag = parts.slice(2).join(':');
|
||||
// Try to extract a human-readable name from the d-tag (after the UUID prefix)
|
||||
const namePart = dTag.split('-').pop();
|
||||
label = namePart ? namePart.charAt(0).toUpperCase() + namePart.slice(1) : `Rank ${rank}`;
|
||||
} else {
|
||||
label = `Rank ${rank}`;
|
||||
}
|
||||
}
|
||||
result.push({ rank, label, members });
|
||||
}
|
||||
return result;
|
||||
}, [membership, community]);
|
||||
|
||||
// ── Events (calendar events tagging this community) ─────────────────────────
|
||||
const { data: communityEvents, isLoading: eventsLoading } = useQuery({
|
||||
queryKey: ['community-events', communityATag],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!communityATag) return [];
|
||||
const events = await nostr.query(
|
||||
[{ kinds: CALENDAR_EVENT_KINDS, '#a': [communityATag], limit: 100 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8_000)]) },
|
||||
);
|
||||
// Sort by start date descending
|
||||
return events.sort((a, b) => {
|
||||
const aStart = parseInt(a.tags.find(([n]) => n === 'start')?.[1] ?? '0', 10);
|
||||
const bStart = parseInt(b.tags.find(([n]) => n === 'start')?.[1] ?? '0', 10);
|
||||
return bStart - aStart;
|
||||
});
|
||||
},
|
||||
enabled: !!communityATag,
|
||||
staleTime: 2 * 60_000,
|
||||
});
|
||||
|
||||
// ── Comments (NIP-22 on the community event) ───────────────────────────────
|
||||
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
|
||||
|
||||
const replyTree = useMemo((): ReplyNode[] => {
|
||||
if (!commentsData) return [];
|
||||
const topLevel = commentsData.topLevelComments ?? [];
|
||||
|
||||
const buildNode = (ev: NostrEvent): ReplyNode => {
|
||||
const allChildren = commentsData.getDirectReplies(ev.id) ?? [];
|
||||
if (allChildren.length <= 1) {
|
||||
return {
|
||||
event: ev,
|
||||
children: allChildren.map((c) => buildNode(c)),
|
||||
};
|
||||
}
|
||||
const [first, ...rest] = allChildren;
|
||||
return {
|
||||
event: ev,
|
||||
children: [buildNode(first)],
|
||||
hiddenChildren: rest.map((c) => buildNode(c)),
|
||||
};
|
||||
};
|
||||
|
||||
return [...topLevel].sort((a, b) => a.created_at - b.created_at).map((r) => buildNode(r));
|
||||
}, [commentsData]);
|
||||
|
||||
// ── Share handler ───────────────────────────────────────────────────────────
|
||||
const handleShare = useCallback(async () => {
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
identifier: d,
|
||||
});
|
||||
const url = `${window.location.origin}/${naddr}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast({ title: 'Link copied to clipboard' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to copy link', variant: 'destructive' });
|
||||
}
|
||||
}, [event, toast]);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto pb-16">
|
||||
{/* ── Top bar ── */}
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-3">
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold flex-1 truncate">Community</h1>
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
>
|
||||
<Share2 className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Hero image ── */}
|
||||
{image ? (
|
||||
<div className="relative aspect-[21/9] w-full overflow-hidden">
|
||||
<img src={image} alt={name} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 px-5 pb-4">
|
||||
<h2 className="text-2xl font-bold text-white leading-tight drop-shadow-lg">{name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative aspect-[21/9] w-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<Users className="size-16 text-primary/20" />
|
||||
<div className="absolute bottom-0 left-0 right-0 px-5 pb-4">
|
||||
<h2 className="text-2xl font-bold leading-tight">{name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Community info ── */}
|
||||
<div className="px-5 mt-4 space-y-4">
|
||||
{/* Description */}
|
||||
{descriptionText && (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
|
||||
)}
|
||||
|
||||
{/* Founder */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Founded by</p>
|
||||
<PersonRow pubkey={event.pubkey} />
|
||||
</div>
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
<Tabs defaultValue="members" className="-mx-5">
|
||||
<TabsList className="w-full rounded-none border-b border-border bg-transparent p-0 h-auto">
|
||||
<TabsTrigger
|
||||
value="members"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
|
||||
>
|
||||
<Users className="size-4 mr-1.5" />
|
||||
Members
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="events"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
|
||||
>
|
||||
<CalendarDays className="size-4 mr-1.5" />
|
||||
Events
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="comments"
|
||||
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
|
||||
>
|
||||
<MessageCircle className="size-4 mr-1.5" />
|
||||
Comments
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Members tab ── */}
|
||||
<TabsContent value="members" className="mt-0">
|
||||
{membersLoading ? (
|
||||
<MembersSkeleton />
|
||||
) : membersByRank.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
No members found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{membersByRank.map(({ rank, label, members }) => (
|
||||
<section key={rank} className="px-5 py-4">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
{rank === 0 ? <Crown className="size-3.5 text-amber-500" /> : <Shield className="size-3.5" />}
|
||||
{label}
|
||||
<span className="text-muted-foreground/60 font-normal">({members.length})</span>
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{members.map((m) => {
|
||||
let roleLabel: string | undefined;
|
||||
if (rank === 0) {
|
||||
roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator';
|
||||
}
|
||||
return (
|
||||
<PersonRow
|
||||
key={m.pubkey}
|
||||
pubkey={m.pubkey}
|
||||
label={roleLabel}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Events tab ── */}
|
||||
<TabsContent value="events" className="mt-0">
|
||||
{eventsLoading ? (
|
||||
<EventsSkeleton />
|
||||
) : !communityEvents || communityEvents.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground text-sm">No events yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{communityEvents.map((ev) => (
|
||||
<NoteCard key={ev.id} event={ev} compact />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Comments tab ── */}
|
||||
<TabsContent value="comments" className="mt-0">
|
||||
<ComposeBox compact replyTo={event} />
|
||||
|
||||
{commentsLoading ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<ThreadedReplyList roots={replyTree} />
|
||||
) : (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
No comments yet. Be the first to comment!
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||