Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7073cadb43 | |||
| 2dfb880566 | |||
| 0d3b8ed23d | |||
| a61925b821 | |||
| cbfbca063e | |||
| f3393b2cc8 | |||
| 2eb643f422 | |||
| e22dbbe85c | |||
| e01ed039fb | |||
| 17cdb87723 | |||
| a55ff61669 | |||
| 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 | |||
| 6dc7fb7ade | |||
| d256acdef3 | |||
| 98e0273bdb | |||
| e26407d740 | |||
| b42f12ce77 | |||
| 7a10e4a406 | |||
| eda18d8b93 | |||
| 70809a8c7c | |||
| 5b15300f23 | |||
| 8585dd4833 | |||
| 12bda76526 | |||
| 5c8c33747e | |||
| 07a9b956cb | |||
| 0e7f847de0 | |||
| 4998ea8f5d | |||
| 0cc81cd35f | |||
| ed09c8947d | |||
| 2e79d93806 | |||
| f05097087b | |||
| 2fbc9e0409 | |||
| 313222d12e | |||
| 46ba6978dd |
@@ -1596,7 +1596,7 @@ The project automatically publishes Android AABs (App Bundles) to [Google Play](
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Full JSON contents of the Google Play API service account key file | Yes | Yes | No |
|
||||
| `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)
|
||||
|
||||
@@ -1604,7 +1604,17 @@ The project automatically publishes Android AABs (App Bundles) to [Google Play](
|
||||
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. Add the full JSON contents of the key file as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.6.5"
|
||||
versionName "2.7.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-keyboard')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -8,6 +8,9 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
|
||||
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')
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
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 */
|
||||
@@ -35,6 +37,8 @@
|
||||
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 */
|
||||
@@ -74,6 +78,8 @@
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
|
||||
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */,
|
||||
B1A2C3D40007000100000002 /* NostrPoller.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
@@ -170,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;
|
||||
};
|
||||
@@ -319,7 +327,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
MARKETING_VERSION = 2.7.1;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -343,7 +351,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
MARKETING_VERSION = 2.7.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,5 +55,13 @@
|
||||
<string>Ditto 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.ditto.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)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ let package = Package(
|
||||
.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: "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"),
|
||||
@@ -28,6 +29,7 @@ let package = Package(
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.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"),
|
||||
|
||||
Generated
+17
-7
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.6.5",
|
||||
"version": "2.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.6.5",
|
||||
"version": "2.7.0",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@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",
|
||||
@@ -109,7 +110,7 @@
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
@@ -418,6 +419,15 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/haptics": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz",
|
||||
"integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/ios": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.2.0.tgz",
|
||||
@@ -10087,12 +10097,12 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.462.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz",
|
||||
"integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
|
||||
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.6.5",
|
||||
"version": "2.7.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@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",
|
||||
@@ -116,7 +117,7 @@
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -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.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",
|
||||
"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,50 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -151,6 +151,11 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
sidebarWidgets: [
|
||||
{ id: 'trends' },
|
||||
{ id: 'hot-posts' },
|
||||
{ id: 'wikipedia' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,209 @@
|
||||
/**
|
||||
* 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)) {
|
||||
hydrateFromPersisted(parsed.missions, pubkey);
|
||||
hydratedRef.current = pubkey;
|
||||
setVersion((v) => v + 1);
|
||||
} else {
|
||||
hydratedRef.current = pubkey;
|
||||
}
|
||||
}, [pubkey, profileContent]);
|
||||
|
||||
// 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
|
||||
const raw = useMemo((): MissionsContent | undefined => {
|
||||
const stored = readMissionsFromStorage(pubkey);
|
||||
|
||||
if (!needsDailyReset(stored)) return stored;
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@
|
||||
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
|
||||
*
|
||||
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
|
||||
*
|
||||
* Most persistent tasks are RETROACTIVE — they query the user's full history
|
||||
* without a `since:` filter. Only Blobbi-specific tasks (interactions,
|
||||
* maintain_stats) require actions on the current Blobbi instance.
|
||||
*/
|
||||
|
||||
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';
|
||||
@@ -21,9 +25,6 @@ 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';
|
||||
@@ -39,15 +40,9 @@ 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;
|
||||
|
||||
@@ -75,52 +70,21 @@ export interface EvolveTasksResult {
|
||||
|
||||
// ─── 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
|
||||
* RETROACTIVE TASKS (count from full user history):
|
||||
* 1. Create 3 Themes (kind 36767) - ≥3 events ever
|
||||
* 2. Create 3 Color Moments (kind 3367) - ≥3 events ever
|
||||
* 3. Edit Profile once (kind 0 or kind 16769) - ≥1 event ever
|
||||
*
|
||||
* BLOBBI-SPECIFIC TASKS (must be done for this Blobbi):
|
||||
* 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
|
||||
* 5. Maintain All Stats >= 80
|
||||
*
|
||||
* @param companion - The Blobbi companion (must be in evolving state)
|
||||
* @param interactionCount - Current interaction count from companion tasks cache
|
||||
@@ -133,50 +97,44 @@ export function useEvolveTasks(
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
const stateStartedAt = companion?.stateStartedAt;
|
||||
const isEvolving = companion?.state === 'evolving';
|
||||
|
||||
// Query for all relevant events
|
||||
// Query for all relevant events.
|
||||
//
|
||||
// RETROACTIVE tasks (theme, color moment, profile) query the user's full
|
||||
// history — no `since:` filter. Completing the activity once satisfies
|
||||
// the requirement for every future baby's evolution.
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['evolve-tasks', pubkey, stateStartedAt],
|
||||
queryKey: ['evolve-tasks', pubkey],
|
||||
queryFn: async () => {
|
||||
if (!pubkey || !stateStartedAt) {
|
||||
if (!pubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build filters for events we need
|
||||
const filters: NostrFilter[] = [
|
||||
// Theme definitions after start
|
||||
// Theme definitions — retroactive (no since:)
|
||||
{
|
||||
kinds: [KIND_THEME_DEFINITION],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: EVOLVE_REQUIRED_THEMES,
|
||||
},
|
||||
// Color moments after start
|
||||
// Color moments — retroactive (no since:)
|
||||
{
|
||||
kinds: [KIND_COLOR_MOMENT],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
},
|
||||
// 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
|
||||
// Custom profile tabs — retroactive (no since:)
|
||||
{
|
||||
kinds: [KIND_PROFILE_TABS],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1, // Only need 1
|
||||
limit: 1,
|
||||
},
|
||||
// Profile metadata after start (for Blobbi shape check + profile edit mission)
|
||||
// Profile metadata — retroactive (no since:)
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1,
|
||||
},
|
||||
];
|
||||
@@ -185,37 +143,19 @@ export function useEvolveTasks(
|
||||
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 themeEvents = events.filter(e => e.kind === KIND_THEME_DEFINITION);
|
||||
const colorMomentEvents = events.filter(e => e.kind === KIND_COLOR_MOMENT);
|
||||
const profileTabsEvents = events.filter(e => e.kind === KIND_PROFILE_TABS);
|
||||
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,
|
||||
hasProfileMetadata: profileEvents.length > 0,
|
||||
};
|
||||
},
|
||||
enabled: !!pubkey && !!stateStartedAt && isEvolving,
|
||||
enabled: !!pubkey && isEvolving,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
refetchInterval: 60_000, // Refetch every minute
|
||||
});
|
||||
@@ -223,7 +163,7 @@ export function useEvolveTasks(
|
||||
// ─── Compute PERSISTENT Tasks ───
|
||||
const tasks: HatchTask[] = [];
|
||||
|
||||
// 1. Create 3 Themes (PERSISTENT)
|
||||
// 1. Create 3 Themes (PERSISTENT) — retroactive
|
||||
const themeCount = data?.themeEvents?.length ?? 0;
|
||||
const themesCompleted = themeCount >= EVOLVE_REQUIRED_THEMES;
|
||||
tasks.push({
|
||||
@@ -239,7 +179,7 @@ export function useEvolveTasks(
|
||||
actionLabel: 'Create Theme',
|
||||
});
|
||||
|
||||
// 2. Create 3 Color Moments (PERSISTENT)
|
||||
// 2. Create 3 Color Moments (PERSISTENT) — retroactive
|
||||
const colorMomentCount = data?.colorMomentEvents?.length ?? 0;
|
||||
const colorMomentsCompleted = colorMomentCount >= EVOLVE_REQUIRED_COLOR_MOMENTS;
|
||||
tasks.push({
|
||||
@@ -255,25 +195,7 @@ export function useEvolveTasks(
|
||||
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)
|
||||
// 3. Interact 21 times (PERSISTENT) — Blobbi-specific
|
||||
const interactions = interactionCount ?? 0;
|
||||
const interactionsCompleted = interactions >= EVOLVE_REQUIRED_INTERACTIONS;
|
||||
tasks.push({
|
||||
@@ -287,9 +209,9 @@ export function useEvolveTasks(
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// 5. Edit Profile once (PERSISTENT) — kind 0 profile metadata OR kind 16769 custom tabs
|
||||
// 4. Edit Profile once (PERSISTENT) — retroactive
|
||||
const hasTabsEdit = (data?.profileTabsEvents?.length ?? 0) >= 1;
|
||||
const hasMetadataEdit = !!data?.profileAfter;
|
||||
const hasMetadataEdit = data?.hasProfileMetadata ?? false;
|
||||
const hasProfileEdit = hasTabsEdit || hasMetadataEdit;
|
||||
tasks.push({
|
||||
id: 'edit_profile',
|
||||
@@ -305,7 +227,7 @@ export function useEvolveTasks(
|
||||
});
|
||||
|
||||
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
|
||||
// 7. Maintain All Stats >= 80
|
||||
// 5. Maintain All Stats >= 80 — Blobbi-specific
|
||||
const stats = companion?.stats ?? {};
|
||||
const hunger = stats.hunger ?? 0;
|
||||
const happiness = stats.happiness ?? 0;
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
* - 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.
|
||||
*
|
||||
* Most tasks are RETROACTIVE — they query the user's full history without
|
||||
* a `since:` filter. Only Blobbi-specific tasks (interactions) require
|
||||
* actions performed on the current Blobbi instance.
|
||||
*
|
||||
* Note: Egg stats no longer decay, so there are no dynamic tasks for hatching.
|
||||
*/
|
||||
@@ -42,23 +45,6 @@ export const BLOBBI_POST_PREFIX = 'Posting to hatch';
|
||||
// Legacy export for backwards compatibility
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -120,50 +106,38 @@ 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.
|
||||
*
|
||||
* A post is valid if it mentions the "blobbi" hashtag in either:
|
||||
* - A `["t", "blobbi"]` tag, OR
|
||||
* - The literal text `#blobbi` anywhere in the content
|
||||
*
|
||||
* This is intentionally loose so that historical posts can count
|
||||
* retroactively toward hatch requirements.
|
||||
*/
|
||||
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 {
|
||||
// Check for blobbi hashtag in t tags
|
||||
const hasBlobbiTag = event.tags.some(
|
||||
tag => tag[0] === 't' && tag[1]?.toLowerCase() === 'blobbi',
|
||||
);
|
||||
|
||||
return hasRequiredHashtags;
|
||||
}
|
||||
if (hasBlobbiTag) return true;
|
||||
|
||||
// Legacy function name for backwards compatibility
|
||||
export const isValidBlobbiPost = isValidHatchPost;
|
||||
// Fallback: check content for #blobbi (case-insensitive)
|
||||
return /#blobbi\b/i.test(event.content);
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
* RETROACTIVE TASKS (count from full user history):
|
||||
* 1. Create Theme (kind 36767) - ≥1 event ever
|
||||
* 2. Color Moment (kind 3367) - ≥1 event ever
|
||||
* 3. Create Post (kind 1) - ≥1 post with #blobbi hashtag ever
|
||||
*
|
||||
* Note: Egg stats no longer decay, so the "maintain stats" dynamic task
|
||||
* has been removed. The baby/adult evolve equivalent is still in useEvolveTasks.
|
||||
* BLOBBI-SPECIFIC TASKS (must be done for this Blobbi):
|
||||
* 4. Interactions - 7 total (tracked via companion.tasks cache)
|
||||
*
|
||||
* @param companion - The Blobbi companion (must be incubating)
|
||||
* @param interactionCount - Current interaction count from companion tasks cache
|
||||
@@ -176,51 +150,40 @@ export function useHatchTasks(
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
const stateStartedAt = companion?.stateStartedAt;
|
||||
const isIncubating = companion?.state === 'incubating';
|
||||
|
||||
// Query for all relevant events
|
||||
// Query for all relevant events.
|
||||
//
|
||||
// RETROACTIVE tasks (theme, color moment, post) query the user's full
|
||||
// history — no `since:` filter. This means completing the activity once
|
||||
// satisfies the requirement for every future egg.
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['hatch-tasks', pubkey, stateStartedAt],
|
||||
queryKey: ['hatch-tasks', pubkey],
|
||||
queryFn: async () => {
|
||||
if (!pubkey || !stateStartedAt) {
|
||||
if (!pubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build filters for events we need
|
||||
const filters: NostrFilter[] = [
|
||||
// Theme definitions after start
|
||||
// Theme definitions — retroactive (no since:)
|
||||
{
|
||||
kinds: [KIND_THEME_DEFINITION],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1, // Only need to know ≥1 exists
|
||||
},
|
||||
// Color moments after start
|
||||
// Color moments — retroactive (no since:)
|
||||
{
|
||||
kinds: [KIND_COLOR_MOMENT],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1,
|
||||
},
|
||||
// Posts after start (will filter for valid Blobbi posts)
|
||||
// Blobbi-tagged posts — retroactive (no since:)
|
||||
// Relay-level filter by #t=blobbi; client-side fallback in isValidHatchPost
|
||||
{
|
||||
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,
|
||||
'#t': ['blobbi'],
|
||||
limit: 1,
|
||||
},
|
||||
];
|
||||
@@ -229,36 +192,17 @@ export function useHatchTasks(
|
||||
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];
|
||||
const themeEvents = events.filter(e => e.kind === KIND_THEME_DEFINITION);
|
||||
const colorMomentEvents = events.filter(e => e.kind === KIND_COLOR_MOMENT);
|
||||
const postEvents = events.filter(e => e.kind === KIND_SHORT_TEXT_NOTE);
|
||||
|
||||
return {
|
||||
themeEvents,
|
||||
colorMomentEvents,
|
||||
postEvents,
|
||||
profileBefore,
|
||||
profileAfter,
|
||||
};
|
||||
},
|
||||
enabled: !!pubkey && !!stateStartedAt && isIncubating,
|
||||
enabled: !!pubkey && isIncubating,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
refetchInterval: 60_000, // Refetch every minute
|
||||
});
|
||||
@@ -296,14 +240,13 @@ export function useHatchTasks(
|
||||
actionLabel: 'Open espy',
|
||||
});
|
||||
|
||||
// 3. Create Post (PERSISTENT)
|
||||
const blobbiName = companion?.name ?? '';
|
||||
const validPosts = data?.postEvents?.filter(e => isValidHatchPost(e, blobbiName)) ?? [];
|
||||
// 3. Create Post (PERSISTENT) — retroactive: any post with #blobbi
|
||||
const validPosts = data?.postEvents?.filter(e => isValidHatchPost(e)) ?? [];
|
||||
const hasValidPost = validPosts.length >= 1;
|
||||
tasks.push({
|
||||
id: 'create_post',
|
||||
name: 'Create Post',
|
||||
description: 'Share a post about hatching your Blobbi',
|
||||
description: 'Share a post with the #blobbi hashtag',
|
||||
current: hasValidPost ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasValidPost,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -54,7 +54,6 @@ export {
|
||||
useHatchTasks,
|
||||
getInteractionCount,
|
||||
filterPersistentTasks,
|
||||
sanitizeToHashtag,
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
@@ -67,14 +66,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';
|
||||
|
||||
@@ -155,20 +151,65 @@ export {
|
||||
|
||||
// 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 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,
|
||||
|
||||
@@ -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,115 @@
|
||||
/**
|
||||
* 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,
|
||||
} 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 });
|
||||
}
|
||||
|
||||
/** Read current session state for a pubkey. */
|
||||
export function readMissionsFromStorage(pubkey?: string): MissionsContent | undefined {
|
||||
return sessionStore.get(key(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,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -105,8 +105,12 @@ 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;
|
||||
// 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');
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
|
||||
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { NoteMedia } from '@/components/NoteMedia';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { usePostComment } from '@/hooks/usePostComment';
|
||||
@@ -34,11 +34,11 @@ import { useToast } from '@/hooks/useToast';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, mimeFromExt } from '@/lib/mediaUrls';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, mimeFromExt } from '@/lib/mediaUrls';
|
||||
|
||||
/** Lazy-loaded EmojiPicker — keeps emoji-mart + its data out of the main bundle. */
|
||||
const LazyEmojiPicker = lazy(() => import('@/components/EmojiPicker').then(m => ({ default: m.EmojiPicker })));
|
||||
import { parseImetaMap } from '@/lib/imeta';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useInsertText } from '@/hooks/useInsertText';
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder';
|
||||
@@ -347,7 +347,7 @@ export function ComposeBox({
|
||||
const url = match[0];
|
||||
// Skip media URLs that render inline
|
||||
// Note: SVGs not excluded - LinkPreview checks content-type and handles both cases
|
||||
if (!IMETA_MEDIA_URL_REGEX.test(url)) {
|
||||
if (!IMETA_MEDIA_URL_TEST_REGEX.test(url)) {
|
||||
embeds.push({ type: 'link', value: url, index: match.index! });
|
||||
}
|
||||
}
|
||||
@@ -716,6 +716,7 @@ export function ComposeBox({
|
||||
}
|
||||
}
|
||||
}
|
||||
notificationSuccess();
|
||||
toast({ title: 'Voice message sent!', description: 'Your voice message has been published.' });
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
@@ -973,6 +974,7 @@ export function ComposeBox({
|
||||
queryClient.invalidateQueries({ queryKey: ['event-stats', quotedEvent.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] });
|
||||
}
|
||||
notificationSuccess();
|
||||
toast({ title: 'Posted!', description: replyTo ? 'Your reply has been published.' : quotedEvent ? 'Your quote has been published.' : 'Your note has been published.' });
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
@@ -1016,6 +1018,7 @@ export function ComposeBox({
|
||||
await createEvent({ kind: 1068, content: finalContent, tags });
|
||||
resetComposeState();
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
notificationSuccess();
|
||||
toast({ title: 'Poll published!' });
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
@@ -1116,31 +1119,13 @@ export function ComposeBox({
|
||||
</div>
|
||||
) : (
|
||||
/* Preview mode - Show how post will look */
|
||||
mockEvent && (() => {
|
||||
const imetaMap = parseImetaMap(mockEvent.tags);
|
||||
const videos = extractVideoUrls(mockEvent.content);
|
||||
const imetaAudios = Array.from(imetaMap.values())
|
||||
.filter((e) => e.mime?.startsWith('audio/'))
|
||||
.map((e) => e.url);
|
||||
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
|
||||
const webxdcApps = Array.from(imetaMap.values()).filter(
|
||||
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
|
||||
);
|
||||
return (
|
||||
<div className="pt-2.5 pb-2 min-h-[100px]">
|
||||
<div className="text-lg opacity-85">
|
||||
<NoteContent event={mockEvent} className="text-foreground" />
|
||||
</div>
|
||||
<NoteMedia
|
||||
videos={videos}
|
||||
audios={audios}
|
||||
imetaMap={imetaMap}
|
||||
webxdcApps={webxdcApps}
|
||||
event={mockEvent}
|
||||
/>
|
||||
mockEvent && (
|
||||
<div className="pt-2.5 pb-2 min-h-[100px]">
|
||||
<div className="text-lg opacity-85">
|
||||
<NoteContent event={mockEvent} className="text-foreground" />
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Poll options + settings — shown below the normal textarea/preview */}
|
||||
|
||||
@@ -905,6 +905,29 @@ export function DMProvider({ children, config }: DMProviderProps) {
|
||||
const messageContent = await user.signer.nip44.decrypt(sealEvent.pubkey, sealEvent.content);
|
||||
const messageEvent = JSON.parse(messageContent) as NostrEvent;
|
||||
|
||||
// NIP-17: clients MUST verify that the inner rumor's pubkey matches the
|
||||
// seal's pubkey. Without this check, anyone can gift-wrap a rumor whose
|
||||
// `pubkey` field claims to be someone else and impersonate that user.
|
||||
// The seal signature authenticates only the seal author, not whatever
|
||||
// pubkey appears inside the (unsigned) rumor.
|
||||
if (messageEvent.pubkey !== sealEvent.pubkey) {
|
||||
console.log(`[DM] ⚠️ NIP-17 IMPERSONATION ATTEMPT - inner pubkey does not match seal pubkey`, {
|
||||
giftWrapId: event.id,
|
||||
sealPubkey: sealEvent.pubkey,
|
||||
innerPubkey: messageEvent.pubkey,
|
||||
});
|
||||
return {
|
||||
processedMessage: {
|
||||
...event,
|
||||
content: '',
|
||||
decryptedContent: '',
|
||||
error: 'Inner event pubkey does not match seal pubkey (possible impersonation)',
|
||||
},
|
||||
conversationPartner: event.pubkey,
|
||||
sealEvent,
|
||||
};
|
||||
}
|
||||
|
||||
// Accept both kind 14 (text) and kind 15 (files/attachments)
|
||||
if (messageEvent.kind !== 14 && messageEvent.kind !== 15) {
|
||||
console.log(`[DM] ⚠️ NIP-17 MESSAGE WITH UNSUPPORTED INNER EVENT KIND:`, {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DORK_ANIMATION = [
|
||||
'<[o_o]>',
|
||||
'>[-_-]<',
|
||||
'<[0_0]>',
|
||||
'>[-_-]<',
|
||||
];
|
||||
|
||||
/** Animated Dork face shown while the AI is thinking. */
|
||||
export function DorkThinking({ className }: { className?: string }) {
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setFrame((f) => (f + 1) % DORK_ANIMATION.length);
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<pre className={cn('font-mono text-muted-foreground leading-none', className)}>{DORK_ANIMATION[frame]}</pre>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface EmbeddedCardShellProps {
|
||||
/** Author pubkey — used for the author row. */
|
||||
pubkey: string;
|
||||
/** Timestamp of the event (unix seconds). */
|
||||
createdAt: number;
|
||||
/** The NIP-19 identifier to navigate to on click. */
|
||||
navigateTo: string;
|
||||
className?: string;
|
||||
/** When true, ProfileHoverCards inside the card are disabled. */
|
||||
disableHoverCards?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared clickable card shell with an author row used by all embedded
|
||||
* note / naddr preview cards. Handles the outer border, hover style,
|
||||
* click / keyboard navigation, avatar, display name, and timestamp.
|
||||
*
|
||||
* Pass card-specific content (text preview, blobbi visual, badge row, etc.)
|
||||
* as `children`.
|
||||
*/
|
||||
export function EmbeddedCardShell({
|
||||
pubkey,
|
||||
createdAt,
|
||||
navigateTo,
|
||||
className,
|
||||
disableHoverCards,
|
||||
children,
|
||||
}: EmbeddedCardShellProps) {
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group block rounded-2xl border border-border overflow-hidden',
|
||||
'hover:bg-secondary/40 transition-colors cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/${navigateTo}`);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(`/${navigateTo}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-5 rounded-full shrink-0" />
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MaybeProfileHoverCard pubkey={pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
|
||||
<MaybeProfileHoverCard pubkey={pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="text-sm font-semibold truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
· {timeAgo(createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Conditionally wraps children in a ProfileHoverCard. */
|
||||
function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
|
||||
if (disabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
{children}
|
||||
</ProfileHoverCard>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Award, Image, MessageSquareOff } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
const BlobbiStateCard = lazy(() => import('@/components/BlobbiStateCard').then(m => ({ default: m.BlobbiStateCard })));
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { parseProfileBadges } from '@/lib/parseProfileBadges';
|
||||
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { isProfileBadgesKind } from '@/lib/badgeUtils';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getKindLabel, getKindIcon } from '@/lib/extraKinds';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
interface EmbeddedNaddrProps {
|
||||
/** The decoded naddr coordinates. */
|
||||
@@ -87,6 +90,11 @@ export function EmbeddedNaddr({ addr, className, disableHoverCards }: EmbeddedNa
|
||||
return <EmbeddedProfileBadgesCard event={event} className={className} />;
|
||||
}
|
||||
|
||||
// Blobbi state events render the pet visual inline
|
||||
if (event.kind === 31124) {
|
||||
return <EmbeddedBlobbiCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
return <EmbeddedNaddrCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
@@ -194,6 +202,7 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
const badgeRefs = useMemo(() => parseProfileBadges(event), [event]);
|
||||
|
||||
@@ -265,7 +274,7 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to={`/${nip19.npubEncode(event.pubkey)}`}
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -277,7 +286,7 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
|
||||
</Avatar>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/${nip19.npubEncode(event.pubkey)}`}
|
||||
to={profileUrl}
|
||||
className="text-sm font-semibold truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -325,13 +334,6 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
|
||||
}
|
||||
|
||||
function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
|
||||
|
||||
const naddrId = useMemo(() => {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
|
||||
@@ -353,114 +355,65 @@ function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: Nos
|
||||
}, [event.kind]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group block rounded-2xl border border-border overflow-hidden',
|
||||
'hover:bg-secondary/40 transition-colors cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/${naddrId}`);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(`/${naddrId}`);
|
||||
}
|
||||
}}
|
||||
<EmbeddedCardShell
|
||||
pubkey={event.pubkey}
|
||||
createdAt={event.created_at}
|
||||
navigateTo={naddrId}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
>
|
||||
{/* Text content */}
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-5 rounded-full shrink-0" />
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={`/${npub}`}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={`/${npub}`}
|
||||
className="text-sm font-semibold truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
</>
|
||||
)}
|
||||
{/* Description */}
|
||||
{truncatedDesc && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{truncatedDesc}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
· {timeAgo(event.created_at)}
|
||||
{/* Kind label and attachment indicators */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{kindMeta && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{kindMeta.Icon && <kindMeta.Icon className="size-3 shrink-0" />}
|
||||
{kindMeta.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{truncatedDesc && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{truncatedDesc}
|
||||
</p>
|
||||
{image && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
Image
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Kind label and attachment indicators */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{kindMeta && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{kindMeta.Icon && <kindMeta.Icon className="size-3 shrink-0" />}
|
||||
{kindMeta.label}
|
||||
</span>
|
||||
)}
|
||||
{image && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
Image
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EmbeddedCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Conditionally wraps children in a ProfileHoverCard. When disabled, renders children directly. */
|
||||
function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
|
||||
if (disabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
/** Embedded card for kind 31124 Blobbi state events — renders the pet visual inline. */
|
||||
function EmbeddedBlobbiCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) {
|
||||
const naddrId = useMemo(() => {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
|
||||
}, [event]);
|
||||
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
{children}
|
||||
</ProfileHoverCard>
|
||||
<EmbeddedCardShell
|
||||
pubkey={event.pubkey}
|
||||
createdAt={event.created_at}
|
||||
navigateTo={naddrId}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
>
|
||||
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
|
||||
<BlobbiStateCard event={event} />
|
||||
</Suspense>
|
||||
</EmbeddedCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+289
-289
@@ -1,74 +1,39 @@
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { lazy, type ReactNode, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Image, Film, Music, ExternalLink, Blocks, MessageSquareOff } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Image, Film, Music, ExternalLink, Blocks, MessageSquareOff, Zap } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
|
||||
import { VanishCardCompact } from '@/components/VanishEventContent';
|
||||
import { EncryptedMessageCompact } from '@/components/EncryptedMessageContent';
|
||||
import { EncryptedLetterCompact } from '@/components/EncryptedLetterContent';
|
||||
import { EmbeddedProfileBadgesCard } from '@/components/EmbeddedNaddr';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { useEvent } from '@/hooks/useEvent';
|
||||
import { isProfileBadgesKind } from '@/lib/badgeUtils';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { isProfileBadgesKind } from '@/lib/badgeUtils';
|
||||
import { extractZapAmount, extractZapSender, extractZapMessage } from '@/hooks/useEventInteractions';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { LinkPreview } from '@/components/LinkPreview';
|
||||
import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_REGEX, extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls';
|
||||
import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls';
|
||||
import { getKindLabel, getKindIcon } from '@/lib/extraKinds';
|
||||
|
||||
const BlobbiStateCard = lazy(() => import('@/components/BlobbiStateCard').then(m => ({ default: m.BlobbiStateCard })));
|
||||
|
||||
/** NIP-62 Request to Vanish. */
|
||||
const VANISH_KIND = 62;
|
||||
|
||||
/** Bech32 charset used by NIP-19 identifiers. */
|
||||
const B32 = '023456789acdefghjklmnpqrstuvwxyz';
|
||||
|
||||
/** Regex that matches nostr:npub1… and nostr:nprofile1… inside text. */
|
||||
const MENTION_REGEX = new RegExp(`nostr:(npub1|nprofile1)[${B32}]+`, 'g');
|
||||
|
||||
/** A parsed segment of embedded-note text. */
|
||||
type EmbedSegment =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'mention'; pubkey: string; npub: string };
|
||||
|
||||
/** Split text into plain strings and mention segments. */
|
||||
function parseEmbedSegments(text: string): EmbedSegment[] {
|
||||
const segments: EmbedSegment[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
MENTION_REGEX.lastIndex = 0;
|
||||
while ((m = MENTION_REGEX.exec(text)) !== null) {
|
||||
if (m.index > last) {
|
||||
segments.push({ type: 'text', value: text.slice(last, m.index) });
|
||||
}
|
||||
try {
|
||||
const bech32 = m[0].slice('nostr:'.length);
|
||||
const decoded = nip19.decode(bech32);
|
||||
const pubkey = decoded.type === 'npub'
|
||||
? (decoded.data as string)
|
||||
: (decoded.data as { pubkey: string }).pubkey;
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
segments.push({ type: 'mention', pubkey, npub });
|
||||
} catch {
|
||||
// If decode fails, keep as plain text
|
||||
segments.push({ type: 'text', value: m[0] });
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
|
||||
if (last < text.length) {
|
||||
segments.push({ type: 'text', value: text.slice(last) });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
/** Max-height (px) for the content area before it gets clipped. */
|
||||
const EMBED_MAX_HEIGHT = 260;
|
||||
|
||||
interface EmbeddedNoteProps {
|
||||
/** Hex event ID to fetch and display. */
|
||||
@@ -82,9 +47,6 @@ interface EmbeddedNoteProps {
|
||||
disableHoverCards?: boolean;
|
||||
}
|
||||
|
||||
/** Maximum characters of note content to show in the embedded preview. */
|
||||
const MAX_CONTENT_LENGTH = 280;
|
||||
|
||||
/** Inline embedded note card – similar to a link preview but for Nostr events. */
|
||||
export function EmbeddedNote({ eventId, relays, authorHint, className, disableHoverCards }: EmbeddedNoteProps) {
|
||||
const { data: event, isLoading, isError } = useEvent(eventId, relays, authorHint);
|
||||
@@ -117,99 +79,32 @@ export function EmbeddedNote({ eventId, relays, authorHint, className, disableHo
|
||||
return <EmbeddedProfileBadgesCard event={event} className={className} />;
|
||||
}
|
||||
|
||||
// Kind 9735 zap receipts get a compact zap card instead of rendering raw JSON
|
||||
if (event.kind === 9735) {
|
||||
return <EmbeddedZapCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
return <EmbeddedNoteCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
/** The actual card once the event has been fetched. */
|
||||
function EmbeddedNoteCard({
|
||||
event,
|
||||
className,
|
||||
disableHoverCards,
|
||||
}: {
|
||||
event: { id: string; kind: number; pubkey: string; content: string; created_at: number; tags: string[][] };
|
||||
className?: string;
|
||||
disableHoverCards?: boolean;
|
||||
}) {
|
||||
const { config } = useAppContext();
|
||||
/** Compact inline card for kind 9735 zap receipts. */
|
||||
function EmbeddedZapCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(event.pubkey);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const neventId = useMemo(
|
||||
() => nip19.neventEncode({ id: event.id, author: event.pubkey }),
|
||||
[event.id, event.pubkey],
|
||||
);
|
||||
|
||||
// Extract the first non-media URL for a link preview card
|
||||
const firstLinkUrl = useMemo(() => {
|
||||
const allUrls = event.content.match(/https?:\/\/[^\s]+/g) || [];
|
||||
return allUrls.find((u) => !IMETA_MEDIA_URL_REGEX.test(u)) ?? null;
|
||||
}, [event.content]);
|
||||
const senderPubkey = useMemo(() => extractZapSender(event), [event]);
|
||||
const amountSats = useMemo(() => Math.floor(extractZapAmount(event) / 1000), [event]);
|
||||
const message = useMemo(() => extractZapMessage(event), [event]);
|
||||
|
||||
// Truncate long content, stripping media URLs, the previewed link, and nested nostr event references
|
||||
const truncatedContent = useMemo(() => {
|
||||
let text = event.content
|
||||
// Strip media URLs (same extensions as NoteContent's MEDIA_URL_REGEX)
|
||||
.replace(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi'), '')
|
||||
// Strip embedded event references (nevent / note) so they don't nest
|
||||
.replace(/nostr:(nevent1|note1)[023456789acdefghjklmnpqrstuvwxyz]+/g, '');
|
||||
// Strip the URL that will be shown as a link preview card
|
||||
if (firstLinkUrl) {
|
||||
text = text.replace(firstLinkUrl, '');
|
||||
}
|
||||
const cleaned = text
|
||||
// Collapse leftover whitespace
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
if (cleaned.length <= MAX_CONTENT_LENGTH) return cleaned;
|
||||
return cleaned.slice(0, MAX_CONTENT_LENGTH).trimEnd() + '…';
|
||||
}, [event.content, firstLinkUrl]);
|
||||
|
||||
// For non-text kinds with empty content, extract title/description from tags
|
||||
const tagMeta = useMemo(() => {
|
||||
if (truncatedContent) return undefined;
|
||||
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
|
||||
const title = getTag('title') || getTag('name') || getTag('d');
|
||||
const description = getTag('summary') || getTag('description');
|
||||
|
||||
// Build a kind label line for context (e.g. "nsite")
|
||||
const kindLabel = getKindLabel(event.kind);
|
||||
const KindIcon = getKindIcon(event.kind);
|
||||
|
||||
if (!title && !description && !kindLabel) return undefined;
|
||||
return { title, description, kindLabel, KindIcon };
|
||||
}, [truncatedContent, event.tags, event.kind]);
|
||||
|
||||
// Detect stripped attachments to show indicator chips
|
||||
const isPhoto = event.kind === 20;
|
||||
const attachments = useMemo(() => {
|
||||
// Kind 20 (NIP-68 photo events): count images from imeta tags instead of content
|
||||
if (isPhoto) {
|
||||
const photoCount = event.tags.filter(([n]) => n === 'imeta').length;
|
||||
return { imgs: 0, vids: 0, auds: 0, apps: 0, links: 0, photos: photoCount };
|
||||
}
|
||||
const imgs = (event.content.match(new RegExp(IMAGE_URL_REGEX.source, 'gi')) || []).length;
|
||||
const vids = extractVideoUrls(event.content).length;
|
||||
const auds = extractAudioUrls(event.content).length;
|
||||
const apps = (event.content.match(/https?:\/\/[^\s]+\.xdc(\?[^\s]*)?/gi) || []).length;
|
||||
const allUrls = event.content.match(/https?:\/\/[^\s]+/g) || [];
|
||||
const nonMediaLinks = allUrls.filter((u) => !IMETA_MEDIA_URL_REGEX.test(u)).length;
|
||||
// Subtract 1 if we're showing a link preview card for the first URL
|
||||
const links = firstLinkUrl ? nonMediaLinks - 1 : nonMediaLinks;
|
||||
return { imgs, vids, auds, apps, links, photos: 0 };
|
||||
}, [event.content, event.tags, isPhoto, firstLinkUrl]);
|
||||
|
||||
// NIP-36 content-warning check
|
||||
const cwTag = event.tags.find(([name]) => name === 'content-warning');
|
||||
const hasCW = !!cwTag;
|
||||
|
||||
// If policy is "hide", don't render the embedded note at all
|
||||
if (hasCW && config.contentWarningPolicy === 'hide') {
|
||||
return null;
|
||||
}
|
||||
const sender = useAuthor(senderPubkey || undefined);
|
||||
const senderMeta = sender.data?.metadata;
|
||||
const senderName = senderMeta?.name || (senderPubkey ? genUserName(senderPubkey) : 'Someone');
|
||||
const senderShape = getAvatarShape(senderMeta);
|
||||
const senderProfileUrl = useProfileUrl(senderPubkey, senderMeta);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -232,178 +127,273 @@ function EmbeddedNoteCard({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Note content */}
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-5 rounded-full shrink-0" />
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
<div className="px-3 py-2.5 flex items-center gap-2.5 min-w-0">
|
||||
{/* Zap icon */}
|
||||
<div className="flex items-center justify-center size-9 rounded-full bg-amber-500/10 shrink-0">
|
||||
<Zap className="size-4 text-amber-500 fill-amber-500" />
|
||||
</div>
|
||||
|
||||
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
|
||||
{/* Sender avatar */}
|
||||
{senderPubkey && (
|
||||
<MaybeHoverCard pubkey={senderPubkey} disabled={disableHoverCards}>
|
||||
<Link to={senderProfileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={senderShape} className="size-5">
|
||||
<AvatarImage src={senderMeta?.picture} alt={senderName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{senderName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</MaybeHoverCard>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{senderPubkey ? (
|
||||
<MaybeHoverCard pubkey={senderPubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
to={senderProfileUrl}
|
||||
className="text-sm font-semibold truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
{sender.data?.event ? (
|
||||
<EmojifiedText tags={sender.data.event.tags}>{senderName}</EmojifiedText>
|
||||
) : senderName}
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
</>
|
||||
</MaybeHoverCard>
|
||||
) : (
|
||||
<span className="text-sm font-semibold truncate">Someone</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">zapped</span>
|
||||
{amountSats > 0 && (
|
||||
<span className="text-sm font-semibold text-amber-500 shrink-0">
|
||||
{formatNumber(amountSats)} {amountSats === 1 ? 'sat' : 'sats'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
· {timeAgo(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{message && (
|
||||
<p className="text-xs text-muted-foreground italic mt-0.5 line-clamp-2">
|
||||
“{message}”
|
||||
</p>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
· {timeAgo(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content warning notice or text preview or tag-based metadata */}
|
||||
{hasCW && config.contentWarningPolicy === 'blur' ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Content warning{cwTag?.[1] ? <>{' '}“{cwTag[1]}”</> : ''}
|
||||
</p>
|
||||
) : truncatedContent ? (
|
||||
<EmbedContentPreview text={truncatedContent} disableHoverCards={disableHoverCards} />
|
||||
) : tagMeta ? (
|
||||
<>
|
||||
{tagMeta.title && (
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">{tagMeta.title}</p>
|
||||
)}
|
||||
{tagMeta.description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">{tagMeta.description}</p>
|
||||
)}
|
||||
{tagMeta.kindLabel && (
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{tagMeta.KindIcon && <tagMeta.KindIcon className="size-3 shrink-0" />}
|
||||
{tagMeta.kindLabel}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Link preview card for the first non-media URL */}
|
||||
{!hasCW && firstLinkUrl && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<LinkPreview url={firstLinkUrl} className="mt-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachment indicators for stripped media/links */}
|
||||
{!hasCW && (attachments.photos > 0 || attachments.imgs > 0 || attachments.vids > 0 || attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0) && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{attachments.photos > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
{attachments.photos > 1 ? `${attachments.photos} photos` : 'Photo'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.imgs > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
{attachments.imgs > 1 ? `${attachments.imgs} images` : 'Image'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.vids > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Film className="size-3" />
|
||||
{attachments.vids > 1 ? `${attachments.vids} videos` : 'Video'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.auds > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Music className="size-3" />
|
||||
{attachments.auds > 1 ? `${attachments.auds} audio files` : 'Audio'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.apps > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Blocks className="size-3" />
|
||||
App
|
||||
</span>
|
||||
)}
|
||||
{attachments.links > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<ExternalLink className="size-3" />
|
||||
{attachments.links > 1 ? `${attachments.links} links` : 'Link'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders embedded-note text with @mentions resolved inline. */
|
||||
function EmbedContentPreview({ text, disableHoverCards }: { text: string; disableHoverCards?: boolean }) {
|
||||
const segments = useMemo(() => parseEmbedSegments(text), [text]);
|
||||
/** The actual card once the event has been fetched. */
|
||||
function EmbeddedNoteCard({
|
||||
event,
|
||||
className,
|
||||
disableHoverCards,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
disableHoverCards?: boolean;
|
||||
}) {
|
||||
const { config } = useAppContext();
|
||||
|
||||
return (
|
||||
<p className="text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words overflow-hidden line-clamp-3">
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === 'text') {
|
||||
return <span key={i}>{seg.value}</span>;
|
||||
}
|
||||
return <EmbedMention key={i} pubkey={seg.pubkey} npub={seg.npub} disableHoverCards={disableHoverCards} />;
|
||||
})}
|
||||
</p>
|
||||
const neventId = useMemo(
|
||||
() => nip19.neventEncode({ id: event.id, author: event.pubkey }),
|
||||
[event.id, event.pubkey],
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline @mention inside an embedded note preview. */
|
||||
function EmbedMention({ pubkey, disableHoverCards }: { pubkey: string; npub: string; disableHoverCards?: boolean }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const hasRealName = !!author.data?.metadata?.name;
|
||||
const displayName = author.data?.metadata?.name ?? genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, author.data?.metadata);
|
||||
const [contentOverflows, setContentOverflows] = useState(false);
|
||||
const [contentExpanded, setContentExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<MaybeProfileHoverCard pubkey={pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className={cn(
|
||||
'font-medium hover:underline',
|
||||
hasRealName ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
);
|
||||
}
|
||||
const isBlobbiState = event.kind === 31124;
|
||||
const isPhoto = event.kind === 20;
|
||||
|
||||
/** Conditionally wraps children in a ProfileHoverCard. When disabled, renders children directly. */
|
||||
function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
|
||||
if (disabled) {
|
||||
return <>{children}</>;
|
||||
// Attachment counts for indicator chips
|
||||
const attachments = useMemo(() => {
|
||||
if (isBlobbiState) return { imgs: 0, vids: 0, auds: 0, apps: 0, links: 0, photos: 0 };
|
||||
if (isPhoto) {
|
||||
const photoCount = event.tags.filter(([n]) => n === 'imeta').length;
|
||||
return { imgs: 0, vids: 0, auds: 0, apps: 0, links: 0, photos: photoCount };
|
||||
}
|
||||
const imgs = (event.content.match(new RegExp(IMAGE_URL_REGEX.source, 'gi')) || []).length;
|
||||
const vids = extractVideoUrls(event.content).length;
|
||||
const auds = extractAudioUrls(event.content).length;
|
||||
const apps = (event.content.match(/https?:\/\/[^\s]+\.xdc(\?[^\s]*)?/gi) || []).length;
|
||||
const allUrls = event.content.match(/https?:\/\/[^\s]+/g) || [];
|
||||
const links = allUrls.filter((u) => !IMETA_MEDIA_URL_TEST_REGEX.test(u)).length;
|
||||
return { imgs, vids, auds, apps, links, photos: 0 };
|
||||
}, [event.content, event.tags, isPhoto, isBlobbiState]);
|
||||
|
||||
// Kind label for non-text-note kinds
|
||||
const kindMeta = useMemo(() => {
|
||||
const label = getKindLabel(event.kind);
|
||||
if (!label) return undefined;
|
||||
return { label, Icon: getKindIcon(event.kind) };
|
||||
}, [event.kind]);
|
||||
|
||||
// Tag-based fallback metadata for events with empty content (articles, custom kinds, etc.)
|
||||
const hasContent = event.content.trim().length > 0;
|
||||
const tagMeta = useMemo(() => {
|
||||
if (hasContent) return undefined;
|
||||
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
|
||||
const title = getTag('title') || getTag('name') || getTag('d');
|
||||
const description = getTag('summary') || getTag('description');
|
||||
if (!title && !description) return undefined;
|
||||
return { title, description };
|
||||
}, [hasContent, event.tags]);
|
||||
|
||||
// NIP-36 content-warning check
|
||||
const cwTag = event.tags.find(([name]) => name === 'content-warning');
|
||||
const hasCW = !!cwTag;
|
||||
|
||||
// If policy is "hide", don't render the embedded note at all
|
||||
if (hasCW && config.contentWarningPolicy === 'hide') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasChips = !hasCW && (
|
||||
attachments.photos > 0 || attachments.imgs > 0 || attachments.vids > 0 ||
|
||||
attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0 || kindMeta
|
||||
);
|
||||
const hasFooter = hasChips || contentOverflows;
|
||||
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
{children}
|
||||
</ProfileHoverCard>
|
||||
<EmbeddedCardShell
|
||||
pubkey={event.pubkey}
|
||||
createdAt={event.created_at}
|
||||
navigateTo={neventId}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
>
|
||||
{/* Content — rendered identically to a normal NoteCard, just height-capped */}
|
||||
{hasCW && config.contentWarningPolicy === 'blur' ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Content warning{cwTag?.[1] ? <>{' '}“{cwTag[1]}”</> : ''}
|
||||
</p>
|
||||
) : isBlobbiState ? (
|
||||
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
|
||||
<BlobbiStateCard event={event} />
|
||||
</Suspense>
|
||||
) : tagMeta ? (
|
||||
<>
|
||||
{tagMeta.title && (
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">{tagMeta.title}</p>
|
||||
)}
|
||||
{tagMeta.description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">{tagMeta.description}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmbedTruncatedContent event={event} expanded={contentExpanded} onOverflowChange={setContentOverflows} />
|
||||
)}
|
||||
|
||||
{/* Attachment / kind indicator chips + Read more toggle */}
|
||||
{hasFooter && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{kindMeta && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
{kindMeta.Icon && <kindMeta.Icon className="size-3 shrink-0" />}
|
||||
{kindMeta.label}
|
||||
</span>
|
||||
)}
|
||||
{attachments.photos > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
{attachments.photos > 1 ? `${attachments.photos} photos` : 'Photo'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.imgs > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
{attachments.imgs > 1 ? `${attachments.imgs} images` : 'Image'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.vids > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Film className="size-3" />
|
||||
{attachments.vids > 1 ? `${attachments.vids} videos` : 'Video'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.auds > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Music className="size-3" />
|
||||
{attachments.auds > 1 ? `${attachments.auds} audio files` : 'Audio'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.apps > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Blocks className="size-3" />
|
||||
App
|
||||
</span>
|
||||
)}
|
||||
{attachments.links > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<ExternalLink className="size-3" />
|
||||
{attachments.links > 1 ? `${attachments.links} links` : 'Link'}
|
||||
</span>
|
||||
)}
|
||||
{contentOverflows && (
|
||||
<button
|
||||
className="ml-auto text-xs text-primary hover:underline shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setContentExpanded((v) => !v);
|
||||
}}
|
||||
>
|
||||
{contentExpanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</EmbeddedCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Truncated content area with overflow detection. Toggle is rendered externally. */
|
||||
function EmbedTruncatedContent({ event, expanded, onOverflowChange }: {
|
||||
event: NostrEvent;
|
||||
expanded: boolean;
|
||||
onOverflowChange: (overflows: boolean) => void;
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const doesOverflow = el.scrollHeight > EMBED_MAX_HEIGHT;
|
||||
setOverflows(doesOverflow);
|
||||
onOverflowChange(doesOverflow);
|
||||
}, [onOverflowChange]);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, [measure]);
|
||||
|
||||
// Re-measure after images load
|
||||
useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const imgs = el.querySelectorAll('img');
|
||||
if (imgs.length === 0) return;
|
||||
imgs.forEach((img) => img.addEventListener('load', measure, { once: true }));
|
||||
return () => imgs.forEach((img) => img.removeEventListener('load', measure));
|
||||
}, [measure]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="relative overflow-hidden"
|
||||
style={!expanded && overflows ? { maxHeight: EMBED_MAX_HEIGHT } : undefined}
|
||||
>
|
||||
<NoteContent event={event} className="text-sm leading-relaxed" disableMediaEmbeds disableNoteEmbeds />
|
||||
{!expanded && overflows && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -489,6 +479,16 @@ function EmbeddedNoteTombstone({ eventId, relays, authorHint, className }: { eve
|
||||
);
|
||||
}
|
||||
|
||||
/** Conditionally wraps children in a ProfileHoverCard. */
|
||||
function MaybeHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
|
||||
if (disabled) return <>{children}</>;
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
{children}
|
||||
</ProfileHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('rounded-2xl border border-border overflow-hidden', className)}>
|
||||
|
||||
@@ -366,7 +366,8 @@ export function EmojiShortcodeAutocomplete({
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
data-autocomplete-dropdown
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150 pointer-events-auto"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[280px] overflow-y-auto py-1">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useExternalReactionCount,
|
||||
} from '@/hooks/useExternalReactions';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ExternalContent } from '@/lib/externalContent';
|
||||
|
||||
@@ -88,6 +89,7 @@ export function ExternalReactionButton({ content, iconSize = 'size-5', count, cl
|
||||
// Publish kind 17 reaction
|
||||
const handleReact = useCallback((emoji: string, emojiTag?: string[]) => {
|
||||
if (!user) return;
|
||||
impactLight();
|
||||
|
||||
const tags: string[][] = [
|
||||
['k', getExternalKTag(content)],
|
||||
|
||||
+73
-30
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { usePageRefresh } from '@/hooks/usePageRefresh';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { LandingHero } from '@/components/LandingHero';
|
||||
@@ -19,8 +19,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedTab } from '@/hooks/useFeedTab';
|
||||
import { useInterests } from '@/hooks/useInterests';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useTabFeed } from '@/hooks/useProfileFeed';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { useCuratedDittoFeed } from '@/hooks/useCuratedDittoFeed';
|
||||
@@ -355,11 +355,11 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a saved search feed using useStreamPosts (live streaming). */
|
||||
/** Renders a saved search feed using useTabFeed (TanStack Query cached, infinite scroll). */
|
||||
function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
// Resolve variable placeholders ($follows etc.) the same way profile tabs do
|
||||
const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter(
|
||||
@@ -368,32 +368,62 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
user?.pubkey ?? '',
|
||||
);
|
||||
|
||||
const search = typeof resolvedFilter?.search === 'string' ? resolvedFilter.search : '';
|
||||
const kindsOverride = Array.isArray(resolvedFilter?.kinds) ? resolvedFilter.kinds as number[] : undefined;
|
||||
const authorPubkeys = Array.isArray(resolvedFilter?.authors) ? resolvedFilter.authors as string[] : undefined;
|
||||
// Augment the resolved filter with protocol:nostr (NIP-50 Ditto extension)
|
||||
// to match the behavior of the core feeds and ensure latest native Nostr
|
||||
// posts are returned.
|
||||
const augmentedFilter = useMemo(() => {
|
||||
if (!resolvedFilter) return null;
|
||||
const existing = resolvedFilter.search ?? '';
|
||||
const search = existing.includes('protocol:nostr')
|
||||
? existing
|
||||
: existing
|
||||
? `${existing} protocol:nostr`
|
||||
: 'protocol:nostr';
|
||||
return { ...resolvedFilter, search };
|
||||
}, [resolvedFilter]);
|
||||
|
||||
const { posts, isLoading: isStreamLoading } = useStreamPosts(search, {
|
||||
includeReplies: true,
|
||||
mediaType: 'all',
|
||||
kindsOverride,
|
||||
authorPubkeys: authorPubkeys && authorPubkeys.length > 0 ? authorPubkeys : undefined,
|
||||
});
|
||||
const {
|
||||
data: rawData,
|
||||
isLoading: isFeedLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useTabFeed(augmentedFilter, `saved-${feed.id}`, !isResolving);
|
||||
|
||||
const isLoading = isResolving || isStreamLoading;
|
||||
const isLoading = isResolving || isFeedLoading;
|
||||
|
||||
// useStreamPosts doesn't use TanStack Query, so refresh by invalidating the
|
||||
// resolution query and letting the stream reconnect via remount.
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['resolve-tab-filter'] });
|
||||
}, [queryClient]);
|
||||
// Prefix key -- usePageRefresh does prefix matching, so this invalidates
|
||||
// the full ['tab-feed', tabKey, kindsKey, authorsKey, searchKey] used by useTabFeed.
|
||||
const queryKey = useMemo(
|
||||
() => ['tab-feed', `saved-${feed.id}`],
|
||||
[feed.id],
|
||||
);
|
||||
const handleRefresh = usePageRefresh(queryKey);
|
||||
|
||||
// Simple scroll-based load more isn't available with useStreamPosts (it's a stream),
|
||||
// but we still wire the ref for future pagination support
|
||||
// Infinite scroll: fetch next page when sentinel is in view
|
||||
useEffect(() => {
|
||||
// intentionally empty — useStreamPosts handles its own streaming
|
||||
}, [inView]);
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading && posts.length === 0) {
|
||||
// Flatten pages, deduplicate, and filter muted content
|
||||
const feedItems = useMemo(() => {
|
||||
if (!rawData?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
return rawData.pages
|
||||
.flatMap((page) => page.items)
|
||||
.filter((item) => {
|
||||
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
if (shouldHideFeedEvent(item.event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [rawData?.pages, muteItems]);
|
||||
|
||||
if (isLoading && feedItems.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
@@ -403,10 +433,10 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
if (feedItems.length === 0) {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<FeedEmptyState message={`No posts found for "${feed.label}". The search may return results as new content arrives.`} />
|
||||
<FeedEmptyState message={`No posts found for "${feed.label}". Try adjusting your relay connections or check back later.`} />
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -414,10 +444,23 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div>
|
||||
{posts.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
{feedItems.map((item) => (
|
||||
<NoteCard
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
/>
|
||||
))}
|
||||
<div ref={scrollRef} className="py-2" />
|
||||
{hasNextPage && (
|
||||
<div ref={scrollRef} className="py-4">
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!hasNextPage && <div ref={scrollRef} className="py-2" />}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { impactMedium } from '@/lib/haptics';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FollowButtonProps {
|
||||
@@ -39,9 +40,11 @@ export function FollowButton({ pubkey, className, size = 'sm' }: FollowButtonPro
|
||||
try {
|
||||
if (isFollowing) {
|
||||
await unfollow(pubkey);
|
||||
impactMedium();
|
||||
toast({ title: 'Unfollowed' });
|
||||
} else {
|
||||
await follow(pubkey);
|
||||
impactMedium();
|
||||
toast({ title: 'Followed' });
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import type { WebxdcHandle } from '@/components/Webxdc';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -28,9 +29,9 @@ type GameButton = keyof typeof KEY_MAP;
|
||||
/** Buttons that trigger haptic feedback on press. */
|
||||
const HAPTIC_BUTTONS = new Set<GameButton>(['a', 'b']);
|
||||
|
||||
/** Trigger a short vibration if the Vibration API is available. */
|
||||
/** Trigger a short vibration via the native haptic engine. */
|
||||
function haptic() {
|
||||
navigator.vibrate?.(25);
|
||||
impactLight();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -89,6 +90,7 @@ export function GameControls({ webxdcHandle, className }: GameControlsProps) {
|
||||
'flex flex-col gap-2 px-4 pb-4 pt-2 select-none touch-none',
|
||||
className,
|
||||
)}
|
||||
style={{ WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
|
||||
>
|
||||
{/* Main controls row: D-pad on left, A/B on right */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Heart,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
|
||||
import { saveNsec } from "@/lib/credentialManager";
|
||||
import { openUrl } from "@/lib/downloadFile";
|
||||
import { fetchFreshEvent } from "@/lib/fetchFreshEvent";
|
||||
import {
|
||||
type ReactNode,
|
||||
@@ -255,7 +257,7 @@ function SetupQuestionnaire({
|
||||
isSignup?: boolean;
|
||||
}) {
|
||||
const { nostr } = useNostr();
|
||||
const { updateConfig } = useAppContext();
|
||||
const { config, updateConfig } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const { updateSettings } = useEncryptedSettings();
|
||||
const login = useLoginActions();
|
||||
@@ -300,6 +302,18 @@ function SetupQuestionnaire({
|
||||
// Continue handler for the download step — saves the key via the best
|
||||
// available method (native credential manager on iOS/Android, file download
|
||||
// on web), logs in, and advances to the next step.
|
||||
//
|
||||
// If the user dismisses the iOS credential prompt, `saveNsec` resolves to
|
||||
// `'dismissed'` and we still advance — dismissal is a legitimate choice
|
||||
// (e.g. the user is saving the key in their own password manager).
|
||||
//
|
||||
// On Android, if no credential provider is available (e.g. GrapheneOS or
|
||||
// other de-Googled devices), `saveNsec` falls back to writing the key to
|
||||
// the app's Documents folder and returns `'saved-to-file'`. We surface a
|
||||
// toast so the user knows where to find the backup file.
|
||||
//
|
||||
// Only unexpected errors (decode failure, filesystem write failure)
|
||||
// surface as a destructive toast.
|
||||
const handleDownloadContinue = useCallback(async () => {
|
||||
try {
|
||||
const decoded = nip19.decode(nsec);
|
||||
@@ -308,7 +322,15 @@ function SetupQuestionnaire({
|
||||
const pubkey = getPublicKey(decoded.data);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
await saveNsec(npub, nsec);
|
||||
const result = await saveNsec(npub, nsec, config.appName);
|
||||
|
||||
if (result === "saved-to-file") {
|
||||
toast({
|
||||
title: "Secret key saved",
|
||||
description:
|
||||
"Your secret key was saved to the Documents folder on your device.",
|
||||
});
|
||||
}
|
||||
|
||||
login.nsec(nsec);
|
||||
next();
|
||||
@@ -320,7 +342,7 @@ function SetupQuestionnaire({
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [nsec, login, next]);
|
||||
}, [nsec, login, next, config.appName]);
|
||||
|
||||
// Save settings and transition to the follows step (or outro if they have follows)
|
||||
const handleSaveAndContinue = useCallback(async () => {
|
||||
@@ -496,7 +518,7 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
|
||||
Create your account
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed max-w-xs mx-auto">
|
||||
Your identity on Nostr is a cryptographic key pair. We'll generate one
|
||||
Your identity on Nostr is a cryptographic key. We'll generate one
|
||||
for you now.
|
||||
</p>
|
||||
</div>
|
||||
@@ -506,7 +528,7 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
|
||||
className="w-full max-w-xs gap-2 rounded-full h-12"
|
||||
onClick={onGenerate}
|
||||
>
|
||||
Generate my keys
|
||||
Generate my key
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -518,18 +540,34 @@ function DownloadStep({
|
||||
onContinue,
|
||||
}: {
|
||||
nsec: string;
|
||||
onContinue: () => void;
|
||||
onContinue: () => Promise<void> | void;
|
||||
}) {
|
||||
const { config } = useAppContext();
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Wrap the continue handler in an in-flight guard so rapid double-taps
|
||||
// don't trigger multiple credential prompts. `finally` guarantees the
|
||||
// button is re-enabled even if the handler throws, so users can never
|
||||
// get stuck on a disabled button.
|
||||
const handleClick = async () => {
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onContinue();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold tracking-tight">
|
||||
Save your secret key
|
||||
Your secret key
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is your only way to access your account. Keep it somewhere safe.
|
||||
This secret key controls your account on {config.appName}. You'll need it to log in later. Without it, you'll lose your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -538,6 +576,8 @@ function DownloadStep({
|
||||
type={showKey ? "text" : "password"}
|
||||
value={nsec}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
className="pr-10 font-mono text-base md:text-sm"
|
||||
/>
|
||||
<Button
|
||||
@@ -555,23 +595,39 @@ function DownloadStep({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-xs font-semibold text-amber-800 dark:text-amber-200 mb-1">
|
||||
Important
|
||||
</p>
|
||||
<p className="text-xs text-amber-900 dark:text-amber-300">
|
||||
This key is your only means of accessing your account. If you lose it,
|
||||
there is no way to recover it.
|
||||
</p>
|
||||
</div>
|
||||
{showKey && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<p className="text-xs text-amber-900 dark:text-amber-300">
|
||||
NEVER share your secret key with anyone. Avoid screenshotting your key or pasting it anywhere except a password manager. If shared, others will be able to access your account.{" "}
|
||||
<a
|
||||
href="https://soapbox.pub/blog/managing-nostr-keys/"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openUrl("https://soapbox.pub/blog/managing-nostr-keys/");
|
||||
}}
|
||||
className="underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full gap-2 rounded-full h-12"
|
||||
onClick={onContinue}
|
||||
onClick={handleClick}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Continue
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" /> Save Key
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Suspense, useState, useMemo, useCallback, useRef, lazy } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { LeftSidebar } from '@/components/LeftSidebar';
|
||||
import { MobileTopBar } from '@/components/MobileTopBar';
|
||||
@@ -12,6 +12,8 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const WidgetSidebar = lazy(() => import('@/components/WidgetSidebar').then((m) => ({ default: m.WidgetSidebar })));
|
||||
|
||||
/** Skeleton shown in the content area while a lazy page chunk is loading. */
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
@@ -104,8 +106,8 @@ function MainLayoutInner() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right sidebar — render page-provided sidebar, or an empty placeholder to preserve layout width */}
|
||||
{rightSidebar ?? <div className="w-[300px] shrink-0 hidden xl:block" />}
|
||||
{/* Right sidebar — render page-provided sidebar, or the widget sidebar */}
|
||||
{rightSidebar ?? <Suspense fallback={<div className="w-[300px] shrink-0 hidden xl:block" />}><WidgetSidebar /></Suspense>}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -262,7 +262,8 @@ export function MentionAutocomplete({
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
data-autocomplete-dropdown
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150 pointer-events-auto"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[240px] overflow-y-auto py-1">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, Home, Search, User } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { selectionChanged } from '@/lib/haptics';
|
||||
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
@@ -21,6 +23,7 @@ const hiddenStyle: React.CSSProperties = {
|
||||
|
||||
export function MobileBottomNav() {
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { user, metadata } = useCurrentUser();
|
||||
const hasUnread = useHasUnreadNotifications();
|
||||
const { scrollContainer, noArcs } = useLayoutSnapshot();
|
||||
@@ -37,6 +40,7 @@ export function MobileBottomNav() {
|
||||
|
||||
const handleSearchClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
selectionChanged();
|
||||
setSearchOpen((v) => !v);
|
||||
}, []);
|
||||
|
||||
@@ -65,7 +69,16 @@ export function MobileBottomNav() {
|
||||
{/* Home */}
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setSearchOpen(false)}
|
||||
onClick={() => {
|
||||
selectionChanged();
|
||||
setSearchOpen(false);
|
||||
// When already on the home page, scroll to top and refresh the feed
|
||||
if (location.pathname === '/' || location.pathname === homePath) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
void queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['ditto-curated-feed'] });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
(location.pathname === '/' || location.pathname === homePath) ? 'text-primary' : 'text-muted-foreground',
|
||||
@@ -91,7 +104,7 @@ export function MobileBottomNav() {
|
||||
{user && (
|
||||
<Link
|
||||
to="/notifications"
|
||||
onClick={() => setSearchOpen(false)}
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
location.pathname === '/notifications' ? 'text-primary' : 'text-muted-foreground',
|
||||
@@ -111,7 +124,7 @@ export function MobileBottomNav() {
|
||||
{user ? (
|
||||
<Link
|
||||
to={profileUrl}
|
||||
onClick={() => setSearchOpen(false)}
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
isOnProfile ? 'text-primary' : 'text-muted-foreground',
|
||||
|
||||
@@ -58,7 +58,6 @@ import { LiveStreamPlayer } from "@/components/LiveStreamPlayer";
|
||||
import { MagicDeckContent } from "@/components/MagicDeckContent";
|
||||
import { Nip05Badge } from "@/components/Nip05Badge";
|
||||
import { NoteContent } from "@/components/NoteContent";
|
||||
import { NoteMedia } from "@/components/NoteMedia";
|
||||
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
|
||||
import { PatchCard } from "@/components/PatchCard";
|
||||
import { PollContent } from "@/components/PollContent";
|
||||
@@ -97,12 +96,11 @@ import { extractZapAmount, extractZapSender, extractZapMessage } from "@/hooks/u
|
||||
import { getContentWarning } from "@/lib/contentWarning";
|
||||
import { genUserName } from "@/lib/genUserName";
|
||||
import { getDisplayName } from "@/lib/getDisplayName";
|
||||
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
|
||||
import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls";
|
||||
import { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
|
||||
import { getParentEventHints, isReplyEvent } from "@/lib/nostrEvents";
|
||||
import { isSingleImagePost } from "@/lib/noteContent";
|
||||
import { shareOrCopy } from "@/lib/share";
|
||||
import { impactLight } from "@/lib/haptics";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { formatNumber } from "@/lib/formatNumber";
|
||||
import { publishedAtAction } from "@/lib/publishedAtAction";
|
||||
@@ -469,35 +467,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isProfile &&
|
||||
!isBlobbiState;
|
||||
|
||||
// Kind 1 specific — images now render inline in NoteContent, only videos go to NoteMedia
|
||||
const videos = useMemo(
|
||||
() => (isTextNote ? extractVideoUrls(event.content) : []),
|
||||
[event.content, isTextNote],
|
||||
);
|
||||
const audios = useMemo(() => {
|
||||
if (!isTextNote) return [];
|
||||
// Prefer imeta-declared audio over URL scraping
|
||||
const imetaAudios = Array.from(parseImetaMap(event.tags).values())
|
||||
.filter((e) => e.mime?.startsWith("audio/"))
|
||||
.map((e) => e.url);
|
||||
if (imetaAudios.length > 0) return imetaAudios;
|
||||
return extractAudioUrls(event.content);
|
||||
}, [event.content, event.tags, isTextNote]);
|
||||
const imetaMap = useMemo(
|
||||
() =>
|
||||
isTextNote ? parseImetaMap(event.tags) : new Map<string, ImetaEntry>(),
|
||||
[event.tags, isTextNote],
|
||||
);
|
||||
|
||||
// Extract webxdc attachments from imeta tags
|
||||
const webxdcApps = useMemo(() => {
|
||||
if (!isTextNote) return [];
|
||||
return Array.from(imetaMap.values()).filter(
|
||||
(entry) =>
|
||||
entry.mime === "application/x-webxdc" ||
|
||||
entry.mime === "application/vnd.webxdc+zip",
|
||||
);
|
||||
}, [imetaMap, isTextNote]);
|
||||
const isComment = event.kind === 1111;
|
||||
const isReply = isTextNote && !isComment && isReplyEvent(event);
|
||||
|
||||
@@ -695,10 +664,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
) : (
|
||||
<TruncatedNoteContent
|
||||
event={event}
|
||||
videos={videos}
|
||||
audios={audios}
|
||||
imetaMap={imetaMap}
|
||||
webxdcApps={webxdcApps}
|
||||
/>
|
||||
)}
|
||||
</ContentWarningGuard>
|
||||
@@ -836,6 +801,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
title="Share"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
impactLight();
|
||||
const url = `${window.location.origin}/${encodedId}`;
|
||||
const result = await shareOrCopy(url);
|
||||
if (result === "copied") toast({ title: "Link copied to clipboard" });
|
||||
@@ -1168,19 +1134,11 @@ export const NoteCard = memo(function NoteCard({
|
||||
const MAX_HEIGHT = 400; // px — posts taller than this get truncated
|
||||
|
||||
/** Truncates long text note content with a "Read more" fade + button.
|
||||
* Media attachments are also hidden behind the truncation and revealed on expand. */
|
||||
* Media attachments render inline within NoteContent at their original content position. */
|
||||
function TruncatedNoteContent({
|
||||
event,
|
||||
videos,
|
||||
audios = [],
|
||||
imetaMap,
|
||||
webxdcApps = [],
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
videos: string[];
|
||||
audios?: string[];
|
||||
imetaMap: Map<string, ImetaEntry>;
|
||||
webxdcApps?: ImetaEntry[];
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
@@ -1212,8 +1170,6 @@ function TruncatedNoteContent({
|
||||
imgs.forEach((img) => img.removeEventListener("load", measure));
|
||||
}, [measure]);
|
||||
|
||||
const showMedia = !overflows || expanded;
|
||||
|
||||
return (
|
||||
<div className="mt-2 break-words overflow-hidden">
|
||||
<div
|
||||
@@ -1241,15 +1197,6 @@ function TruncatedNoteContent({
|
||||
{expanded ? "Show less" : "Read more"}
|
||||
</button>
|
||||
)}
|
||||
{showMedia && (
|
||||
<NoteMedia
|
||||
videos={videos}
|
||||
audios={audios}
|
||||
imetaMap={imetaMap}
|
||||
webxdcApps={webxdcApps}
|
||||
event={event}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+126
-20
@@ -4,11 +4,16 @@ import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
|
||||
import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
import { AudioVisualizer } from '@/components/AudioVisualizer';
|
||||
import { WebxdcEmbed } from '@/components/WebxdcEmbed';
|
||||
import { Lightbox, ImageGallery } from '@/components/ImageGallery';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { EmojifiedText, CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
@@ -17,6 +22,8 @@ import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
|
||||
import { COUNTRIES } from '@/lib/countries';
|
||||
import { IMAGE_URL_REGEX, EMBED_MEDIA_URL_REGEX } from '@/lib/mediaUrls';
|
||||
import { parseImetaMap } from '@/lib/imeta';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AddrCoords } from '@/hooks/useEvent';
|
||||
|
||||
@@ -27,6 +34,13 @@ interface NoteContentProps {
|
||||
disableEmbeds?: boolean;
|
||||
/** When true, hides thumbnail images in link preview cards (useful when a cover image is already shown). */
|
||||
hideEmbedImages?: boolean;
|
||||
/** When true, nested nostr:nevent/note/naddr embeds render as inline links instead of cards.
|
||||
* Used inside embedded quote cards to prevent unbounded recursive nesting. */
|
||||
disableNoteEmbeds?: boolean;
|
||||
/** When true, images, galleries, and video/audio players are suppressed (rendered as
|
||||
* whitespace) while link preview cards and other non-media embeds are preserved.
|
||||
* Used inside embedded quote cards to keep them lightweight. */
|
||||
disableMediaEmbeds?: boolean;
|
||||
}
|
||||
|
||||
/** Regex matching `:shortcode:` patterns in text. */
|
||||
@@ -170,6 +184,7 @@ type ContentToken =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'image-embed'; url: string }
|
||||
| { type: 'image-gallery'; urls: string[] }
|
||||
| { type: 'media-embed'; url: string }
|
||||
| { type: 'link-embed'; url: string }
|
||||
| { type: 'inline-link'; url: string }
|
||||
| { type: 'mention'; pubkey: string }
|
||||
@@ -233,6 +248,8 @@ export function NoteContent({
|
||||
className,
|
||||
disableEmbeds = false,
|
||||
hideEmbedImages = false,
|
||||
disableNoteEmbeds = false,
|
||||
disableMediaEmbeds = false,
|
||||
}: NoteContentProps) {
|
||||
const tokens = useMemo(() => {
|
||||
const text = event.content;
|
||||
@@ -302,7 +319,7 @@ export function NoteContent({
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip non-image media URLs — rendered as embedded media by the parent.
|
||||
// Non-image media URLs (video, audio, webxdc) — render inline at their position.
|
||||
if (EMBED_MEDIA_URL_REGEX.test(url)) {
|
||||
if (result.length > 0) {
|
||||
const prev = result[result.length - 1];
|
||||
@@ -310,8 +327,9 @@ export function NoteContent({
|
||||
prev.value = prev.value.replace(/\s+$/, '');
|
||||
}
|
||||
}
|
||||
result.push({ type: 'media-embed', url });
|
||||
lastIndex = index + fullMatch.length;
|
||||
// Also strip leading whitespace that follows the skipped URL
|
||||
// Strip leading whitespace that follows the media URL
|
||||
const remaining = text.substring(lastIndex);
|
||||
const leadingWs = remaining.match(/^\s+/);
|
||||
if (leadingWs) {
|
||||
@@ -411,11 +429,42 @@ export function NoteContent({
|
||||
}
|
||||
}
|
||||
|
||||
// Append media-embed tokens for imeta-declared media URLs not found in the content.
|
||||
// Some clients attach audio/video/webxdc via imeta tags without including the URL in
|
||||
// the content string. Without this, those attachments would be silently dropped.
|
||||
// Only scan for text note kinds — other kinds (DMs, calendar events, etc.) may use
|
||||
// imeta tags for different purposes.
|
||||
const TEXT_NOTE_KINDS = new Set([1, 11, 1111]);
|
||||
if (TEXT_NOTE_KINDS.has(event.kind)) {
|
||||
const contentMediaUrls = new Set(
|
||||
result.filter((t): t is { type: 'media-embed'; url: string } => t.type === 'media-embed').map((t) => t.url),
|
||||
);
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== 'imeta') continue;
|
||||
let rawUrl: string | undefined;
|
||||
let mime: string | undefined;
|
||||
for (let j = 1; j < tag.length; j++) {
|
||||
const sp = tag[j].indexOf(' ');
|
||||
if (sp === -1) continue;
|
||||
const key = tag[j].slice(0, sp);
|
||||
if (key === 'url') rawUrl = tag[j].slice(sp + 1);
|
||||
else if (key === 'm') mime = tag[j].slice(sp + 1);
|
||||
}
|
||||
const url = sanitizeUrl(rawUrl);
|
||||
if (!url || contentMediaUrls.has(url)) continue;
|
||||
const isEmbeddableMedia = mime?.startsWith('audio/') || mime?.startsWith('video/')
|
||||
|| mime === 'application/x-webxdc' || mime === 'application/vnd.webxdc+zip';
|
||||
if (isEmbeddableMedia) {
|
||||
result.push({ type: 'media-embed', url });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse excessive whitespace around block-level tokens (link-preview, youtube-embed)
|
||||
// Preserve formatting but prevent too much stacking with the card's own spacing.
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const token = result[i];
|
||||
const isBlock = token.type === 'image-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|
||||
const isBlock = token.type === 'image-embed' || token.type === 'media-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|
||||
|| (token.type === 'naddr-embed' && !token.url) || token.type === 'lightning-invoice';
|
||||
|
||||
if (isBlock) {
|
||||
@@ -469,21 +518,17 @@ export function NoteContent({
|
||||
return map;
|
||||
}, [event.tags, viewerEmojis]);
|
||||
|
||||
// Parse imeta tags for dim/blurhash to pass to ImageGallery
|
||||
const imetaMap = useMemo(() => {
|
||||
const map = new Map<string, { dim?: string; blurhash?: string }>();
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== 'imeta') continue;
|
||||
const parts: Record<string, string> = {};
|
||||
for (let i = 1; i < tag.length; i++) {
|
||||
const p = tag[i];
|
||||
const sp = p.indexOf(' ');
|
||||
if (sp !== -1) parts[p.slice(0, sp)] = p.slice(sp + 1);
|
||||
}
|
||||
if (parts.url) map.set(parts.url, { dim: parts.dim, blurhash: parts.blurhash });
|
||||
}
|
||||
return map;
|
||||
}, [event.tags]);
|
||||
// Parse imeta tags — used by ImageGallery (dim/blurhash) and inline media embeds
|
||||
const imetaMap = useMemo(() => parseImetaMap(event.tags), [event.tags]);
|
||||
|
||||
// Only fetch author data when there are media embeds that need it (video artist, audio avatar)
|
||||
const hasMedia = tokens.some((t) => t.type === 'media-embed');
|
||||
const author = useAuthor(hasMedia ? event.pubkey : undefined);
|
||||
const authorMetadata = author.data?.metadata;
|
||||
const authorDisplayName = useMemo(
|
||||
() => getDisplayName(authorMetadata, event.pubkey) ?? genUserName(event.pubkey),
|
||||
[authorMetadata, event.pubkey],
|
||||
);
|
||||
|
||||
// Group consecutive image-embed tokens (≥2) into image-gallery tokens
|
||||
const groupedTokens = useMemo(() => {
|
||||
@@ -554,7 +599,7 @@ export function NoteContent({
|
||||
case 'text':
|
||||
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 object-contain align-text-bottom' : undefined))}</span>;
|
||||
case 'image-embed': {
|
||||
if (disableEmbeds) {
|
||||
if (disableEmbeds || disableMediaEmbeds) {
|
||||
// In preview contexts (e.g. triple-dot menu), replace image URLs
|
||||
// with a newline so text flow is preserved without showing raw URLs.
|
||||
return <span key={i}>{'\n'}</span>;
|
||||
@@ -569,7 +614,7 @@ export function NoteContent({
|
||||
);
|
||||
}
|
||||
case 'image-gallery': {
|
||||
if (disableEmbeds) {
|
||||
if (disableEmbeds || disableMediaEmbeds) {
|
||||
return <span key={i}>{token.urls.map(() => '\n').join('')}</span>;
|
||||
}
|
||||
const galleryStartIndex = tokenImageIndex.get(i) ?? 0;
|
||||
@@ -621,9 +666,70 @@ export function NoteContent({
|
||||
{token.url}
|
||||
</a>
|
||||
);
|
||||
case 'media-embed': {
|
||||
if (disableEmbeds || disableMediaEmbeds) {
|
||||
return <span key={i}>{'\n'}</span>;
|
||||
}
|
||||
const imeta = imetaMap.get(token.url);
|
||||
const mime = imeta?.mime ?? '';
|
||||
const isWebxdc = mime === 'application/x-webxdc' || mime === 'application/vnd.webxdc+zip' || token.url.endsWith('.xdc');
|
||||
const isAudio = mime.startsWith('audio/') || /\.(mp3|wav|ogg|flac|m4a|aac|opus)(\?[^\s]*)?$/i.test(token.url);
|
||||
if (isWebxdc && imeta) {
|
||||
return <WebxdcEmbed key={i} url={token.url} uuid={imeta.webxdc} name={imeta.summary} icon={imeta.thumbnail} />;
|
||||
}
|
||||
if (isAudio) {
|
||||
return (
|
||||
<AudioVisualizer
|
||||
key={i}
|
||||
src={token.url}
|
||||
mime={imeta?.mime}
|
||||
avatarUrl={authorMetadata?.picture}
|
||||
avatarFallback={authorDisplayName[0]?.toUpperCase() ?? '?'}
|
||||
avatarShape={getAvatarShape(authorMetadata)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Default: video
|
||||
return (
|
||||
<VideoPlayer
|
||||
key={i}
|
||||
src={token.url}
|
||||
poster={imeta?.thumbnail}
|
||||
dim={imeta?.dim}
|
||||
blurhash={imeta?.blurhash}
|
||||
artist={authorDisplayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'nevent-embed':
|
||||
if (disableNoteEmbeds) {
|
||||
const neventId = nip19.neventEncode({ id: token.eventId, ...(token.author ? { author: token.author } : {}), ...(token.relays?.length ? { relays: token.relays } : {}) });
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
to={`/${neventId}`}
|
||||
className="text-primary hover:underline break-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{neventId.slice(0, 16)}…
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <EmbeddedNote key={i} eventId={token.eventId} relays={token.relays} authorHint={token.author} className="my-2.5" />;
|
||||
case 'naddr-embed':
|
||||
if (disableNoteEmbeds) {
|
||||
const naddrId = nip19.naddrEncode({ kind: token.addr.kind, pubkey: token.addr.pubkey, identifier: token.addr.identifier });
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
to={`/${naddrId}`}
|
||||
className="text-primary hover:underline break-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{naddrId.slice(0, 16)}…
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span key={i}>
|
||||
{token.url && (
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
import { AudioVisualizer } from '@/components/AudioVisualizer';
|
||||
import { WebxdcEmbed } from '@/components/WebxdcEmbed';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import type { ImetaEntry } from '@/lib/imeta';
|
||||
|
||||
/** Media content for kind 1 text notes — renders videos, audio, and webxdc apps. */
|
||||
export function NoteMedia({
|
||||
videos,
|
||||
audios = [],
|
||||
imetaMap,
|
||||
webxdcApps = [],
|
||||
event,
|
||||
}: {
|
||||
videos: string[];
|
||||
audios?: string[];
|
||||
imetaMap: Map<string, ImetaEntry>;
|
||||
webxdcApps?: ImetaEntry[];
|
||||
event: NostrEvent;
|
||||
}) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey) ?? genUserName(event.pubkey);
|
||||
|
||||
if (videos.length === 0 && audios.length === 0 && webxdcApps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Videos — each rendered with play/pause overlay */}
|
||||
{videos.map((url, i) => (
|
||||
<VideoPlayer key={`v-${i}`} src={url} poster={imetaMap.get(url)?.thumbnail} dim={imetaMap.get(url)?.dim} blurhash={imetaMap.get(url)?.blurhash} artist={displayName} />
|
||||
))}
|
||||
|
||||
{/* Audio — rendered as visualizer with avatar */}
|
||||
{audios.map((url, i) => {
|
||||
const mime = imetaMap.get(url)?.mime;
|
||||
return (
|
||||
<AudioVisualizer
|
||||
key={`a-${i}`}
|
||||
src={url}
|
||||
mime={mime}
|
||||
avatarUrl={metadata?.picture}
|
||||
avatarFallback={displayName[0]?.toUpperCase() ?? '?'}
|
||||
avatarShape={getAvatarShape(metadata)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Webxdc apps */}
|
||||
{webxdcApps.map((app) => (
|
||||
<WebxdcEmbed key={app.url} url={app.url} uuid={app.webxdc} name={app.summary} icon={app.thumbnail} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -343,6 +344,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
|
||||
};
|
||||
|
||||
const handleBookmark = () => {
|
||||
impactLight();
|
||||
toggleBookmark.mutate(event.id);
|
||||
close();
|
||||
};
|
||||
@@ -359,6 +361,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
|
||||
};
|
||||
|
||||
const handleTogglePin = () => {
|
||||
impactLight();
|
||||
togglePin.mutate(event.id, {
|
||||
onSuccess: () => {
|
||||
toast({ title: pinned ? 'Unpinned from profile' : 'Pinned to profile' });
|
||||
@@ -371,6 +374,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
|
||||
};
|
||||
|
||||
const handleMuteConversation = () => {
|
||||
impactLight();
|
||||
const rootTag = event.tags.find(([name, , , marker]) => name === 'e' && marker === 'root');
|
||||
const threadId = rootTag?.[1] ?? event.id;
|
||||
addMute.mutate(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useProfileBadges } from '@/hooks/useProfileBadges';
|
||||
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
|
||||
import { BadgeShowcaseGrid } from '@/components/BadgeShowcaseGrid';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Shared classes for all editable fields — static muted bg when idle, border on hover/focus */
|
||||
const editableBase = [
|
||||
@@ -129,6 +130,9 @@ export function ProfileCard({
|
||||
const initial = displayName[0]?.toUpperCase() ?? '?';
|
||||
const patch = (key: keyof NostrMetadata) => (v: string) => onChange?.({ [key]: v });
|
||||
|
||||
// Sanitize banner URL from untrusted metadata before CSS url() interpolation
|
||||
const bannerUrl = sanitizeUrl(metadata.banner);
|
||||
|
||||
// Read shape from metadata (it's a custom property passed through the loose schema)
|
||||
const rawShape = metadata.shape;
|
||||
const shape: AvatarShape | undefined = isValidAvatarShape(rawShape) ? rawShape : undefined;
|
||||
@@ -187,8 +191,8 @@ export function ProfileCard({
|
||||
<div
|
||||
className={cn('relative h-36 bg-secondary', editable && 'cursor-pointer group')}
|
||||
style={
|
||||
metadata.banner
|
||||
? { backgroundImage: `url(${metadata.banner})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
bannerUrl
|
||||
? { backgroundImage: `url("${bannerUrl}")`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: undefined
|
||||
}
|
||||
onClick={() => editable && onPickImage?.('banner')}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useEmojiUsage } from '@/hooks/useEmojiUsage';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
interface ProfileReactionButtonProps {
|
||||
@@ -33,6 +34,7 @@ export function ProfileReactionButton({ profileEvent, className }: ProfileReacti
|
||||
|
||||
const handleReact = useCallback((emoji: string, emojiTag?: string[]) => {
|
||||
if (!user) return;
|
||||
impactLight();
|
||||
|
||||
trackEmojiUsage(emoji);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { impactMedium } from '@/lib/haptics';
|
||||
|
||||
const THRESHOLD = 80; // raw px before triggering
|
||||
const MAX_PULL = 120; // max visual distance (after damping)
|
||||
@@ -74,6 +75,7 @@ export function PullToRefresh({ onRefresh, children, className }: PullToRefreshP
|
||||
const reached = currentPull.current >= THRESHOLD * RESISTANCE;
|
||||
|
||||
if (reached) {
|
||||
impactMedium();
|
||||
busy.current = true;
|
||||
currentPull.current = 40;
|
||||
setPullDistance(40);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useEmojiUsage } from '@/hooks/useEmojiUsage';
|
||||
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
import type { ResolvedEmoji } from '@/lib/customEmoji';
|
||||
|
||||
@@ -81,6 +82,7 @@ export function QuickReactMenu({
|
||||
/** Publish a reaction with a native Unicode emoji string. */
|
||||
const publishReaction = useCallback((emoji: string, emojiTag?: [string, string, string]) => {
|
||||
if (!user) return;
|
||||
impactLight();
|
||||
|
||||
// Close the entire popover
|
||||
onClose?.();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useUserReaction } from '@/hooks/useUserReaction';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
|
||||
@@ -138,6 +139,7 @@ export function ReactionButton({
|
||||
e.stopPropagation();
|
||||
if (!user) return;
|
||||
if (hasReacted) {
|
||||
impactLight();
|
||||
handleUnreact(e);
|
||||
return;
|
||||
}
|
||||
@@ -148,6 +150,7 @@ export function ReactionButton({
|
||||
e.stopPropagation();
|
||||
if (!user) return;
|
||||
if (hasReacted) return;
|
||||
impactLight();
|
||||
setMenuOpen(false);
|
||||
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', eventId]);
|
||||
queryClient.setQueryData(['user-reaction', eventId], { content: '❤️' });
|
||||
|
||||
@@ -86,6 +86,15 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
|
||||
const handleInteractOutside = useCallback((e: Event) => {
|
||||
if (isNestedDialogInteraction(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// The emoji/mention autocomplete dropdowns are portaled to document.body
|
||||
// (outside the Dialog DOM tree) to escape overflow clipping. Clicks on
|
||||
// them fire as "interact outside" the dialog — prevent dismissal so the
|
||||
// user can select an emoji or mention with the mouse.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[data-autocomplete-dropdown]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [isNestedDialogInteraction]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Quote, Undo2 } from 'lucide-react';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { useState } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
@@ -37,6 +38,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
toast({ title: 'Please log in to repost', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
impactLight();
|
||||
|
||||
// Optimistically update stats cache immediately
|
||||
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', event.id]);
|
||||
@@ -98,6 +100,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
|
||||
const handleUnrepost = () => {
|
||||
if (!user || !repostEventId) return;
|
||||
impactLight();
|
||||
|
||||
// Optimistically update stats cache
|
||||
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', event.id]);
|
||||
|
||||
@@ -36,61 +36,9 @@ function useIsXl(): boolean {
|
||||
return isXl;
|
||||
}
|
||||
|
||||
const SPARK_W = 50;
|
||||
const SPARK_H = 28;
|
||||
const SPARK_MARGIN = 2;
|
||||
const SPARK_DIVISOR = 0.25;
|
||||
|
||||
/** Maps an array of values to {x, y} SVG coordinates, filling the full chart area. */
|
||||
function dataToPoints(data: number[]): { x: number; y: number }[] {
|
||||
const len = data.length;
|
||||
if (len === 0) return [];
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const vfactor = (SPARK_H - SPARK_MARGIN * 2) / ((max - min) || 2);
|
||||
const hfactor = (SPARK_W - SPARK_MARGIN * 2) / ((len > 1 ? len - 1 : 1));
|
||||
return data.map((d, i) => ({
|
||||
x: i * hfactor + SPARK_MARGIN,
|
||||
y: (max === min ? 1 : (max - d)) * vfactor + SPARK_MARGIN,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Builds a smooth cubic-bezier SVG path string from {x,y} points. */
|
||||
function pointsToCurvePath(points: { x: number; y: number }[]): string {
|
||||
if (points.length === 0) return '';
|
||||
const cmds: (string | number)[] = [];
|
||||
let prev: { x: number; y: number } | undefined;
|
||||
for (const p of points) {
|
||||
if (!prev) {
|
||||
cmds.push(p.x, p.y);
|
||||
} else {
|
||||
const len = (p.x - prev.x) * SPARK_DIVISOR;
|
||||
cmds.push('C', prev.x + len, prev.y, p.x - len, p.y, p.x, p.y);
|
||||
}
|
||||
prev = p;
|
||||
}
|
||||
return 'M' + cmds.join(' ');
|
||||
}
|
||||
|
||||
/** Small sparkline SVG using a smooth cubic-bezier curve. */
|
||||
export function TrendSparkline({ data }: { data: number[] }) {
|
||||
const d = useMemo(() => pointsToCurvePath(dataToPoints(data)), [data]);
|
||||
|
||||
if (!d) return null;
|
||||
|
||||
return (
|
||||
<svg width={SPARK_W} height={SPARK_H} viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} className="text-primary/60">
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
// Re-export TrendSparkline from its dedicated module for backwards compatibility.
|
||||
import { TrendSparkline } from '@/components/TrendSparkline';
|
||||
export { TrendSparkline };
|
||||
|
||||
export function RightSidebar() {
|
||||
const isXl = useIsXl();
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SandboxFrameProps
|
||||
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, 'src' | 'id'> {
|
||||
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, 'src' | 'id' | 'sandbox'> {
|
||||
/** HMAC-derived subdomain identifier. */
|
||||
id: string;
|
||||
/**
|
||||
@@ -324,6 +324,20 @@ const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${origin}/`}
|
||||
// Defense-in-depth on top of the cross-origin subdomain isolation.
|
||||
// - allow-scripts + allow-same-origin: required for apps to run JS and
|
||||
// use origin-keyed storage (localStorage, IndexedDB) and to register
|
||||
// the iframe.diy Service Worker that proxies fetches. Because the
|
||||
// iframe lives on a distinct HMAC-derived subdomain, it is still a
|
||||
// different origin from the parent app.
|
||||
// - allow-forms / allow-modals / allow-popups(+escape-sandbox) /
|
||||
// allow-downloads: normal web-app affordances (form submission,
|
||||
// alert/confirm/prompt, opening links in new tabs, exporting files)
|
||||
// that webxdc/nsite content may legitimately rely on.
|
||||
// Notably omitted: allow-top-navigation (prevents window.top.location
|
||||
// phishing redirects) and allow-pointer-lock / allow-presentation /
|
||||
// allow-orientation-lock (unused niche capabilities).
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-downloads"
|
||||
{...iframeProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation, useNavigationType } from 'react-router-dom';
|
||||
|
||||
export function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
// Only scroll to top on PUSH navigation (user clicked a link).
|
||||
// On POP (back/forward), let the browser restore scroll position naturally.
|
||||
if (navigationType === 'PUSH') {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}, [pathname, navigationType]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -52,6 +52,10 @@ export function TabButton({ label, active, onClick, disabled, className, childre
|
||||
const handleMouseLeave = () => onHover(null);
|
||||
|
||||
const handleClick = () => {
|
||||
// Clear hover highlight immediately — on mobile, mouseleave never fires
|
||||
// after a tap, so the hover arc would otherwise stay visible.
|
||||
onHover(null);
|
||||
|
||||
if (active) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const SPARK_W = 50;
|
||||
const SPARK_H = 28;
|
||||
const SPARK_MARGIN = 2;
|
||||
const SPARK_DIVISOR = 0.25;
|
||||
|
||||
/** Maps an array of values to {x, y} SVG coordinates, filling the full chart area. */
|
||||
function dataToPoints(data: number[]): { x: number; y: number }[] {
|
||||
const len = data.length;
|
||||
if (len === 0) return [];
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const vfactor = (SPARK_H - SPARK_MARGIN * 2) / ((max - min) || 2);
|
||||
const hfactor = (SPARK_W - SPARK_MARGIN * 2) / ((len > 1 ? len - 1 : 1));
|
||||
return data.map((d, i) => ({
|
||||
x: i * hfactor + SPARK_MARGIN,
|
||||
y: (max === min ? 1 : (max - d)) * vfactor + SPARK_MARGIN,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Builds a smooth cubic-bezier SVG path string from {x,y} points. */
|
||||
function pointsToCurvePath(points: { x: number; y: number }[]): string {
|
||||
if (points.length === 0) return '';
|
||||
const cmds: (string | number)[] = [];
|
||||
let prev: { x: number; y: number } | undefined;
|
||||
for (const p of points) {
|
||||
if (!prev) {
|
||||
cmds.push(p.x, p.y);
|
||||
} else {
|
||||
const len = (p.x - prev.x) * SPARK_DIVISOR;
|
||||
cmds.push('C', prev.x + len, prev.y, p.x - len, p.y, p.x, p.y);
|
||||
}
|
||||
prev = p;
|
||||
}
|
||||
return 'M' + cmds.join(' ');
|
||||
}
|
||||
|
||||
/** Small sparkline SVG using a smooth cubic-bezier curve. */
|
||||
export function TrendSparkline({ data }: { data: number[] }) {
|
||||
const d = useMemo(() => pointsToCurvePath(dataToPoints(data)), [data]);
|
||||
|
||||
if (!d) return null;
|
||||
|
||||
return (
|
||||
<svg width={SPARK_W} height={SPARK_H} viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} className="text-primary/60">
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { GripVertical, X } from 'lucide-react';
|
||||
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WidgetDefinition } from '@/lib/sidebarWidgets';
|
||||
import type { WidgetConfig } from '@/contexts/AppContext';
|
||||
|
||||
interface WidgetCardProps {
|
||||
definition: WidgetDefinition;
|
||||
config: WidgetConfig;
|
||||
onRemove: () => void;
|
||||
onHeightChange: (height: number) => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: Record<string, unknown>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Wrapper for each widget in the sidebar — header, height control. */
|
||||
export function WidgetCard({
|
||||
definition,
|
||||
config,
|
||||
onRemove,
|
||||
onHeightChange,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
children,
|
||||
}: WidgetCardProps) {
|
||||
const configHeight = config.height ?? definition.defaultHeight;
|
||||
const Icon = definition.icon;
|
||||
|
||||
// Local height for smooth resize — only commits to config on pointer up.
|
||||
const [liveHeight, setLiveHeight] = useState(configHeight);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const liveHeightRef = useRef(liveHeight);
|
||||
|
||||
// Sync local height when config changes externally (e.g. cross-device sync).
|
||||
useEffect(() => {
|
||||
if (!resizing) {
|
||||
setLiveHeight(configHeight);
|
||||
}
|
||||
}, [configHeight, resizing]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
setResizing(true);
|
||||
const startY = e.clientY;
|
||||
const startHeight = liveHeightRef.current;
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const newHeight = Math.max(
|
||||
definition.minHeight,
|
||||
Math.min(definition.maxHeight, startHeight + (ev.clientY - startY)),
|
||||
);
|
||||
liveHeightRef.current = newHeight;
|
||||
setLiveHeight(newHeight);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
setResizing(false);
|
||||
onHeightChange(liveHeightRef.current);
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', onMove);
|
||||
document.addEventListener('pointerup', onUp);
|
||||
}, [definition.minHeight, definition.maxHeight, onHeightChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background/85 rounded-xl overflow-hidden transition-shadow',
|
||||
isDragging && 'shadow-lg ring-1 ring-primary/20',
|
||||
resizing && 'select-none',
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2">
|
||||
{/* Icon + label */}
|
||||
{definition.href ? (
|
||||
<Link to={definition.href} className="flex items-center gap-1.5 flex-1 min-w-0 hover:text-primary transition-colors">
|
||||
<Icon className="size-5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xl font-semibold truncate">{definition.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Icon className="size-5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xl font-semibold flex-1 truncate">{definition.label}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-0.5 rounded text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove widget"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
className="p-0.5 rounded text-muted-foreground/50 hover:text-muted-foreground cursor-grab active:cursor-grabbing transition-colors"
|
||||
{...dragHandleProps}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{definition.fillHeight ? (
|
||||
<div style={{ height: liveHeight }} className={cn('p-2', !resizing && 'transition-[height] duration-200')}>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea style={{ maxHeight: liveHeight }} className={cn(!resizing && 'transition-[max-height] duration-200')}>
|
||||
<div className="p-2">
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
onPointerDown={handleResizeStart}
|
||||
className="h-1.5 cursor-ns-resize flex items-center justify-center hover:bg-secondary/60 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Check, Plus } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { WIDGET_DEFINITIONS, WIDGET_CATEGORIES } from '@/lib/sidebarWidgets';
|
||||
import type { WidgetConfig } from '@/contexts/AppContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface WidgetPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentWidgets: WidgetConfig[];
|
||||
onAdd: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Dialog for adding/removing widgets from the sidebar. */
|
||||
export function WidgetPickerDialog({ open, onOpenChange, currentWidgets, onAdd, onRemove }: WidgetPickerDialogProps) {
|
||||
const activeIds = useMemo(() => new Set(currentWidgets.map((w) => w.id)), [currentWidgets]);
|
||||
|
||||
// Group widgets by category
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, typeof WIDGET_DEFINITIONS> = {};
|
||||
for (const w of WIDGET_DEFINITIONS) {
|
||||
(groups[w.category] ??= []).push(w);
|
||||
}
|
||||
return groups;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Widget</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="space-y-5 pr-2">
|
||||
{Object.entries(grouped).map(([category, widgets]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||
{WIDGET_CATEGORIES[category] ?? category}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{widgets.map((widget) => {
|
||||
const isActive = activeIds.has(widget.id);
|
||||
const Icon = widget.icon;
|
||||
return (
|
||||
<button
|
||||
key={widget.id}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onRemove(widget.id);
|
||||
} else {
|
||||
onAdd(widget.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-3 w-full px-3 py-2.5 rounded-xl transition-colors text-left',
|
||||
isActive
|
||||
? 'bg-primary/10 hover:bg-primary/15'
|
||||
: 'hover:bg-secondary/60',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'size-9 rounded-lg flex items-center justify-center shrink-0',
|
||||
isActive ? 'bg-primary/20 text-primary' : 'bg-secondary text-muted-foreground',
|
||||
)}>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{widget.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{widget.description}</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'size-6 rounded-full flex items-center justify-center shrink-0 transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'border border-border text-muted-foreground/50',
|
||||
)}>
|
||||
{isActive ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useMemo, useState, lazy, Suspense, memo } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { WidgetCard } from '@/components/WidgetCard';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { LinkFooter } from '@/components/LinkFooter';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getWidgetDefinition } from '@/lib/sidebarWidgets';
|
||||
import type { WidgetConfig } from '@/contexts/AppContext';
|
||||
import type { WidgetDefinition } from '@/lib/sidebarWidgets';
|
||||
|
||||
// ── Lazy-loaded widget components ────────────────────────────────────────────
|
||||
|
||||
const TrendingWidget = lazy(() => import('@/components/widgets/TrendingWidget').then((m) => ({ default: m.TrendingWidget })));
|
||||
const HotPostsWidget = lazy(() => import('@/components/widgets/HotPostsWidget').then((m) => ({ default: m.HotPostsWidget })));
|
||||
const BlobbiWidget = lazy(() => import('@/components/widgets/BlobbiWidget').then((m) => ({ default: m.BlobbiWidget })));
|
||||
const StatusWidget = lazy(() => import('@/components/widgets/StatusWidget').then((m) => ({ default: m.StatusWidget })));
|
||||
const AIChatWidget = lazy(() => import('@/components/widgets/AIChatWidget').then((m) => ({ default: m.AIChatWidget })));
|
||||
const WikipediaWidget = lazy(() => import('@/components/widgets/WikipediaWidget').then((m) => ({ default: m.WikipediaWidget })));
|
||||
const BlueskyWidget = lazy(() => import('@/components/widgets/BlueskyWidget').then((m) => ({ default: m.BlueskyWidget })));
|
||||
const PhotoWidget = lazy(() => import('@/components/widgets/PhotoWidget').then((m) => ({ default: m.PhotoWidget })));
|
||||
const MusicWidget = lazy(() => import('@/components/widgets/MusicWidget').then((m) => ({ default: m.MusicWidget })));
|
||||
const FeedWidget = lazy(() => import('@/components/widgets/FeedWidget').then((m) => ({ default: m.FeedWidget })));
|
||||
|
||||
const WidgetPickerDialog = lazy(() => import('@/components/WidgetPickerDialog').then((m) => ({ default: m.WidgetPickerDialog })));
|
||||
|
||||
// ── Widget content resolver ──────────────────────────────────────────────────
|
||||
|
||||
function WidgetContent({ id }: { id: string }) {
|
||||
switch (id) {
|
||||
case 'trends':
|
||||
return <TrendingWidget />;
|
||||
case 'hot-posts':
|
||||
return <HotPostsWidget />;
|
||||
case 'blobbi':
|
||||
return <BlobbiWidget />;
|
||||
case 'status':
|
||||
return <StatusWidget />;
|
||||
case 'ai-chat':
|
||||
return <AIChatWidget />;
|
||||
case 'wikipedia':
|
||||
return <WikipediaWidget />;
|
||||
case 'bluesky':
|
||||
return <BlueskyWidget />;
|
||||
case 'feed:photos':
|
||||
return <PhotoWidget />;
|
||||
case 'feed:music':
|
||||
return <MusicWidget />;
|
||||
case 'feed:articles':
|
||||
return <FeedWidget kinds={[30023]} feedPath="/articles" feedLabel="View all articles" />;
|
||||
case 'feed:events':
|
||||
return <FeedWidget kinds={[31922, 31923]} feedPath="/events" feedLabel="View all events" />;
|
||||
|
||||
default:
|
||||
return <p className="text-xs text-muted-foreground p-1">Unknown widget.</p>;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback while a widget component is loading. */
|
||||
function WidgetSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-4/5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact fallback shown when a widget crashes. */
|
||||
function WidgetErrorFallback({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-4 px-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">{name} failed to load.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sortable widget wrapper ──────────────────────────────────────────────────
|
||||
|
||||
interface SortableWidgetProps {
|
||||
config: WidgetConfig;
|
||||
definition: WidgetDefinition;
|
||||
onRemove: (id: string) => void;
|
||||
onHeightChange: (id: string, height: number) => void;
|
||||
}
|
||||
|
||||
const SortableWidget = memo(function SortableWidget({ config, definition, onRemove, onHeightChange }: SortableWidgetProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: config.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<WidgetCard
|
||||
definition={definition}
|
||||
config={config}
|
||||
onRemove={() => onRemove(config.id)}
|
||||
onHeightChange={(h) => onHeightChange(config.id, h)}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={listeners}
|
||||
>
|
||||
<ErrorBoundary fallback={<WidgetErrorFallback name={definition.label} />} reportToSentry>
|
||||
<Suspense fallback={<WidgetSkeleton />}>
|
||||
<WidgetContent id={config.id} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</WidgetCard>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Main sidebar ─────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_WIDGETS: WidgetConfig[] = [];
|
||||
|
||||
export function WidgetSidebar() {
|
||||
const { config, updateConfig } = useAppContext();
|
||||
const widgets = config.sidebarWidgets ?? EMPTY_WIDGETS;
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// Filter out widgets with unknown definitions
|
||||
const validWidgets = useMemo(
|
||||
() => widgets.filter((w) => getWidgetDefinition(w.id)),
|
||||
[widgets],
|
||||
);
|
||||
|
||||
const updateWidgets = useCallback((updater: (current: WidgetConfig[]) => WidgetConfig[]) => {
|
||||
updateConfig((c) => ({
|
||||
...c,
|
||||
sidebarWidgets: updater(c.sidebarWidgets ?? widgets),
|
||||
}));
|
||||
}, [updateConfig, widgets]);
|
||||
|
||||
const removeWidget = useCallback((id: string) => {
|
||||
updateWidgets((ws) => ws.filter((w) => w.id !== id));
|
||||
}, [updateWidgets]);
|
||||
|
||||
const changeHeight = useCallback((id: string, height: number) => {
|
||||
updateWidgets((ws) => ws.map((w) => w.id === id ? { ...w, height } : w));
|
||||
}, [updateWidgets]);
|
||||
|
||||
const addWidget = useCallback((id: string) => {
|
||||
updateWidgets((ws) => {
|
||||
if (ws.some((w) => w.id === id)) return ws;
|
||||
return [...ws, { id }];
|
||||
});
|
||||
}, [updateWidgets]);
|
||||
|
||||
// Drag-and-drop
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const sortableIds = useMemo(() => validWidgets.map((w) => w.id), [validWidgets]);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
updateWidgets((ws) => {
|
||||
const oldIndex = ws.findIndex((w) => w.id === active.id);
|
||||
const newIndex = ws.findIndex((w) => w.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return ws;
|
||||
return arrayMove(ws, oldIndex, newIndex);
|
||||
});
|
||||
}, [updateWidgets]);
|
||||
|
||||
return (
|
||||
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-2">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2 flex-1">
|
||||
{validWidgets.map((w) => {
|
||||
const def = getWidgetDefinition(w.id);
|
||||
if (!def) return null;
|
||||
return (
|
||||
<SortableWidget
|
||||
key={w.id}
|
||||
config={w}
|
||||
definition={def}
|
||||
onRemove={removeWidget}
|
||||
onHeightChange={changeHeight}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add widget button */}
|
||||
<button
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 w-full py-2.5 rounded-xl bg-background/85 text-muted-foreground hover:text-foreground hover:bg-background transition-colors text-xs"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add widget
|
||||
</button>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<div className="mt-3">
|
||||
<LinkFooter />
|
||||
</div>
|
||||
|
||||
{/* Widget picker dialog */}
|
||||
<Suspense fallback={null}>
|
||||
{pickerOpen && (
|
||||
<WidgetPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
currentWidgets={widgets}
|
||||
onAdd={addWidget}
|
||||
onRemove={removeWidget}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, forwardRef } from 'react';
|
||||
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, X, Smile } from 'lucide-react';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { impactMedium } from '@/lib/haptics';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -368,6 +369,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
}, [open, setInvoice]);
|
||||
|
||||
const handleZap = () => {
|
||||
impactMedium();
|
||||
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
|
||||
zap(finalAmount, comment);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { FONT_OPTIONS, LETTER_KIND, LINE_HEIGHT_RATIO, type Letter } from '@/lib/letterTypes';
|
||||
import { ensureLetterFonts } from '@/lib/letterUtils';
|
||||
import { sanitizeCssString } from '@/lib/fontLoader';
|
||||
import { StationeryBackground } from './StationeryBackground';
|
||||
import { useStationeryColors } from '@/hooks/useStationeryColors';
|
||||
import { LetterStickers } from './LetterStickers';
|
||||
@@ -98,7 +99,10 @@ export function LetterCard({ letter, mode }: LetterCardProps) {
|
||||
const timeAgo = formatDistanceToNow(new Date(letter.timestamp * 1000), { addSuffix: true });
|
||||
|
||||
const { text: textColor, faint: faintColor, line: lineColor } = useStationeryColors(effectiveStationery);
|
||||
const rawFont = effectiveStationery?.fontFamily;
|
||||
// Sanitize event-sourced font family before CSS interpolation (M-6).
|
||||
const rawFont = effectiveStationery?.fontFamily
|
||||
? sanitizeCssString(effectiveStationery.fontFamily)
|
||||
: undefined;
|
||||
const letterFontFamily = rawFont
|
||||
? (rawFont.includes(',') ? rawFont : `${rawFont}, ${FONT_OPTIONS[0].family}`)
|
||||
: FONT_OPTIONS[0].family;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FONT_OPTIONS, LINE_HEIGHT_RATIO, COLOR_MOMENT_KIND, THEME_KIND, resolve
|
||||
import { hexLuminance, backgroundTextColor } from '@/lib/colorUtils';
|
||||
import { ColorPaletteDisplay, type PaletteLayout } from './ColorPaletteDisplay';
|
||||
import { ensureLetterFonts } from '@/lib/letterUtils';
|
||||
import { sanitizeCssString } from '@/lib/fontLoader';
|
||||
import { StationeryBackground } from './StationeryBackground';
|
||||
import { useStationeryColors } from '@/hooks/useStationeryColors';
|
||||
import { LetterStickers } from './LetterStickers';
|
||||
@@ -204,7 +205,10 @@ export function LetterDetailSheet({ letter, onClose, onReply }: LetterDetailShee
|
||||
const effectiveFrameTint = effectiveStationery?.frameTint;
|
||||
|
||||
const { text: textColor, faint: faintColor, line: lineColor } = useStationeryColors(effectiveStationery);
|
||||
const rawFont = effectiveStationery?.fontFamily;
|
||||
// Sanitize event-sourced font family before CSS interpolation (M-6).
|
||||
const rawFont = effectiveStationery?.fontFamily
|
||||
? sanitizeCssString(effectiveStationery.fontFamily)
|
||||
: undefined;
|
||||
const letterFontFamily = rawFont
|
||||
? (rawFont.includes(',') ? rawFont : `${rawFont}, ${FONT_OPTIONS[0].family}`)
|
||||
: FONT_OPTIONS[0].family;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { useId, useRef, useEffect, useLayoutEffect, useCallback, useState, useMemo } from 'react';
|
||||
import { hexToRgb, rgbToHex, darkenHex, blendHex } from '@/lib/colorUtils';
|
||||
import { impactMedium } from '@/lib/haptics';
|
||||
import { useEnvelopeDimensions } from '@/hooks/useEnvelopeDimensions';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -101,8 +102,8 @@ function generateParticles(count: number, primaryHex: string): ConfettiParticle[
|
||||
}));
|
||||
}
|
||||
|
||||
function haptic(pattern: number | number[] = 30) {
|
||||
try { navigator?.vibrate?.(pattern); } catch { /* unsupported */ }
|
||||
function haptic() {
|
||||
impactMedium();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -182,7 +183,7 @@ export function SendAnimation({
|
||||
|
||||
if (impactT > 0 && !sealHapticFired.current) {
|
||||
sealHapticFired.current = true;
|
||||
haptic([15, 30, 50]);
|
||||
haptic();
|
||||
}
|
||||
|
||||
// Fly
|
||||
|
||||
@@ -199,7 +199,7 @@ export function StationeryBackground({
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url(${s.imageUrl})`,
|
||||
backgroundImage: `url("${s.imageUrl}")`,
|
||||
backgroundSize: s.imageMode === 'tile' ? 'auto' : 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: s.imageMode === 'tile' ? 'repeat' : 'no-repeat',
|
||||
@@ -243,7 +243,7 @@ function ThemeMockup({ stationery }: { stationery: Stationery }) {
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url(${s.imageUrl})`,
|
||||
backgroundImage: `url("${s.imageUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
opacity: 0.35,
|
||||
@@ -312,7 +312,7 @@ export function StationeryPreview({
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url(${s.imageUrl})`,
|
||||
backgroundImage: `url("${s.imageUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
|
||||
@@ -2,16 +2,21 @@ import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { selectionChanged } from "@/lib/haptics"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
>(({ className, onCheckedChange, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
selectionChanged();
|
||||
onCheckedChange?.(checked);
|
||||
}}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Send, Bot } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { DorkThinking } from '@/components/DorkThinking';
|
||||
import { useShakespeare, type ChatMessage } from '@/hooks/useShakespeare';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Module-level cache so conversation survives collapse/expand (which unmounts
|
||||
* the component). Keyed by user pubkey. Intentionally not persisted to
|
||||
* localStorage — sidebar chat is ephemeral.
|
||||
*/
|
||||
const conversationCache = new Map<string, ChatMessage[]>();
|
||||
|
||||
/** Compact AI chat widget for the sidebar. */
|
||||
export function AIChatWidget() {
|
||||
const { user } = useCurrentUser();
|
||||
const { sendStreamingMessage, getAvailableModels, isLoading, isAuthenticated } = useShakespeare();
|
||||
|
||||
// Fetch available models and select the cheapest as default
|
||||
const { data: defaultModelId } = useQuery({
|
||||
queryKey: ['shakespeare-default-model'],
|
||||
queryFn: async () => {
|
||||
const response = await getAvailableModels();
|
||||
const sorted = response.data.sort((a, b) => {
|
||||
const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion);
|
||||
const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion);
|
||||
return costA - costB;
|
||||
});
|
||||
return sorted[0]?.id ?? '';
|
||||
},
|
||||
staleTime: 10 * 60_000,
|
||||
enabled: !!user,
|
||||
});
|
||||
const cacheKey = user?.pubkey ?? '';
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => conversationCache.get(cacheKey) ?? []);
|
||||
const [input, setInput] = useState('');
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
|
||||
// Write back to cache whenever messages change.
|
||||
useEffect(() => {
|
||||
if (cacheKey) {
|
||||
conversationCache.set(cacheKey, messages);
|
||||
}
|
||||
}, [messages, cacheKey]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||||
if (viewport) {
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent, scrollToBottom]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
const userMessage: ChatMessage = { role: 'user', content: text };
|
||||
const newMessages = [...messages, userMessage];
|
||||
setMessages(newMessages);
|
||||
setInput('');
|
||||
setStreamingContent('');
|
||||
|
||||
try {
|
||||
let accumulated = '';
|
||||
await sendStreamingMessage(
|
||||
newMessages,
|
||||
defaultModelId || 'shakespeare',
|
||||
(chunk) => {
|
||||
accumulated += chunk;
|
||||
setStreamingContent(accumulated);
|
||||
},
|
||||
);
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: accumulated }]);
|
||||
setStreamingContent('');
|
||||
} catch {
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }]);
|
||||
setStreamingContent('');
|
||||
}
|
||||
}, [input, isLoading, messages, sendStreamingMessage, defaultModelId]);
|
||||
|
||||
if (!user || !isAuthenticated) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-4 px-2 text-center">
|
||||
<Bot className="size-8 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">Log in to chat with AI</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Messages area */}
|
||||
<ScrollArea ref={scrollRef} className="flex-1 min-h-0">
|
||||
<div className="space-y-3 p-2">
|
||||
{messages.length === 0 && !streamingContent && (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Bot className="size-6 text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">Ask me anything...</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} message={msg} />
|
||||
))}
|
||||
{streamingContent && (
|
||||
<MessageBubble message={{ role: 'assistant', content: streamingContent }} />
|
||||
)}
|
||||
{isLoading && !streamingContent && (
|
||||
<div className="flex gap-2 items-start">
|
||||
<div className="bg-secondary rounded-xl rounded-tl-sm px-3 py-2">
|
||||
<DorkThinking className="text-[10px]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-border p-2 space-y-1.5">
|
||||
<div className="flex items-end gap-1.5">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Message..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none text-sm bg-secondary/50 rounded-lg px-2.5 py-1.5 border-0 outline-none focus:ring-1 focus:ring-primary/30 placeholder:text-muted-foreground/60 min-h-[32px] max-h-[80px]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading || !defaultModelId}
|
||||
className="shrink-0 p-1.5 rounded-lg text-primary hover:bg-primary/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
const isUser = message.role === 'user';
|
||||
const content = typeof message.content === 'string' ? message.content : message.content.map((c) => c.text ?? '').join('');
|
||||
|
||||
return (
|
||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[85%] rounded-xl px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap break-words',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground rounded-br-sm'
|
||||
: 'bg-secondary text-foreground rounded-bl-sm',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Egg, Footprints, Loader2 } from 'lucide-react';
|
||||
|
||||
import { BlobbiAwayState } from '@/blobbi/ui/BlobbiAwayState';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { StatIndicator } from '@/blobbi/ui/StatIndicator';
|
||||
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
|
||||
import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
|
||||
import { useBlobbisCollection } from '@/blobbi/core/hooks/useBlobbisCollection';
|
||||
import { useBlobbiCompanionData } from '@/blobbi/companion/hooks/useBlobbiCompanionData';
|
||||
import { useBlobbiMigration } from '@/blobbi/core/hooks/useBlobbiMigration';
|
||||
import { useBlobbiUseInventoryItem } from '@/blobbi/actions/hooks/useBlobbiUseInventoryItem';
|
||||
import { isActionVisibleForStage, type InventoryAction, type BlobbiAction } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { KIND_BLOBBI_STATE, KIND_BLOBBONAUT_PROFILE, updateBlobbiTags, updateBlobbonautTags } from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiStats } from '@/blobbi/core/types/blobbi';
|
||||
|
||||
/** Stat-to-action mapping: each stat has an associated quick action + default item. */
|
||||
const STAT_ACTION_MAP: Record<string, { itemId: string; action: InventoryAction } | 'sleep'> = {
|
||||
hunger: { itemId: 'food_apple', action: 'feed' },
|
||||
happiness: { itemId: 'toy_ball', action: 'play' },
|
||||
health: { itemId: 'med_vitamins', action: 'medicine' },
|
||||
hygiene: { itemId: 'hyg_soap', action: 'clean' },
|
||||
energy: 'sleep',
|
||||
};
|
||||
|
||||
/** Stat-to-color mapping matching the BlobbiPage convention. */
|
||||
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
|
||||
hunger: 'orange',
|
||||
happiness: 'yellow',
|
||||
health: 'green',
|
||||
hygiene: 'blue',
|
||||
energy: 'violet',
|
||||
};
|
||||
|
||||
/** Blobbi action name for stage visibility checks. */
|
||||
const STAT_ACTION_NAME: Record<string, BlobbiAction> = {
|
||||
hunger: 'feed',
|
||||
happiness: 'play',
|
||||
health: 'medicine',
|
||||
hygiene: 'clean',
|
||||
};
|
||||
|
||||
/** localStorage key helper matching BlobbiPage pattern. */
|
||||
function getSelectedBlobbiKey(pubkey: string): string {
|
||||
return `blobbi:selected:d:${pubkey.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/** Mini Blobbi widget with live stats and quick actions. */
|
||||
export function BlobbiWidget() {
|
||||
const { user } = useCurrentUser();
|
||||
const { companions, companionsByD, isLoading, updateCompanionEvent } = useBlobbisCollection();
|
||||
const { profile, updateProfileEvent, invalidate: invalidateProfile } = useBlobbonautProfile();
|
||||
const { ensureCanonicalBlobbiBeforeAction } = useBlobbiMigration();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
// Match BlobbiPage's selection logic: localStorage > profile.has > first companion
|
||||
const localStorageKey = user?.pubkey ? getSelectedBlobbiKey(user.pubkey) : 'blobbi:selected:d:none';
|
||||
const [storedSelectedD, setStoredSelectedD] = useLocalStorage<string | null>(localStorageKey, null);
|
||||
|
||||
const companion = useMemo<BlobbiCompanion | null>(() => {
|
||||
if (!companions || companions.length === 0) return null;
|
||||
if (storedSelectedD && companionsByD[storedSelectedD]) return companionsByD[storedSelectedD];
|
||||
if (profile) {
|
||||
for (const d of profile.has) {
|
||||
if (companionsByD[d]) return companionsByD[d];
|
||||
}
|
||||
}
|
||||
return companions[0];
|
||||
}, [companions, companionsByD, storedSelectedD, profile]);
|
||||
|
||||
// Zero-arg wrapper for ensureCanonical (same pattern as BlobbiPage)
|
||||
const ensureCanonicalBeforeAction = useCallback(async () => {
|
||||
if (!companion || !profile) return null;
|
||||
return ensureCanonicalBlobbiBeforeAction({
|
||||
companion,
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
updateStoredSelectedD: setStoredSelectedD,
|
||||
});
|
||||
}, [companion, profile, ensureCanonicalBlobbiBeforeAction, updateProfileEvent, updateCompanionEvent, setStoredSelectedD]);
|
||||
|
||||
// Wire up item action hook
|
||||
const { mutateAsync: executeUseItem, isPending: isUsingItem } = useBlobbiUseInventoryItem({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
updateProfileEvent,
|
||||
});
|
||||
|
||||
// Sleep/wake handler (same pattern as BlobbiPage)
|
||||
const [isSleepPending, setIsSleepPending] = useState(false);
|
||||
const handleRest = useCallback(async () => {
|
||||
if (!user?.pubkey || !companion) return;
|
||||
const isCurrentlySleeping = companion.state === 'sleeping';
|
||||
const newState = isCurrentlySleeping ? 'active' : 'sleeping';
|
||||
setIsSleepPending(true);
|
||||
try {
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
const nowStr = now.toString();
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
const newTags = updateBlobbiTags(canonical.allTags, {
|
||||
state: newState,
|
||||
hunger: decayResult.stats.hunger.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
energy: decayResult.stats.energy.toString(),
|
||||
...streakUpdates,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const prev = canonical.companion.event;
|
||||
const event = await publishEvent({ kind: KIND_BLOBBI_STATE, content: canonical.content, tags: newTags, prev });
|
||||
updateCompanionEvent(event);
|
||||
toast({ title: isCurrentlySleeping ? 'Woke up!' : 'Resting...' });
|
||||
} catch {
|
||||
toast({ title: 'Action failed', variant: 'destructive' });
|
||||
} finally {
|
||||
setIsSleepPending(false);
|
||||
}
|
||||
}, [user?.pubkey, companion, ensureCanonicalBeforeAction, publishEvent, updateCompanionEvent]);
|
||||
|
||||
// Companion toggle handler (same logic as BlobbiPage)
|
||||
const [isUpdatingCompanion, setIsUpdatingCompanion] = useState(false);
|
||||
const isCurrentCompanion = companion ? profile?.currentCompanion === companion.d : false;
|
||||
const { companion: activeCompanion } = useBlobbiCompanionData();
|
||||
const isActiveFloatingCompanion = companion ? activeCompanion?.d === companion.d : false;
|
||||
|
||||
const handleSetAsCompanion = useCallback(async () => {
|
||||
if (!profile || !companion) return;
|
||||
setIsUpdatingCompanion(true);
|
||||
try {
|
||||
// Fetch fresh profile data from relays to avoid stale-read-then-write
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) return;
|
||||
|
||||
let updatedTags: string[][];
|
||||
if (isCurrentCompanion) {
|
||||
updatedTags = updateBlobbonautTags(canonical.profileAllTags, {})
|
||||
.filter(tag => tag[0] !== 'current_companion');
|
||||
} else {
|
||||
const tagsWithoutCompanion = canonical.profileAllTags.filter(tag => tag[0] !== 'current_companion');
|
||||
updatedTags = updateBlobbonautTags(tagsWithoutCompanion, {
|
||||
current_companion: companion.d,
|
||||
});
|
||||
}
|
||||
const prev = canonical.profileEvent;
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
prev,
|
||||
});
|
||||
updateProfileEvent(event);
|
||||
invalidateProfile();
|
||||
toast({
|
||||
title: isCurrentCompanion ? 'Companion unset' : 'Companion set!',
|
||||
description: isCurrentCompanion
|
||||
? `${companion.name} is no longer your companion`
|
||||
: `${companion.name} is now your companion`,
|
||||
});
|
||||
} catch {
|
||||
toast({ title: 'Failed to update companion', variant: 'destructive' });
|
||||
} finally {
|
||||
setIsUpdatingCompanion(false);
|
||||
}
|
||||
}, [profile, companion, isCurrentCompanion, ensureCanonicalBeforeAction, publishEvent, updateProfileEvent, invalidateProfile]);
|
||||
|
||||
const isActionPending = isUsingItem || isSleepPending;
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Link to="/blobbi" className="flex flex-col items-center gap-2 py-4 hover:bg-secondary/40 rounded-lg transition-colors">
|
||||
<div className="size-16 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||
<Egg className="size-8 text-primary" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Log in to hatch your Blobbi</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<Skeleton className="size-24 rounded-full" />
|
||||
<div className="flex items-center justify-center gap-1.5 pt-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="size-9 rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
return (
|
||||
<Link to="/blobbi" className="flex flex-col items-center gap-2 py-4 hover:bg-secondary/40 rounded-lg transition-colors">
|
||||
<div className="size-16 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||
<Egg className="size-8 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-primary">Hatch your Blobbi</span>
|
||||
<span className="text-xs text-muted-foreground">Get your virtual pet companion</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlobbiWidgetContent
|
||||
companion={companion}
|
||||
onUseItem={executeUseItem}
|
||||
onRest={handleRest}
|
||||
isActionPending={isActionPending}
|
||||
isCurrentCompanion={isCurrentCompanion}
|
||||
isActiveFloatingCompanion={isActiveFloatingCompanion}
|
||||
isUpdatingCompanion={isUpdatingCompanion}
|
||||
onToggleCompanion={handleSetAsCompanion}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface BlobbiWidgetContentProps {
|
||||
companion: BlobbiCompanion;
|
||||
onUseItem: (req: { itemId: string; action: InventoryAction }) => Promise<unknown>;
|
||||
onRest: () => Promise<void>;
|
||||
isActionPending: boolean;
|
||||
isCurrentCompanion: boolean;
|
||||
isActiveFloatingCompanion: boolean;
|
||||
isUpdatingCompanion: boolean;
|
||||
onToggleCompanion: () => Promise<void>;
|
||||
}
|
||||
|
||||
function BlobbiWidgetContent({ companion, onUseItem, onRest, isActionPending, isCurrentCompanion, isActiveFloatingCompanion, isUpdatingCompanion, onToggleCompanion }: BlobbiWidgetContentProps) {
|
||||
const projected = useProjectedBlobbiState(companion);
|
||||
const defaultStats: BlobbiStats = { hunger: 100, happiness: 100, health: 100, hygiene: 100, energy: 100 };
|
||||
const stats = projected?.stats ?? defaultStats;
|
||||
const { recipe, recipeLabel } = useStatusReaction({
|
||||
stats,
|
||||
enabled: companion.state !== 'sleeping',
|
||||
});
|
||||
|
||||
const stage = companion.stage;
|
||||
|
||||
// Get the stat keys relevant for this stage (eggs see fewer stats)
|
||||
const relevantStats = getVisibleStats(stage);
|
||||
|
||||
// Filter stats by stage-appropriate actions
|
||||
const visibleStats = relevantStats.filter((stat) => {
|
||||
// Energy maps to sleep, which eggs can't do
|
||||
if (stat === 'energy') return stage !== 'egg';
|
||||
const actionName = STAT_ACTION_NAME[stat];
|
||||
if (!actionName) return false;
|
||||
return isActionVisibleForStage(stage, actionName);
|
||||
});
|
||||
|
||||
const handleStatClick = useCallback(async (stat: keyof BlobbiStats) => {
|
||||
const mapping = STAT_ACTION_MAP[stat];
|
||||
if (!mapping) return;
|
||||
if (mapping === 'sleep') {
|
||||
await onRest();
|
||||
} else {
|
||||
try {
|
||||
await onUseItem(mapping);
|
||||
} catch {
|
||||
// Error already toasted by the mutation hook
|
||||
}
|
||||
}
|
||||
}, [onUseItem, onRest]);
|
||||
|
||||
// When this Blobbi is the active floating companion, show "out exploring" state
|
||||
if (isActiveFloatingCompanion) {
|
||||
return (
|
||||
<BlobbiAwayState
|
||||
name={companion.name}
|
||||
size="sm"
|
||||
isUpdating={isUpdatingCompanion}
|
||||
onBringHome={onToggleCompanion}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center gap-3 py-3">
|
||||
{/* Take along button — top right */}
|
||||
<button
|
||||
onClick={onToggleCompanion}
|
||||
disabled={isUpdatingCompanion || isActionPending}
|
||||
className={cn(
|
||||
'absolute top-2 right-1 size-7 rounded-full flex items-center justify-center transition-colors',
|
||||
isCurrentCompanion
|
||||
? 'text-emerald-500 bg-emerald-500/10 hover:bg-emerald-500/20'
|
||||
: 'text-violet-500 bg-violet-500/10 hover:bg-violet-500/20',
|
||||
(isUpdatingCompanion || isActionPending) && 'opacity-40 pointer-events-none',
|
||||
)}
|
||||
title={isCurrentCompanion ? 'With you' : 'Take along'}
|
||||
>
|
||||
{isUpdatingCompanion
|
||||
? <Loader2 className="size-3.5 animate-spin" />
|
||||
: <Footprints className="size-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* Pet visual — links to full page */}
|
||||
<Link to="/blobbi" className="relative hover:scale-105 transition-transform">
|
||||
<BlobbiStageVisual
|
||||
companion={companion}
|
||||
size="lg"
|
||||
animated
|
||||
lookMode="follow-pointer"
|
||||
recipe={recipe}
|
||||
recipeLabel={recipeLabel}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Name */}
|
||||
<Link to="/blobbi" className="text-sm font-semibold hover:text-primary transition-colors">
|
||||
{companion.name}
|
||||
</Link>
|
||||
|
||||
{/* Unified stat wheels — each is both a status indicator and an action button */}
|
||||
<div className="flex items-center justify-center gap-1.5 px-2">
|
||||
{visibleStats.map((stat) => (
|
||||
<StatIndicator
|
||||
key={stat}
|
||||
stat={stat}
|
||||
value={stats[stat]}
|
||||
color={STAT_COLOR_MAP[stat]}
|
||||
status={getStatStatus(stage, stat, stats[stat] ?? 100)}
|
||||
size="sm"
|
||||
onClick={() => handleStatClick(stat)}
|
||||
disabled={isActionPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MessageCircle, Repeat2, Heart } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { BlueskyPost, GetFeedResponse } from '@/hooks/useBlueskyTrending';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
|
||||
const BSKY_PUBLIC_API = 'https://api.bsky.app/xrpc';
|
||||
const DISCOVER_FEED_URI = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot';
|
||||
const WIDGET_LIMIT = 5;
|
||||
|
||||
/**
|
||||
* Dedicated query for the widget — fetches a single page of 5 posts.
|
||||
* Uses a separate query key from the full BlueskyPage infinite query
|
||||
* so they don't share cached pages.
|
||||
*/
|
||||
function useBlueskyWidgetPosts() {
|
||||
return useQuery({
|
||||
queryKey: ['bluesky-widget'],
|
||||
queryFn: async ({ signal }) => {
|
||||
const params = new URLSearchParams({
|
||||
feed: DISCOVER_FEED_URI,
|
||||
limit: String(WIDGET_LIMIT),
|
||||
});
|
||||
const res = await fetch(`${BSKY_PUBLIC_API}/app.bsky.feed.getFeed?${params}`, {
|
||||
signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Bluesky API error: ${res.status}`);
|
||||
const data: GetFeedResponse = await res.json();
|
||||
if (!data.feed) return [];
|
||||
return data.feed.map((item) => item.post);
|
||||
},
|
||||
staleTime: 15 * 60_000, // 15 minutes
|
||||
gcTime: 60 * 60_000, // 1 hour
|
||||
retry: 2,
|
||||
});
|
||||
}
|
||||
|
||||
/** Bluesky trending posts widget for the sidebar. */
|
||||
export function BlueskyWidget() {
|
||||
const { data: posts, isLoading, isError } = useBlueskyWidgetPosts();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <p className="text-sm text-muted-foreground p-1">Failed to load Bluesky posts.</p>;
|
||||
}
|
||||
|
||||
if (!posts || posts.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No trending posts right now.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{posts.map((post) => (
|
||||
<BlueskyPostCard key={post.cid} post={post} />
|
||||
))}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to="/bluesky" className="text-xs text-primary hover:underline">View more on Bluesky</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlueskyPostCard({ post }: { post: BlueskyPost }) {
|
||||
const text = post.record.text;
|
||||
const snippet = text.length > 120 ? text.slice(0, 120) + '...' : text;
|
||||
const webUrl = `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`;
|
||||
const internalUrl = `/i/${encodeURIComponent(webUrl)}`;
|
||||
|
||||
const timeAgo = useMemo(() => {
|
||||
const diff = Date.now() - new Date(post.indexedAt).getTime();
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}, [post.indexedAt]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={internalUrl}
|
||||
className="block hover:bg-secondary/40 px-2 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
{/* Author line */}
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
{post.author.avatar ? (
|
||||
<img src={post.author.avatar} alt="" className="size-4 rounded-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="size-4 rounded-full bg-sky-500 flex items-center justify-center text-white text-[8px] font-bold">
|
||||
{(post.author.displayName ?? post.author.handle).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs font-semibold truncate">{post.author.displayName ?? post.author.handle}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo}</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2 mb-1">{snippet}</p>
|
||||
|
||||
{/* Engagement */}
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-0.5"><MessageCircle className="size-2.5" />{formatNumber(post.replyCount)}</span>
|
||||
<span className="flex items-center gap-0.5"><Repeat2 className="size-2.5" />{formatNumber(post.repostCount)}</span>
|
||||
<span className="flex items-center gap-0.5"><Heart className="size-2.5" />{formatNumber(post.likeCount)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Generic compact feed widget that queries Nostr events by kind and renders
|
||||
* them as a compact list. Used for Photos, Music, Articles, Events, Books widgets.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
|
||||
interface FeedWidgetProps {
|
||||
/** Event kind(s) to fetch. */
|
||||
kinds: number[];
|
||||
/** Link to the full feed page. */
|
||||
feedPath: string;
|
||||
/** Label for "View all" link. */
|
||||
feedLabel: string;
|
||||
/** Number of items to show. */
|
||||
limit?: number;
|
||||
/** Empty state message. */
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/** Compact feed widget showing recent events for given kind(s). */
|
||||
export function FeedWidget({ kinds, feedPath, feedLabel, limit = 5, emptyMessage = 'No content yet.' }: FeedWidgetProps) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followData } = useFollowList();
|
||||
const { data: curatorFollows } = useCuratorFollowList();
|
||||
const followPubkeys = followData?.pubkeys;
|
||||
|
||||
const kindsKey = kinds.join(',');
|
||||
|
||||
// Scope to followed authors when logged in, curator's follow list otherwise.
|
||||
const authors = user && followPubkeys?.length ? followPubkeys : curatorFollows;
|
||||
const authorsKey = user ? 'follows' : 'curator';
|
||||
|
||||
const { data: events, isLoading } = useQuery({
|
||||
queryKey: ['widget-feed', kindsKey, authorsKey, limit],
|
||||
queryFn: async () => {
|
||||
return nostr.query([{ kinds, limit, ...(authors ? { authors } : {}) }]);
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
// Don't run until the appropriate follow list is resolved
|
||||
enabled: user ? followPubkeys !== undefined : curatorFollows !== undefined,
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => (events ?? []).slice(0, limit), [events, limit]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">{emptyMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{filtered.map((event) => (
|
||||
<CompactEventCard key={event.id} event={event} />
|
||||
))}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to={feedPath} className="text-xs text-primary hover:underline">{feedLabel}</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal event card for sidebar widgets. */
|
||||
function CompactEventCard({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
|
||||
|
||||
// Try to get a title from tags (articles, events, etc.)
|
||||
const title = event.tags.find(([t]) => t === 'title')?.[1];
|
||||
|
||||
// Build a snippet from content
|
||||
const snippet = useMemo(() => {
|
||||
if (title) return title;
|
||||
const clean = event.content.replace(/https?:\/\/\S+/g, '').trim();
|
||||
if (clean.length > 100) return clean.slice(0, 100) + '...';
|
||||
return clean || '(media)';
|
||||
}, [event.content, title]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${encodedId}`}
|
||||
className="block hover:bg-secondary/40 px-2 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs font-semibold truncate">{displayName}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useOpenPost } from '@/hooks/useOpenPost';
|
||||
import { useSortedPosts } from '@/hooks/useTrending';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
|
||||
/** Hot posts widget for the right sidebar. */
|
||||
export function HotPostsWidget() {
|
||||
const { data: rawPosts, isLoading } = useSortedPosts('hot', 5);
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
const posts = useMemo(() => {
|
||||
if (!rawPosts || muteItems.length === 0) return rawPosts;
|
||||
return rawPosts.filter((e) => !isEventMuted(e, muteItems));
|
||||
}, [rawPosts, muteItems]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!posts || posts.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No hot posts right now.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{posts.slice(0, 5).map((event) => (
|
||||
<HotPostCard key={event.id} event={event} />
|
||||
))}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to="/trends" className="text-xs text-primary hover:underline">View all on Trends</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact hot post card for the sidebar widget. */
|
||||
function HotPostCard({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
|
||||
const { onClick: openPost, onAuxClick } = useOpenPost(`/${encodedId}`);
|
||||
|
||||
const snippet = useMemo(() => {
|
||||
const clean = event.content.replace(/https?:\/\/\S+/g, '').trim();
|
||||
if (clean.length > 100) return clean.slice(0, 100) + '\u2026';
|
||||
return clean || '(media)';
|
||||
}, [event.content]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={openPost}
|
||||
onAuxClick={onAuxClick}
|
||||
className="block w-full text-left hover:bg-secondary/40 px-2 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Play, Pause, Music, Clock } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { parseMusicTrack, toAudioTrack } from '@/lib/musicHelpers';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Rich music widget showing the latest track with playback controls. */
|
||||
export function MusicWidget() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followData } = useFollowList();
|
||||
const { data: curatorFollows } = useCuratorFollowList();
|
||||
|
||||
const followPubkeys = followData?.pubkeys;
|
||||
const authors = user && followPubkeys?.length ? followPubkeys : curatorFollows;
|
||||
const authorsKey = user ? 'follows' : 'curator';
|
||||
|
||||
const { data: event, isLoading } = useQuery<NostrEvent | null>({
|
||||
queryKey: ['widget-music', authorsKey],
|
||||
queryFn: async () => {
|
||||
const events = await nostr.query([{ kinds: [36787], limit: 1, ...(authors ? { authors } : {}) }]);
|
||||
return events[0] ?? null;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: user ? followPubkeys !== undefined : curatorFollows !== undefined,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
<Skeleton className="w-full aspect-square rounded-lg" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No music yet.</p>;
|
||||
}
|
||||
|
||||
return <MusicCard event={event} />;
|
||||
}
|
||||
|
||||
function MusicCard({ event }: { event: NostrEvent }) {
|
||||
const player = useAudioPlayer();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
|
||||
const parsed = useMemo(() => parseMusicTrack(event), [event]);
|
||||
const encodedId = useMemo(() => {
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: d });
|
||||
}, [event]);
|
||||
|
||||
if (!parsed) return null;
|
||||
|
||||
const isNowPlaying = player.currentTrack?.id === event.id;
|
||||
const dur = parsed.duration ? formatTime(parsed.duration) : undefined;
|
||||
|
||||
const handlePlay = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isNowPlaying && player.isPlaying) {
|
||||
player.pause();
|
||||
} else if (isNowPlaying) {
|
||||
player.resume();
|
||||
} else {
|
||||
const track = toAudioTrack(event, parsed);
|
||||
track.artwork ??= metadata?.picture;
|
||||
player.playTrack(track);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Artwork with play overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-lg overflow-hidden cursor-pointer',
|
||||
isNowPlaying ? 'ring-2 ring-primary' : '',
|
||||
)}
|
||||
onClick={handlePlay}
|
||||
>
|
||||
{parsed.artwork ? (
|
||||
<img
|
||||
src={parsed.artwork}
|
||||
alt={parsed.title}
|
||||
className="w-full aspect-square object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-square bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<Music className="size-12 text-primary/20" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 hover:bg-black/15 transition-colors">
|
||||
<div
|
||||
className={cn(
|
||||
'size-12 rounded-full flex items-center justify-center transition-colors',
|
||||
isNowPlaying && player.isPlaying
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-primary/15 text-primary hover:bg-primary/25 backdrop-blur-sm',
|
||||
)}
|
||||
>
|
||||
{isNowPlaying && player.isPlaying
|
||||
? <Pause className="size-5" fill="currentColor" />
|
||||
: <Play className="size-5 ml-0.5" fill="currentColor" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<Link to={`/${encodedId}`} className="block px-0.5 space-y-1 group">
|
||||
<p className="text-sm font-semibold leading-snug truncate group-hover:text-primary transition-colors">
|
||||
{parsed.title}
|
||||
</p>
|
||||
{parsed.artist && (
|
||||
<p className="text-xs text-muted-foreground truncate">{parsed.artist}</p>
|
||||
)}
|
||||
{dur && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="size-3 shrink-0" />
|
||||
<span>{dur}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-1.5 pt-0.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user