Compare commits

...

32 Commits

Author SHA1 Message Date
Alex Gleason 7073cadb43 release: v2.7.1 2026-04-16 16:09:12 -05:00
Alex Gleason 2dfb880566 Improve signup save-key step
Addresses confusion on the key-save step during signup:

- Rename the primary button from 'Continue' to 'Save Key' with a
  Download icon, so the label matches the action it performs.
- Change saveNsec() to return 'saved' | 'saved-to-file' | 'dismissed'
  instead of throwing on native dismissal. Dismissing the iCloud
  Keychain prompt is a legitimate user choice so the handler now
  proceeds silently rather than blocking with a 'Save failed' toast.
- Add an in-flight guard on the Save Key button with a spinner and
  'Saving…' label. The finally block guarantees the disabled state is
  cleared, so users can never get stuck on an unresponsive button —
  fixing the 'button became disabled after I dismissed the prompt'
  complaint by construction.
- On de-Googled Android builds (GrapheneOS, /e/OS, etc.) the AndroidX
  Credential Manager has no provider to delegate to, so the keychain
  save fails immediately. Fall back to writing the key to the app's
  Documents directory so the user always has a persistent backup, and
  surface a toast telling them where the file is.
- iOS keeps its original behaviour: dismissing the iCloud Keychain
  sheet is a deliberate user choice, no automatic fallback. The
  Documents folder on iOS is accessible via the Files app without
  authentication, so silently dropping a plaintext nsec there would
  violate user intent.
- Use the app name (from config.appName) as the filename slug for any
  .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. Drop the redundant 'nostr-' prefix since the
  '.nsec.txt' extension already identifies the file.
- Rewrite the description and title on the save step: 'Your secret
  key' + a single paragraph explaining what the key is and why it
  matters.
- When the user reveals the key via the eye toggle, show an amber
  callout with sharing/screenshotting warnings and a 'Learn more' link
  to the Managing Nostr keys blog post. The warning appears at the
  moment risk is highest.
- Auto-select the full nsec on focus/click so users copying into a
  password manager don't have to fight mobile selection handles.
- Use openUrl() for the external 'Learn more' link so it works
  correctly inside Capacitor's WKWebView.
