Compare commits
32 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 | |||
| 5c8c33747e | |||
| 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,42 @@
|
||||
# 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
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.6.6"
|
||||
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.6;
|
||||
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.6;
|
||||
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
+12
-2
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.6.6",
|
||||
"version": "2.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.6.6",
|
||||
"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",
|
||||
@@ -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",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.6.6",
|
||||
"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",
|
||||
|
||||
@@ -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,42 @@
|
||||
# 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
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
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,
|
||||
@@ -97,6 +98,7 @@ export function BlobbiRoomShell({
|
||||
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]);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useToast } from '@/hooks/useToast';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
import { cn } from '@/lib/utils';
|
||||
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. */
|
||||
@@ -715,6 +716,7 @@ export function ComposeBox({
|
||||
}
|
||||
}
|
||||
}
|
||||
notificationSuccess();
|
||||
toast({ title: 'Voice message sent!', description: 'Your voice message has been published.' });
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
@@ -972,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 {
|
||||
@@ -1015,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 {
|
||||
|
||||
@@ -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:`, {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -100,6 +100,7 @@ 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";
|
||||
@@ -800,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" });
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
+7
-3
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { useSecureLocalStorage } from '@/hooks/useSecureLocalStorage';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { LN } from '@getalby/sdk';
|
||||
|
||||
@@ -26,8 +26,12 @@ export function useNWCInternal(userPubkey?: string) {
|
||||
// be accessible without a user anyway since zap actions require login).
|
||||
const storagePrefix = userPubkey ? `nwc-connections:${userPubkey}` : 'nwc-connections';
|
||||
const activePrefix = userPubkey ? `nwc-active-connection:${userPubkey}` : 'nwc-active-connection';
|
||||
const [connections, setConnections] = useLocalStorage<NWCConnection[]>(storagePrefix, []);
|
||||
const [activeConnection, setActiveConnection] = useLocalStorage<string | null>(activePrefix, null);
|
||||
// NWC connection strings embed a secret that authorizes Lightning payments,
|
||||
// so on native platforms we store them in Keychain/KeyStore via secureStorage
|
||||
// rather than plaintext localStorage. On web the behavior matches the
|
||||
// previous useLocalStorage implementation (plaintext localStorage).
|
||||
const [connections, setConnections] = useSecureLocalStorage<NWCConnection[]>(storagePrefix, []);
|
||||
const [activeConnection, setActiveConnection] = useSecureLocalStorage<string | null>(activePrefix, null);
|
||||
const [connectionInfo, setConnectionInfo] = useState<Record<string, NWCInfo>>({});
|
||||
|
||||
// Add new connection
|
||||
|
||||
@@ -99,6 +99,15 @@ export function useNip05Resolve(identifier: string | undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate that the returned value is a well-formed 64-char lowercase
|
||||
// hex pubkey. Without this check, a malicious or broken NIP-05 server
|
||||
// could return arbitrary strings that get persisted to IndexedDB and
|
||||
// later fed into Nostr filters or passed to downstream consumers.
|
||||
if (!/^[0-9a-f]{64}$/.test(pubkey)) {
|
||||
void deleteNip05Cached(identifier);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Persist the successful resolution to IndexedDB (fire-and-forget).
|
||||
void setNip05Cached(identifier, pubkey);
|
||||
|
||||
|
||||
@@ -101,16 +101,24 @@ export function usePushNotifications(): UsePushNotificationsReturn {
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
|
||||
const client = new NostrPushClient(SERVER_PUBKEY, RPC_RELAYS);
|
||||
clientRef.current = client;
|
||||
let cancelled = false;
|
||||
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { scope: '/' })
|
||||
.then((reg) => {
|
||||
(async () => {
|
||||
// Load the device key from secure storage before the rest of the bring-up
|
||||
// sequence; everything below depends on \`clientRef.current\` being set.
|
||||
const client = await NostrPushClient.create(SERVER_PUBKEY, RPC_RELAYS);
|
||||
if (cancelled) {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
clientRef.current = client;
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
swRegistrationRef.current = reg;
|
||||
return navigator.serviceWorker.ready;
|
||||
})
|
||||
.then(async (reg) => {
|
||||
await navigator.serviceWorker.ready;
|
||||
if (cancelled) return;
|
||||
|
||||
// Pre-fetch and cache the VAPID key so it is ready before the user
|
||||
// clicks "Enable". This keeps pushManager.subscribe() as the first
|
||||
// async step inside enable(), satisfying the browser's user-gesture
|
||||
@@ -125,6 +133,7 @@ export function usePushNotifications(): UsePushNotificationsReturn {
|
||||
console.warn('[push] Failed to pre-fetch VAPID key:', err);
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (vapidKey) {
|
||||
vapidKeyRef.current = vapidKey;
|
||||
}
|
||||
@@ -133,16 +142,20 @@ export function usePushNotifications(): UsePushNotificationsReturn {
|
||||
// subscription exists, restore the enabled state silently.
|
||||
if (Notification.permission === 'granted') {
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
if (cancelled) return;
|
||||
if (existing) {
|
||||
pushSubRef.current = existing;
|
||||
setPermission('granted');
|
||||
setEnabled(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('[push] SW registration failed:', err));
|
||||
} catch (err) {
|
||||
console.error('[push] SW registration failed:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clientRef.current?.destroy();
|
||||
clientRef.current = null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { secureStorage } from '@/lib/secureStorage';
|
||||
|
||||
/**
|
||||
* Hook for storing sensitive state that should not live in plaintext
|
||||
* localStorage on native platforms.
|
||||
*
|
||||
* - On web: uses `localStorage` (identical to `useLocalStorage`).
|
||||
* - On native (Capacitor iOS/Android): uses the native Keychain / KeyStore via
|
||||
* `secureStorage`. Any existing plaintext value in localStorage for the same
|
||||
* key is migrated on first read and the plaintext copy is removed.
|
||||
*
|
||||
* This is async under the hood, so the hook additionally exposes a `ready`
|
||||
* flag indicating whether the initial load has completed. While `!ready`, the
|
||||
* state is `defaultValue` and callers should avoid making decisions based on
|
||||
* an "empty" state (e.g. do not persist a default back if the user has real
|
||||
* data stored that hasn't been loaded yet).
|
||||
*
|
||||
* The return tuple is `[state, setValue, ready]`.
|
||||
*/
|
||||
export function useSecureLocalStorage<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
serializer?: {
|
||||
serialize: (value: T) => string;
|
||||
deserialize: (value: string) => T;
|
||||
},
|
||||
) {
|
||||
const serialize = serializer?.serialize || JSON.stringify;
|
||||
const deserialize = serializer?.deserialize || JSON.parse;
|
||||
|
||||
const isNative = Capacitor.isNativePlatform();
|
||||
|
||||
// On web we can read synchronously during initialization (same behavior as
|
||||
// useLocalStorage). On native we must wait for the async read, so we start
|
||||
// with defaultValue and flip `ready` once loaded.
|
||||
const [state, setState] = useState<T>(() => {
|
||||
if (isNative) return defaultValue;
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? deserialize(item) : defaultValue;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load ${key} from localStorage:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
});
|
||||
|
||||
const [ready, setReady] = useState<boolean>(!isNative);
|
||||
|
||||
// Track the most-recently-requested key so stale async reads don't clobber
|
||||
// state after the caller swapped to a different key.
|
||||
const currentKeyRef = useRef(key);
|
||||
currentKeyRef.current = key;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
if (isNative) {
|
||||
// Check secureStorage first (handles migration from plaintext
|
||||
// localStorage internally per secureStorage.getItem).
|
||||
const item = await secureStorage.getItem(key);
|
||||
if (cancelled || currentKeyRef.current !== key) return;
|
||||
setState(item ? deserialize(item) : defaultValue);
|
||||
} else {
|
||||
const item = localStorage.getItem(key);
|
||||
if (cancelled || currentKeyRef.current !== key) return;
|
||||
setState(item ? deserialize(item) : defaultValue);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.warn(`Failed to load ${key} from secure storage:`, error);
|
||||
setState(defaultValue);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
setReady(false);
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// defaultValue and deserialize are intentionally excluded — we only want to
|
||||
// re-read when the key identity changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, isNative]);
|
||||
|
||||
const setValue = (value: T | ((prev: T) => T)) => {
|
||||
const persist = (next: T) => {
|
||||
try {
|
||||
const serialized = serialize(next);
|
||||
if (isNative) {
|
||||
// Fire-and-forget; errors are logged but shouldn't block the caller.
|
||||
void secureStorage.setItem(key, serialized).catch((error) => {
|
||||
console.warn(`Failed to save ${key} to secure storage:`, error);
|
||||
});
|
||||
} else {
|
||||
localStorage.setItem(key, serialized);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to serialize ${key}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
if (value instanceof Function) {
|
||||
setState((prev) => {
|
||||
const next = (value as (p: T) => T)(prev);
|
||||
if (next === prev) return prev;
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setState((prev) => {
|
||||
if (value === prev) return prev;
|
||||
persist(value);
|
||||
return value;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Sync with cross-tab changes on web. Native secure storage has no
|
||||
// cross-tab concept.
|
||||
useEffect(() => {
|
||||
if (isNative) return;
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === key && e.newValue !== null) {
|
||||
try {
|
||||
setState(deserialize(e.newValue));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to sync ${key} from localStorage:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [key, isNative, deserialize]);
|
||||
|
||||
return [state, setValue, ready] as const;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { nip57 } from 'nostr-tools';
|
||||
import type { Event } from 'nostr-tools';
|
||||
import type { WebLNProvider } from '@webbtc/webln-types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
|
||||
/**
|
||||
* Hook for sending zaps to an event author.
|
||||
@@ -160,6 +161,7 @@ export function useZaps(
|
||||
// Clear states immediately on success
|
||||
setIsZapping(false);
|
||||
setInvoice(null);
|
||||
notificationSuccess();
|
||||
|
||||
toast({
|
||||
title: 'Zap successful!',
|
||||
@@ -204,6 +206,7 @@ export function useZaps(
|
||||
// Clear states immediately on success
|
||||
setIsZapping(false);
|
||||
setInvoice(null);
|
||||
notificationSuccess();
|
||||
|
||||
toast({
|
||||
title: 'Zap successful!',
|
||||
|
||||
@@ -125,40 +125,82 @@ export async function getNsecCredential(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of a `saveNsec` call. */
|
||||
export type SaveNsecResult = 'saved' | 'saved-to-file' | 'dismissed';
|
||||
|
||||
/** Build the filename used for the fallback `.nsec.txt` file. */
|
||||
function nsecFilename(npub: string, appName?: string): string {
|
||||
// Slugify the app name so it's filesystem-safe. On Capacitor `location.hostname`
|
||||
// is always `localhost`, which produces meaningless filenames — prefer the
|
||||
// app name when the caller provides it.
|
||||
const slug = (appName ?? location.hostname)
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'nostr';
|
||||
return `${slug}-${npub.slice(5, 9)}.nsec.txt`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a Nostr secret key using the best method available on the platform.
|
||||
*
|
||||
* - **Native (iOS / Android)**: Prompts the credential manager
|
||||
* (iCloud Keychain / Google). Throws if the user dismisses so the caller
|
||||
* can block progression and retry.
|
||||
* - **Android Capacitor**: Tries the AndroidX Credential Manager first
|
||||
* (which delegates to Google Password Manager or any registered provider).
|
||||
* On de-Googled devices (GrapheneOS, /e/OS, etc.) there may be no provider
|
||||
* available and the call fails — in that case we fall back to writing the
|
||||
* key to the app's Documents directory so the user always has a backup.
|
||||
* Returns `'saved'` on keychain success, `'saved-to-file'` on fallback.
|
||||
*
|
||||
* - **iOS Capacitor**: Prompts iCloud Keychain via
|
||||
* `SecAddSharedWebCredential`. Returns `'dismissed'` if the user dismisses
|
||||
* the sheet — dismissal is a legitimate user choice and not an error, so
|
||||
* callers can proceed anyway. No file fallback on iOS: the Documents
|
||||
* folder is accessible without authentication, so silently writing a
|
||||
* plaintext nsec there would violate user intent.
|
||||
*
|
||||
* - **Web**: Downloads the key as a `.nsec.txt` file (always), and also
|
||||
* attempts to store it via `PasswordCredential` as a bonus (Chromium).
|
||||
* The bonus store is fire-and-forget — it never blocks or throws.
|
||||
* Resolves to `'saved'` once the file download completes.
|
||||
*
|
||||
* Real errors (e.g. filesystem write failure on native) still throw.
|
||||
*
|
||||
* @param npub - The user's npub (credential username / account)
|
||||
* @param nsec - The user's nsec (credential password)
|
||||
* @param name - Optional display name (Chromium only)
|
||||
* @throws On native platforms if the user dismisses the credential prompt.
|
||||
* @param name - Optional app/display name. Used as the Chromium password-
|
||||
* manager entry name, and as the filename slug for any
|
||||
* fallback `.nsec.txt` file written to disk. On Capacitor
|
||||
* `location.hostname` is always `localhost`, so passing the
|
||||
* app name is the only way to get a meaningful filename.
|
||||
* @returns `'saved'` if stored in the platform credential manager or
|
||||
* downloaded as a file on web; `'saved-to-file'` if stored as a
|
||||
* file via the Android fallback; `'dismissed'` if the user
|
||||
* dismissed the iOS credential prompt.
|
||||
*/
|
||||
export async function saveNsec(
|
||||
npub: string,
|
||||
nsec: string,
|
||||
name?: string,
|
||||
): Promise<void> {
|
||||
// Native: credential manager is the sole save mechanism.
|
||||
): Promise<SaveNsecResult> {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const saved = await storeNsecCredential(npub, nsec, name);
|
||||
if (!saved) {
|
||||
throw new Error('Credential save was dismissed');
|
||||
if (saved) return 'saved';
|
||||
|
||||
// Android fallback: write the key to Documents so de-Googled devices
|
||||
// (no credential provider installed) still get a persistent backup.
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
await downloadTextFile(nsecFilename(npub, name), nsec);
|
||||
return 'saved-to-file';
|
||||
}
|
||||
return;
|
||||
|
||||
// iOS: dismissal is a deliberate user choice, no automatic fallback.
|
||||
return 'dismissed';
|
||||
}
|
||||
|
||||
// Web: always download the file as the primary save mechanism.
|
||||
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
|
||||
await downloadTextFile(filename, nsec);
|
||||
await downloadTextFile(nsecFilename(npub, name), nsec);
|
||||
|
||||
// Bonus: also try to store in the browser's password manager (Chromium).
|
||||
storeNsecCredential(npub, nsec, name).catch(() => {});
|
||||
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
@@ -17,8 +17,11 @@ import { findBundledFont, loadBundledFont, resolveCssFamily } from '@/lib/fonts'
|
||||
* Sanitize a string for safe interpolation into a double-quoted CSS context.
|
||||
* Uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens,
|
||||
* underscores, apostrophes, and periods are permitted. Everything else is stripped.
|
||||
*
|
||||
* Use whenever event-sourced strings flow into a CSS declaration value
|
||||
* (e.g. `font-family`) to prevent CSS-string breakout.
|
||||
*/
|
||||
function sanitizeCssString(value: string): string {
|
||||
export function sanitizeCssString(value: string): string {
|
||||
return value.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
/**
|
||||
* Centralized haptic feedback utility.
|
||||
*
|
||||
* On native (iOS/Android) it uses @capacitor/haptics for true taptic engine
|
||||
* feedback. On web it falls back to navigator.vibrate() which works on
|
||||
* Android browsers but is a silent no-op elsewhere.
|
||||
*/
|
||||
|
||||
type ImpactStyle = 'Heavy' | 'Medium' | 'Light';
|
||||
type NotificationType = 'Success' | 'Warning' | 'Error';
|
||||
|
||||
// Lazy-loaded Haptics plugin — only imported on native to avoid bundling
|
||||
// the plugin in web builds where it isn't useful.
|
||||
let hapticsPromise: Promise<typeof import('@capacitor/haptics')> | null = null;
|
||||
|
||||
function getHaptics() {
|
||||
if (!hapticsPromise) {
|
||||
hapticsPromise = import('@capacitor/haptics');
|
||||
}
|
||||
return hapticsPromise;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function nativeImpact(style: ImpactStyle) {
|
||||
const { Haptics, ImpactStyle } = await getHaptics();
|
||||
await Haptics.impact({ style: ImpactStyle[style] });
|
||||
}
|
||||
|
||||
async function nativeNotification(type: NotificationType) {
|
||||
const { Haptics, NotificationType } = await getHaptics();
|
||||
await Haptics.notification({ type: NotificationType[type] });
|
||||
}
|
||||
|
||||
async function nativeSelectionChanged() {
|
||||
const { Haptics } = await getHaptics();
|
||||
await Haptics.selectionChanged();
|
||||
}
|
||||
|
||||
function vibrate(ms: number) {
|
||||
try {
|
||||
navigator.vibrate?.(ms);
|
||||
} catch {
|
||||
/* Vibration API not available */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
function warnHapticError(label: string, err: unknown) {
|
||||
console.warn(`[haptics] ${label} failed:`, err);
|
||||
}
|
||||
|
||||
/** Light tap — reactions, reposts, bookmarks, share. */
|
||||
export function impactLight(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeImpact('Light').catch((e) => warnHapticError('impactLight', e));
|
||||
} else {
|
||||
vibrate(10);
|
||||
}
|
||||
}
|
||||
|
||||
/** Medium tap — zap button press, pull-to-refresh threshold, follow. */
|
||||
export function impactMedium(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeImpact('Medium').catch((e) => warnHapticError('impactMedium', e));
|
||||
} else {
|
||||
vibrate(20);
|
||||
}
|
||||
}
|
||||
|
||||
/** Heavy tap — game button press, letter seal. */
|
||||
export function impactHeavy(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeImpact('Heavy').catch((e) => warnHapticError('impactHeavy', e));
|
||||
} else {
|
||||
vibrate(30);
|
||||
}
|
||||
}
|
||||
|
||||
/** Success notification — zap payment success, post published. */
|
||||
export function notificationSuccess(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeNotification('Success').catch((e) => warnHapticError('notificationSuccess', e));
|
||||
} else {
|
||||
vibrate(15);
|
||||
}
|
||||
}
|
||||
|
||||
/** Warning notification. */
|
||||
export function notificationWarning(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeNotification('Warning').catch((e) => warnHapticError('notificationWarning', e));
|
||||
} else {
|
||||
vibrate(20);
|
||||
}
|
||||
}
|
||||
|
||||
/** Error notification. */
|
||||
export function notificationError(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeNotification('Error').catch((e) => warnHapticError('notificationError', e));
|
||||
} else {
|
||||
vibrate(30);
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection changed — toggle switches, tab taps, picker changes. */
|
||||
export function selectionChanged(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeSelectionChanged().catch((e) => warnHapticError('selectionChanged', e));
|
||||
} else {
|
||||
vibrate(5);
|
||||
}
|
||||
}
|
||||
+14
-5
@@ -1,4 +1,6 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { sanitizeCssString } from '@/lib/fontLoader';
|
||||
|
||||
export const LETTER_KIND = 8211;
|
||||
export const COLOR_MOMENT_KIND = 3367;
|
||||
@@ -96,11 +98,15 @@ export interface ResolvedStationery {
|
||||
|
||||
/** Resolve a Stationery into full rendering attributes by reading event tags. */
|
||||
export function resolveStationery(s: Stationery): ResolvedStationery {
|
||||
// Sanitize event-sourced font family before CSS interpolation (see SECURITY_AUDIT M-6).
|
||||
// Applied at the parse layer so every consumer gets a safe value.
|
||||
const safeFontFamily = s.fontFamily ? sanitizeCssString(s.fontFamily) : undefined;
|
||||
|
||||
const base: ResolvedStationery = {
|
||||
color: s.color,
|
||||
emoji: s.emoji,
|
||||
emojiMode: s.emojiMode ?? 'tile',
|
||||
fontFamily: s.fontFamily,
|
||||
fontFamily: safeFontFamily,
|
||||
frame: s.frame,
|
||||
frameTint: s.frameTint,
|
||||
imageMode: 'cover',
|
||||
@@ -131,22 +137,25 @@ export function resolveStationery(s: Stationery): ResolvedStationery {
|
||||
const bgTag = event.tags.find(([n]) => n === 'bg');
|
||||
if (bgTag) {
|
||||
for (const slot of bgTag.slice(1)) {
|
||||
if (slot.startsWith('url ')) base.imageUrl = slot.slice(4);
|
||||
// Sanitize event-sourced URL before CSS `url(...)` interpolation (H-2).
|
||||
if (slot.startsWith('url ')) base.imageUrl = sanitizeUrl(slot.slice(4));
|
||||
else if (slot === 'mode tile') base.imageMode = 'tile';
|
||||
else if (slot === 'mode cover') base.imageMode = 'cover';
|
||||
}
|
||||
}
|
||||
if (!base.imageUrl) {
|
||||
base.imageUrl = event.tags.find(([n]) => n === 'image')?.[1];
|
||||
base.imageUrl = sanitizeUrl(event.tags.find(([n]) => n === 'image')?.[1]);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// No event or unknown kind — use legacy flat fallbacks (old letters, presets)
|
||||
// No event or unknown kind — use legacy flat fallbacks (old letters, presets).
|
||||
// Legacy `imageUrl` may carry user-supplied URLs from pre-sanitization letters,
|
||||
// so sanitize here as well for defense-in-depth.
|
||||
base.textColor = s.textColor;
|
||||
base.primaryColor = s.primaryColor;
|
||||
base.layout = s.layout;
|
||||
base.imageUrl = s.imageUrl;
|
||||
base.imageUrl = sanitizeUrl(s.imageUrl);
|
||||
base.imageMode = s.imageMode ?? 'cover';
|
||||
return base;
|
||||
}
|
||||
|
||||
+26
-6
@@ -19,6 +19,8 @@ import { nip44, generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-too
|
||||
import { SimplePool } from 'nostr-tools';
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||
|
||||
import { secureStorage } from '@/lib/secureStorage';
|
||||
|
||||
// ─── Ephemeral device key ─────────────────────────────────────────────────────
|
||||
|
||||
const DEVICE_KEY_STORAGE = 'ditto-push-device-key';
|
||||
@@ -26,14 +28,19 @@ const DEVICE_KEY_STORAGE = 'ditto-push-device-key';
|
||||
/**
|
||||
* Get or generate a persistent ephemeral key for this device.
|
||||
* Used to sign nostr-push RPC events without prompting the user's signer.
|
||||
*
|
||||
* Routed through \`secureStorage\` so native builds keep the key in the iOS
|
||||
* Keychain / Android KeyStore. Web falls back to localStorage (the key is
|
||||
* ephemeral and per-device, so a plaintext copy only leaks which Nostr
|
||||
* events this device wants pushed — not the user's identity).
|
||||
*/
|
||||
function getDeviceSecretKey(): Uint8Array {
|
||||
const stored = localStorage.getItem(DEVICE_KEY_STORAGE);
|
||||
async function getDeviceSecretKey(): Promise<Uint8Array> {
|
||||
const stored = await secureStorage.getItem(DEVICE_KEY_STORAGE);
|
||||
if (stored) {
|
||||
return hexToBytes(stored);
|
||||
}
|
||||
const sk = generateSecretKey();
|
||||
localStorage.setItem(DEVICE_KEY_STORAGE, bytesToHex(sk));
|
||||
await secureStorage.setItem(DEVICE_KEY_STORAGE, bytesToHex(sk));
|
||||
return sk;
|
||||
}
|
||||
|
||||
@@ -116,15 +123,28 @@ export class NostrPushClient {
|
||||
private secretKey: Uint8Array;
|
||||
private publicKey: string;
|
||||
|
||||
constructor(
|
||||
private constructor(
|
||||
/** The nostr-push server's pubkey (hex). */
|
||||
private readonly serverPubkey: string,
|
||||
/** Relays to publish RPC calls to. */
|
||||
private readonly relays: string[],
|
||||
secretKey: Uint8Array,
|
||||
) {
|
||||
this.pool = new SimplePool();
|
||||
this.secretKey = getDeviceSecretKey();
|
||||
this.publicKey = getPublicKey(this.secretKey);
|
||||
this.secretKey = secretKey;
|
||||
this.publicKey = getPublicKey(secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new client, loading (or generating) the device key from
|
||||
* platform-appropriate secure storage.
|
||||
*/
|
||||
static async create(
|
||||
serverPubkey: string,
|
||||
relays: string[],
|
||||
): Promise<NostrPushClient> {
|
||||
const secretKey = await getDeviceSecretKey();
|
||||
return new NostrPushClient(serverPubkey, relays, secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-2
@@ -64,12 +64,15 @@ export const ThemeColorsCompatSchema = z.union([
|
||||
/** Zod schema for ThemeFont */
|
||||
export const ThemeFontSchema = z.object({
|
||||
family: z.string(),
|
||||
url: z.string().optional(),
|
||||
// Reject non-URL strings at the schema layer. Downstream consumers still
|
||||
// run the value through \`sanitizeUrl()\` to enforce \`https:\` and strip
|
||||
// \`javascript:\`/\`data:\` URIs before use — this is defense-in-depth.
|
||||
url: z.url().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for ThemeBackground */
|
||||
export const ThemeBackgroundSchema = z.object({
|
||||
url: z.string(),
|
||||
url: z.url(),
|
||||
mode: z.enum(['cover', 'tile']).optional(),
|
||||
dimensions: z.string().optional(),
|
||||
mimeType: z.string().optional(),
|
||||
|
||||
@@ -41,4 +41,17 @@ export const secureStorage = {
|
||||
|
||||
await SecureStoragePlugin.set({ key, value });
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await SecureStoragePlugin.remove({ key });
|
||||
} catch {
|
||||
// Key didn't exist — ignore.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -300,8 +300,9 @@ export function NotificationSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notification Style — native only, visible when push is enabled */}
|
||||
{isNative && pushEnabled && (
|
||||
{/* Notification Style — Android only, visible when push is enabled.
|
||||
On iOS both modes use BGAppRefreshTask so the choice is meaningless. */}
|
||||
{Capacitor.getPlatform() === 'android' && pushEnabled && (
|
||||
<>
|
||||
<SectionHeader title="Delivery Method" />
|
||||
<div className="pb-4">
|
||||
@@ -333,7 +334,7 @@ export function NotificationSettings() {
|
||||
<span className="text-sm font-medium">Persistent</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Background service polls relays directly. Shows a persistent notification. Use this on devices that don't support push notifications (e.g. GrapheneOS).
|
||||
Polls relays directly in the background for new notifications. Use this for reliable delivery on devices without push notification support.
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
@@ -103,6 +103,7 @@ import { TabButton } from '@/components/TabButton';
|
||||
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import type { AddrCoords } from '@/hooks/useEvent';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { impactMedium } from '@/lib/haptics';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
@@ -1619,6 +1620,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
} else {
|
||||
await follow(pubkey);
|
||||
}
|
||||
impactMedium();
|
||||
toast({ title: isFollowing ? `Unfollowed @${displayName}` : `Followed @${displayName}` });
|
||||
} catch (err) {
|
||||
console.error('Follow toggle failed:', err);
|
||||
|
||||
@@ -46,6 +46,7 @@ import { EXTRA_KINDS } from "@/lib/extraKinds";
|
||||
import { getRepostKind } from "@/lib/feedUtils";
|
||||
import { formatNumber } from "@/lib/formatNumber";
|
||||
import { getDisplayName } from "@/lib/getDisplayName";
|
||||
import { impactLight } from "@/lib/haptics";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const VINE_KIND = 34236;
|
||||
@@ -144,6 +145,7 @@ export function VineHeartButton({
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!user || hasReacted) return;
|
||||
impactLight();
|
||||
|
||||
// Optimistically update stats cache
|
||||
const prevStats = queryClient.getQueryData<EventStats>([
|
||||
@@ -219,6 +221,7 @@ export function VineRepostButton({
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!user) return;
|
||||
impactLight();
|
||||
|
||||
const repostKind = getRepostKind(event.kind);
|
||||
const prevStats = queryClient.getQueryData<EventStats>([
|
||||
|
||||
Reference in New Issue
Block a user