Compare commits

...

11 Commits

Author SHA1 Message Date
Alex Gleason 2d52aa8a56 release: v2.7.0 2026-04-14 16:01:08 -05:00
Alex Gleason 02b83be58e Prevent text selection on long-press of gamepad controls on iOS
Add -webkit-touch-callout: none and -webkit-user-select: none inline
styles to the GameControls container. The existing Tailwind select-none
class (user-select: none) is not sufficient on iOS, where WKWebView
still triggers the long-press callout/highlight gesture on held buttons.
2026-04-14 15:49:16 -05:00
Alex Gleason 8c3371e968 Add native iOS notification polling with rich metadata and grouping
Implement background relay polling for iOS using BGTaskScheduler,
addressing Apple App Store rejection (Guideline 4.2 - Minimum Functionality).

- DittoNotificationPlugin: Capacitor plugin mirroring the Android interface,
  schedules BGAppRefreshTask whenever notifications are enabled (no settings
  change required — both push/persistent modes poll on iOS)
- NostrPoller: fetches notification events via URLSessionWebSocketTask,
  resolves author display names from kind 0 metadata (24h cache), verifies
  referenced event authorship for reactions/reposts/zaps
- Rich notifications with author names, content previews, zap amounts, and
  reaction emoji display
- iOS thread identifiers for native notification grouping per category+post
- Notification categories with summary formats
- Foreground notification display and tap-to-navigate handling
- Immediate poll on app foreground to catch up on missed notifications
- Hide Delivery Method picker on iOS (only meaningful on Android)
2026-04-14 14:58:49 -05:00
Alex Gleason 1a106545f7 Fix haptics: call isNativePlatform() at invocation time, log errors
The platform check was cached as a module-level constant, which could
evaluate before the Capacitor bridge was ready. Moved to per-call checks
matching the pattern used everywhere else in the codebase. Also replaced
silent .catch(() => {}) with console.warn so failures are visible in
Safari Web Inspector / Xcode console.
2026-04-14 14:08:32 -05:00
Alex Gleason ffa1094f93 Add haptic feedback to Blobbi egg interactions
Hatching ceremony: escalating haptics on each crack click (light → medium
→ heavy → success notification on hatch). Egg tap-to-wiggle in feeds and
posts: light impact on each user-initiated tap. Auto-wiggle intervals are
excluded to avoid unwanted vibration.
2026-04-14 11:44:45 -05:00
Alex Gleason e890e913f5 Fix deep-linking on Google Play version (assetlinks.json update) 2026-04-14 11:42:40 -05:00
Alex Gleason 12a4966b84 Add haptic feedback to emoji reaction selection in QuickReactMenu
The popover emoji picker (both quick presets and full picker) was
publishing reactions internally without triggering haptic feedback.
Add impactLight() at the top of publishReaction() so every emoji
selection path gets tactile feedback.
2026-04-14 11:29:22 -05:00
Alex Gleason cc702027b0 Add native haptic feedback to all key interactions
Install @capacitor/haptics and add a centralized haptics utility
(src/lib/haptics.ts) that uses the native taptic engine on iOS/Android
and falls back to navigator.vibrate() on web.

Haptics added to:
- Switch component (covers 36+ toggle switches app-wide)
- PullToRefresh threshold (covers 15+ pages)
- MobileBottomNav tab taps
- ReactionButton (like/unlike, double-click heart)
- RepostMenu (repost/undo repost)
- ZapDialog button press + payment success (NWC and WebLN)
- FollowButton and ProfilePage follow toggle
- ComposeBox (post, voice message, and poll publish success)
- NoteMoreMenu (bookmark, pin, mute)
- VinesFeedPage reaction and repost buttons
- ProfileReactionButton and ExternalReactionButton
- NoteCard share button
- BlobbiRoomShell swipe navigation

Replaces raw navigator.vibrate() calls in GameControls and
SendAnimation with the new cross-platform haptics utility, fixing
haptic feedback on iOS where the Vibration API is not available.
2026-04-14 11:06:18 -05:00
Alex Gleason 328c858e4e Merge branch 'fix/emoji-autocomplete-click' into 'main'
Fix emoji/mention autocomplete dropdowns not clickable in compose modal

Closes #221

See merge request soapbox-pub/ditto!184
2026-04-14 02:25:11 +00:00
Mary Kate Fain dcf77aac2a Add pointer-events-auto to autocomplete dropdowns
Radix Dialog's DismissableLayer sets pointer-events: none on
document.body when the modal is open. Since the dropdowns are portaled
to document.body, they inherit this and silently swallow all mouse
events. Adding pointer-events-auto restores click delivery.
2026-04-13 21:14:52 -05:00
Mary Kate Fain cdf3391aad Fix emoji/mention autocomplete dropdowns not clickable in compose modal
The autocomplete dropdowns are portaled to document.body to escape
overflow clipping, which places them outside the Radix Dialog DOM tree.
Clicks on them were treated as 'interact outside' the dialog, preventing
mouse selection of emoji and mention suggestions.

Add data-autocomplete-dropdown attribute to both dropdown containers and
check for it in handleInteractOutside to prevent modal dismissal.
2026-04-13 21:09:58 -05:00
41 changed files with 1212 additions and 35 deletions
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [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
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.6.6"
versionName "2.7.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1
View File
@@ -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
View File
@@ -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')
+10 -2
View File
@@ -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.0;
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.0;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+80 -9
View File
@@ -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: []
)
}
}
+209
View File
@@ -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
)
}
}
}
+8
View File
@@ -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>
+633
View File
@@ -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)
}
}
+2
View File
@@ -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"),
+10
View File
@@ -11,6 +11,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",
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.6.6",
"version": "2.7.0",
"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",
+14 -7
View File
@@ -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"
]
}
}
}]
]
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [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
+1 -1
View File
@@ -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'];
+3
View File
@@ -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';
@@ -412,15 +413,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);
@@ -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]);
+4
View File
@@ -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 {
@@ -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)],
+3
View File
@@ -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) {
+4 -2
View File
@@ -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">
+2 -1
View File
@@ -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">
+5 -3
View File
@@ -4,6 +4,7 @@ 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';
@@ -37,6 +38,7 @@ export function MobileBottomNav() {
const handleSearchClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
selectionChanged();
setSearchOpen((v) => !v);
}, []);
@@ -65,7 +67,7 @@ export function MobileBottomNav() {
{/* Home */}
<Link
to="/"
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 === '/' || location.pathname === homePath) ? 'text-primary' : 'text-muted-foreground',
@@ -91,7 +93,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 +113,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',
+2
View File
@@ -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" });
+4
View File
@@ -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(
+2
View File
@@ -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);
+2
View File
@@ -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);
+2
View File
@@ -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?.();
+3
View File
@@ -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: '❤️' });
+9
View File
@@ -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]);
+3
View File
@@ -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]);
+2
View File
@@ -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);
};
+4 -3
View File
@@ -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
+6 -1
View File
@@ -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}
>
+3
View File
@@ -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!',
+117
View File
@@ -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);
}
}
+4 -3
View File
@@ -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>
+2
View File
@@ -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);
+3
View File
@@ -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>([