- Singularise the keygen step copy ('cryptographic key' / 'Generate
  my key') to stay consistent with the save step which presents a
  single secret key.
2026-04-16 15:50:59 -05:00
Alex Gleason 0d3b8ed23d Harden CSS/URL handling, NWC storage, and Android backup
- Sanitize event-sourced URLs before CSS url() interpolation in
  ProfileCard banner and letter stationery background (closes H-1, H-2)
- Sanitize event-sourced font families at the parse layer and in letter
  card/detail consumers that bypass resolveStationery (closes M-6)
- Export sanitizeCssString for broader reuse
- Route NWC wallet connection URIs and active pointer through a new
  useSecureLocalStorage hook, storing in iOS Keychain / Android KeyStore
  on native (closes M-1)
- Add removeItem to secureStorage
- Add Android backup/data-extraction rules that exclude WebView storage
  and Capacitor secure-storage SharedPreferences so wallet credentials
  don't leak via Google Auto Backup (closes M-5)
- Document that GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be base64-encoded
  to match what the CI job expects (closes M-2)
2026-04-16 14:20:26 -05:00
Alex Gleason a61925b821 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-16 13:50:42 -05:00
Alex Gleason cbfbca063e Validate theme font and background URLs at the schema layer
`ThemeFontSchema.url` and `ThemeBackgroundSchema.url` previously accepted
any string, relying entirely on downstream `sanitizeUrl()` calls for
protocol enforcement. Tightening the schema to `z.url()` rejects
obviously malformed inputs up front and matches the approach already used
for the relay list (`BlossomServersEventSchema`). `sanitizeUrl()` remains
the authoritative guard for `https:` enforcement at render time.
2026-04-16 13:47:16 -05:00
Alex Gleason f3393b2cc8 Store nostr-push device key in secure storage on native builds
The per-device ephemeral key used to sign nostr-push RPC events was
previously stored unconditionally in localStorage. On Capacitor builds
this bypassed the iOS Keychain / Android KeyStore wrapper that every
other persistent key in the app already uses.

Route the key through `secureStorage`, which keeps the native path
encrypted at rest and falls back to localStorage on web (where it was
before). Because the key is now loaded asynchronously, convert the
`NostrPushClient` constructor into a private constructor plus a public
`create()` factory, and restructure `usePushNotifications` bring-up to
await the client before registering the service worker.

The key is ephemeral and per-device, so compromise only reveals which
Nostr events this device subscribes to -- not the user's identity --
but matching the existing secure-storage contract closes an obvious
inconsistency.
2026-04-16 13:47:16 -05:00
Alex Gleason 2eb643f422 Sanitize app-handler picture and banner URLs
The `picture` and `banner` fields parsed from a kind 31990 NIP-89 event's
JSON content were passed directly to `<img src>` attributes without any
scheme validation. Non-https URLs could leak the user's IP to arbitrary
hosts, and data: URIs could be used for fingerprinting.

The same event's `website` URL was already sanitized; apply the same
treatment to the image URLs for consistency. The app's CSP `img-src`
already blocks most of these at the browser level, so this is
defense-in-depth.
2026-04-16 13:47:15 -05:00
Alex Gleason e22dbbe85c Validate NIP-05 resolver returns a 64-char hex pubkey
Previously the resolver accepted any string value from a domain's
.well-known/nostr.json `names` map and persisted it to IndexedDB. A
malicious or misconfigured NIP-05 server could return arbitrary data
(non-hex, wrong length, HTML, etc.) that would then be cached and
passed to downstream consumers as a pubkey.

Exploitation impact is limited because invalid hex simply fails to
match anywhere in the Nostr filter API, but hygiene and cache
integrity warrant rejecting malformed values outright. Enforce the
standard 64-char lowercase hex shape and evict any cached entry that
fails validation.
2026-04-16 13:47:15 -05:00
Alex Gleason e01ed039fb Add restrictive sandbox attribute to web sandbox iframe
Previously the SandboxFrame iframe relied entirely on cross-origin
subdomain isolation (the HMAC-derived `<id>.sandbox.ditto.pub` origin)
for containment. That does give origin-keyed storage and postMessage
isolation, but it does not restrict top-frame navigation, pointer lock,
or other capabilities that a hostile nsite/webxdc app could abuse.

The highest-value protection here is blocking `allow-top-navigation`:
without it, a malicious nsite could do `window.top.location = evilUrl`
and redirect the entire Ditto tab to a phishing page that impersonates
the app. The user opened a preview expecting to stay inside Ditto, so
this is a realistic and impactful attack.

The policy grants the capabilities that real web apps legitimately use
(scripts, same-origin storage + Service Workers per iframe.diy's
architecture, forms, modals, popups that escape the sandbox, downloads)
while withholding the ones that are either attacks (top navigation) or
unused niche features (pointer lock, presentation API, orientation
lock).

Also Omit 'sandbox' from the spread props so consumers cannot
accidentally weaken the policy.
2026-04-16 13:47:13 -05:00
Chad Curtis 17cdb87723 Merge branch 'fix/scroll-restoration-on-back-navigation' into 'main'
Fix scroll position lost when navigating back from post detail page

Closes #217

See merge request soapbox-pub/ditto!161
2026-04-16 18:35:28 +00:00
Alex Gleason a55ff61669 Verify NIP-17 inner rumor pubkey matches seal pubkey
NIP-17 requires that clients verify `messageEvent.pubkey === sealEvent.pubkey`
before trusting a gift-wrapped direct message. Without this check, any
attacker can construct a rumor claiming to be from another user and
gift-wrap it to the victim -- the seal signature only authenticates the
seal author, not the (unsigned) inner rumor.

Ditto's primary sender display uses sealEvent.pubkey so the headline
impersonation case is mitigated in practice, but the inner event's fields
(including its pubkey) are passed whole to NoteContent for kind 15 file
attachments, which could leak into downstream zap/reply targeting. Add
the spec-mandated check to prevent any trust in the inner pubkey.
2026-04-16 13:21:15 -05:00
Chad Curtis 3039c46565 Merge branch 'feat/blobbi-retroactive-task-progression' into 'main'
Make hatch/evolve missions count retroactively from user history

Closes #222

See merge request soapbox-pub/ditto!185
2026-04-16 16:18:07 +00:00
Chad Curtis 2d74088b25 Add scroll-to-top feed refresh on Home re-tap and fix mobile tab hover artifact 2026-04-15 20:56:02 -05:00
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
filemon 86c4594cdd Clean up self-review findings: remove dead exports, simplify query keys, align ceremony state flow
- Remove dead deprecated exports: isValidEvolvePost, EVOLVE_REQUIRED_POSTS,
  BLOBBI_EVOLVE_POST_PREFIX, isValidBlobbiPost, sanitizeToHashtag
- Remove corresponding barrel re-exports from actions/index.ts
- Simplify hatch/evolve query keys to ['...-tasks', pubkey] since
  retroactive queries no longer depend on stateStartedAt
- Drop stateStartedAt from enabled guards so retroactive queries
  aren't blocked when the timestamp is missing
- Align BlobbiHatchingCeremony hatch path: babies now start as
  'evolving' with state_started_at set, matching useBlobbiStageTransition
- Ceremony fakePreview for existing eggs preserves companion's actual state
2026-04-14 14:58:49 -03:00
filemon 6d157c0a65 Merge branch 'main' into feat/blobbi-retroactive-task-progression 2026-04-14 14:13:47 -03:00
filemon 43c75175f4 Auto-start incubation/evolution for new Blobbis
New eggs now start in 'incubating' state with state_started_at set at
adoption time, so hatch tasks begin tracking immediately.

Newly hatched babies now start in 'evolving' state with a fresh
state_started_at, so evolution tasks begin tracking immediately.

The evolving state is applied after validateAndRepairBlobbiTags (which
would otherwise repair task-process states to 'active' via cleanupTaskTags).

Existing/older Blobbis are unaffected -- no migration is performed.
Stop incubation/evolution actions continue to work as before.
2026-04-14 13:47:34 -03: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
filemon b68ea276db Make hatch/evolve missions count retroactively from user history
Content-type missions (theme, color moment, post, profile edit) now query
the user's full Nostr history instead of filtering by state_started_at.
Only Blobbi-specific tasks (interactions, maintain_stats) still require
actions on the current Blobbi instance.

Egg incubation:
- create_theme, color_moment: retroactive (no since: filter)
- create_post: retroactive, simplified to any post with #blobbi tag
- interactions: still Blobbi-specific (7x care actions)

Baby evolution:
- create_themes, color_moments, edit_profile: retroactive
- create_posts task removed entirely
- interactions: still Blobbi-specific (21x care actions)
- maintain_stats: still Blobbi-specific (dynamic, all stats >= 80)
2026-04-14 13:27:16 -03: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
Mary Kate Fain 5c8c33747e Guard against redundant protocol:nostr and document prefix queryKey
- Skip appending protocol:nostr if the resolved filter already contains it
- Add comment explaining why the 2-element prefix key correctly invalidates
  the full 5-element useTabFeed query key via TanStack prefix matching
2026-04-10 12:36:27 -05:00
Mary Kate Fain 2fbc9e0409 Add protocol:nostr to saved feed queries for latest results
The previous useStreamPosts always injected 'protocol:nostr' into the
NIP-50 search string, which is a Ditto relay extension that filters for
native Nostr events. Without it, useTabFeed's queries return stale or
fewer results because the relay doesn't scope to the Nostr protocol.

Augment the resolved filter's search field with 'protocol:nostr' before
passing it to useTabFeed, matching the old behavior.
2026-04-05 17:59:13 -05:00
Mary Kate Fain 313222d12e Fix custom feed scroll position lost on back navigation
SavedFeedContent was using useStreamPosts which stores data in React
component state (useState). When navigating to a post detail page the
component unmounts and all state is destroyed, forcing a full re-fetch
on back navigation — losing the user's scroll position and content.

Replace useStreamPosts with useTabFeed (useInfiniteQuery) to match how
the Home, Ditto, and Global feeds work. TanStack Query caches all
fetched pages independently of component lifecycle (gcTime = 30 min),
so navigating back renders content instantly from cache, preserving
scroll position.

This also adds proper infinite scroll pagination and repost unwrapping
to custom saved feeds, which previously loaded a single batch.

Closes #217
2026-04-05 17:54:41 -05:00
Mary Kate Fain 46ba6978dd Fix scroll position lost when navigating back from post detail page
ScrollToTop was calling window.scrollTo(0, 0) on every pathname change,
including back/forward (POP) navigation. This destroyed the browser's
native scroll restoration, forcing users back to the top of the feed.

Use useNavigationType() to only scroll to top on PUSH navigation (user
clicked a link), preserving scroll position on POP (back/forward).

Closes #217
2026-04-05 17:44:13 -05:00
71 changed files with 1952 additions and 373 deletions
+12 -2
View File
@@ -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
+37
View File
@@ -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
+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.1"
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')
+2
View File
@@ -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>
+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.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 = "";
+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"),
+12 -2
View File
@@ -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
View File
@@ -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",
+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"
]
}
}
}]
]
+37
View File
@@ -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
+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'];
@@ -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
+37 -115
View File
@@ -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;
+47 -104
View File
@@ -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,
-4
View File
@@ -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';
+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';
@@ -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);
+6 -5
View File
@@ -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]);
+6 -2
View File
@@ -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');
+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 {
+23
View File
@@ -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
View File
@@ -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>
);
+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">
+76 -20
View File
@@ -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>
);
+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">
+16 -3
View File
@@ -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',
+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(
+6 -2
View File
@@ -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')}
+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]);
+15 -1
View File
@@ -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}
/>
);
+8 -3
View File
@@ -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;
}
+4
View File
@@ -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 {
+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);
};
+5 -1
View File
@@ -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;
+5 -1
View File
@@ -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;
+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
@@ -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',
}}
+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}
>
+7 -3
View File
@@ -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
+9
View File
@@ -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);
+23 -10
View File
@@ -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;
};
+145
View File
@@ -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;
}
+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!',
+54 -12
View File
@@ -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';
}
+4 -1
View File
@@ -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, '');
}
+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);
}
}
+14 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
+13
View File
@@ -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.
}
},
};
+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>([