Compare commits

...

68 Commits

Author SHA1 Message Date
Alex Gleason ac1e82b52d release: v2.6.2 2026-04-08 23:38:31 -05:00
Alex Gleason 437b8de652 Remove right sidebar content and show profile fields inline 2026-04-08 23:34:03 -05:00
Alex Gleason adadb6ed53 Fix native file downloads: save directly to Documents on iOS/Android 2026-04-08 22:54:46 -05:00
Alex Gleason f7c90a4a23 Remove trending hashtags section from logged-out homepage 2026-04-08 22:28:22 -05:00
Alex Gleason 82632bb76c Store nostr:login in secure storage on native platforms
Use capacitor-secure-storage-plugin to persist login credentials
(nsec keys) in iOS Keychain / Android KeyStore instead of plaintext
localStorage. Web behavior is unchanged. Existing native users are
auto-migrated on first launch: if secure storage is empty but
localStorage has data, it is moved over and the plaintext copy is
removed.

Also ignore ios/ directory in ESLint (Capacitor-generated files).
2026-04-08 22:20:48 -05:00
Alex Gleason 3a70d34e6d npm audit fix 2026-04-08 22:12:03 -05:00
Alex Gleason 221d3f4aff Merge branch 'mobile-search' 2026-04-08 22:11:38 -05:00
Alex Gleason 6a1a462ab0 Upgrade @nostrify/react to ^0.5.0 (async storage support)
Upgrade to the new version that includes the NLoginStorage interface
and storage/fallback props on NostrLoginProvider for pluggable async
storage backends (e.g. Capacitor Secure Storage).

- Add resolve.dedupe for react/react-dom to prevent dual-React issues
- Update NoteContent tests to use async findBy* queries since the
  provider now always awaits storage initialization
2026-04-08 22:08:56 -05:00
Alex Gleason 5ee8bc1cc0 Improve mobile search UX: lock scroll, hide bottom nav, dismiss accessory bar, and fix close behavior 2026-04-08 22:04:26 -05:00
Alex Gleason 76d53859cf Simplify webxdc to always open in fullscreen panel 2026-04-08 20:47:46 -05:00
Alex Gleason e482afbd3f Fix sandbox origin isolation and Android build issues 2026-04-08 20:47:42 -05:00
Alex Gleason 11ff27efe2 Enable iOS swipe-back navigation and fix bottom nav layout 2026-04-08 20:47:37 -05:00
Alex Gleason 8f6f678132 Add safe area padding and fix fullscreen sandbox on iOS 2026-04-08 20:47:32 -05:00
Alex Gleason f25139103c Add native SandboxPlugin for iOS and Android 2026-04-08 20:47:28 -05:00
Alex Gleason 0028b506e7 Fix webxdc bridge: serve script via resolveFile instead of injectedScripts
SandboxFrame's virtual script serving intercepted /webxdc.js and served
the empty placeholder content before resolveFile was ever called. The
dynamically generated bridge script (which embeds selfAddr etc.) was
never reaching the iframe.

Move bridge serving and HTML injection into resolveFileWithBridge so
the content is served from bridgeScriptRef after onReady populates it.
2026-04-08 16:55:01 -05:00
Alex Gleason 926c27d51c Fix webxdc race condition: await onReady before sending init
The sandbox frame was sending init immediately and calling onReady
concurrently, so fetch requests arrived before the archive was
downloaded and unzipped. Now onReady is awaited before init is sent,
matching the original Webxdc behavior.
2026-04-08 16:50:44 -05:00
Alex Gleason c4454ee2a1 Refactor iframe.diy usage into unified SandboxFrame component
Extract duplicated sandbox protocol logic from NsitePreviewDialog and
Webxdc into a single SandboxFrame component. Shared utilities (MIME
types, base64, HTML injection, JSON-RPC types) move to src/lib/sandbox/.

Add configurable sandboxDomain to AppConfig so the iframe.diy domain
can be overridden via ditto.json, preparing for native Capacitor
implementations.

Strip unused console/navigation/error RPC from previewInjectedScript,
leaving only the /index.html path normalization.
2026-04-08 16:41:23 -05:00
Chad Curtis e56737f776 Fix blobbi discovery: query by author instead of relying on profile.has[]
The Blobbi collection was previously discovered via the profile's has[] tag
list, meaning any blobbi whose d-tag was missing from that secondary index
would be invisible to the user despite existing on the relay.

Now useBlobbisCollection() without args queries all kind 31124 events by
author + ecosystem namespace tag — the user authored these events, so that
is the source of truth. The profile.has[] list is still used for selection
ordering preference, but no longer gates discovery.

The dList parameter remains available for targeted fetches (e.g. the
companion layer only needs one specific blobbi).
2026-04-08 11:02:03 -05:00
Chad Curtis feb6c1a9f6 Add drop shadow and solid gradient to overflow tab arrows 2026-04-08 10:27:17 -05:00
Chad Curtis 6f8d225597 Increase overflow tab arrow stroke to 4 and boost contrast 2026-04-08 10:22:04 -05:00
Chad Curtis 9ecd99a6a1 Add 'Write a letter' option to profile more menu
Adds a Mail-icon menu item in the profile more menu for other users'
profiles. Navigates to /letters/compose?to={npub} so the recipient is
pre-filled, matching the same flow used by the notification reply button.
2026-04-08 04:01:11 -05:00
Chad Curtis 287097627d Hide delivery method when push disabled; fix persistent description
Only show the delivery method radio group when push notifications are
enabled. Update the persistent option description to explain it is for
devices that don't support push notifications (e.g. GrapheneOS).
2026-04-08 00:20:20 -05:00
Chad Curtis 3ee491a63b Add push vs persistent notification delivery option for Android
Default to push mode (no foreground service). Persistent mode with
the always-on background polling service is opt-in via the new
Delivery Method section in notification settings.

- Add notificationStyle ('push' | 'persistent') to EncryptedSettings
- Show radio group in NotificationSettings on native platforms
- Pass notificationStyle through Capacitor plugin to SharedPreferences
- DittoNotificationPlugin starts/stops foreground service on style change
- MainActivity only starts service on launch when style is persistent
- Re-enable unread polling on native when push mode is active
2026-04-07 10:54:30 -05:00
Chad Curtis 7944f73da3 fix: use fetchFreshEvent and preserve non-p-tags in Follow All handlers
FollowPackDetailContent, TeamSoapboxCard, and InitialSyncGate all had
handleFollowAll implementations that queried kind 3 directly (bypassing
fetchFreshEvent) and rebuilt the tag array with only p-tags, silently
dropping all non-p-tags (relay hints, petnames, etc.). They also did
not pass prev for published_at preservation.

Align all three with the safe pattern already used in FollowPage and
useFollowActions.
2026-04-07 09:03:07 -05:00
Chad Curtis 17c1936817 Support follow pack/set naddr identifiers on /follow URL
The /follow route now accepts naddr1 identifiers for follow packs
(kind 39089) and follow sets (kind 30000) in addition to npub/nprofile.

Renders an immersive fullscreen layout with pack info hero, avatar
stack, big Follow All CTA with status indicator, and Feed/Members
tabs using the standard SubHeaderBar arc.

Follow All uses the safe fetch-fresh -> modify -> publish pattern
with prev for published_at preservation.

Shared components (PackFeedTab, MemberCard, MemberCardSkeleton) and
parsePackEvent are reused from FollowPackDetailContent and packUtils.

Also fixes SubHeaderBar tab indicator positioning when innerClassName
centers the tab container (adds containerOffset + ResizeObserver for
layout-dependent recalculation).
2026-04-07 08:55:27 -05:00
Chad Curtis c570f4689d Merge branch 'curated-ditto-feed' into 'main'
Curate Ditto feed by curator follow list with photos, divines, videos, and music

See merge request soapbox-pub/ditto!164
2026-04-07 12:52:23 +00:00
Chad Curtis 064ab1e101 Address MR review: extract feed hook, fix cache key, add error handling, make curator configurable
- Remove unused 'authors' parameter from useInfiniteHotFeed
- Extract inline query from Feed.tsx into useCuratedDittoFeed hook
- Use content-based fingerprint for query key instead of list length
- Add error state handling so curator fetch failure shows empty state
  instead of infinite skeletons for first-time visitors
- Move hardcoded curator pubkey to AppConfig (curatorPubkey) so it
  can be overridden via ditto.json without a code change
- Remove LANDING_KINDS/LANDING_WEBXDC_FILTER from Feed.tsx (now in hook)
2026-04-07 07:48:23 -05:00
Alex Gleason 9c0d49b904 Add OPFS as blocked API in lockdown-mode skill 2026-04-06 18:42:45 -05:00
Alex Gleason 69634e7c05 Update lockdown-mode skill with cross-platform availability info
Lockdown Mode is not iOS-only — it's available on iOS 16+, iPadOS 16+,
watchOS 10+, and macOS Ventura+. Add platform availability section with
Apple Support reference link, rename report file to ios-report.txt to
clarify it's iOS-specific, and broaden the skill description.
2026-04-06 16:13:57 -05:00
Alex Gleason db48ce7c40 Add raw diagnostic report as skill reference file 2026-04-06 16:05:52 -05:00
Alex Gleason 36c6e537a7 Add lockdown-mode agent skill with iOS Lockdown Mode API reference 2026-04-06 15:59:29 -05:00
Alex Gleason cbc3df0bef Allow any dev server host via ALLOWED_HOSTS env var 2026-04-06 14:40:31 -05:00
Alex Gleason 2ecd557430 Fix IndexedDB crash on iOS Lockdown Mode
openDatabase() now catches errors from idb's openDB() (which throws
synchronously when indexedDB is undefined) and returns null. All
consumers — profileCache, nip05Cache, dmMessageStore — check for null
and silently degrade to in-memory only.

The DM message store also stops re-throwing errors, which previously
could produce unhandled rejections in DMProvider.
2026-04-06 13:41:32 -05:00
Alex Gleason 594e7ea8fa ci: add build-web job to produce downloadable artifact
The old 'pages' job was removed when deploying switched to nsite,
which broke the artifact download URL on the docs site. This adds
a new build-web job that builds the web app on main and saves the
dist/ directory as a downloadable artifact.
2026-04-06 01:09:10 -05:00
Alex Gleason 0a5e72efd0 release: v2.6.1 2026-04-06 00:58:23 -05:00
Alex Gleason 0f1021e0d3 Switch nsite preview from local-shakespeare.dev to iframe.diy
Replace the local-shakespeare.dev preview domain with iframe.diy, which
provides a service-worker based sandbox. This brings the nsite preview
implementation in line with Shakespeare's approach.

Key changes:
- iframe.diy handshake: listen for 'ready', respond with 'init'
- Derive private HMAC-SHA256 subdomains via deriveIframeSubdomain('nsite', ...)
- Inject preview script into HTML responses for console forwarding,
  SPA navigation tracking, and /index.html path normalization
- Remove sandbox attribute (iframe.diy manages its own sandboxing)
- Serve injected script from virtual /__injected__/preview.js path
2026-04-06 00:51:39 -05:00
Alex Gleason be65c659b2 Derive private iframe.diy subdomains with HMAC-SHA256
The i-tag UUID used for webxdc coordination is attacker-controlled.
Using it directly as the iframe.diy subdomain lets a malicious event
author pick a subdomain that collides with another app's origin,
gaining access to its localStorage/IndexedDB.

Introduce a persistent random seed in localStorage (ditto:seed) and
derive the subdomain as base36(HMAC-SHA256(seed, prefix|identifier)).
The prefix (e.g. "webxdc") domain-separates different use-cases.
The subdomain is stable per device+app but unpredictable to event
authors.
2026-04-06 00:33:55 -05:00
Alex Gleason b64aa4b24a Add Content-Security-Policy to webxdc fetch responses
Apply a strict CSP header to every response served from the .xdc archive
to enforce the webxdc offline sandbox. Permits same-origin, inline, eval,
wasm, data: and blob: but blocks all external network access.
2026-04-05 23:20:21 -05:00
Alex Gleason f63d8943d8 Replace webxdc.app with iframe.diy for webxdc sandboxing
Migrate the webxdc iframe runtime from webxdc.app to iframe.diy. Instead of
sending ZIP bytes to the iframe and having the SW unzip them, the parent now
unzips the .xdc archive and serves files via iframe.diy's fetch-proxy RPC.
A webxdc bridge script is served as a virtual /webxdc.js file, and a
<script> tag is injected into HTML responses via DOMParser to load it.

- Rewrite Webxdc.tsx to use iframe.diy's ready/init/fetch protocol
- Unzip .xdc archives on the parent side and serve via fetch RPC responses
- Serve webxdc bridge as virtual /webxdc.js via the fetch handler
- Inject <script src="/webxdc.js"> into HTML using DOMParser
2026-04-05 22:15:16 -05:00
Mary Kate Fain e6efdc3539 Switch Ditto feed from sort:hot to latest-first chronological ordering
Replace useInfiniteHotFeed (sort:hot via NIP-50 search) with standard
NIP-01 reverse-chronological pagination for the curated Ditto feed.
Latest ordering provides a natural time-based spread of content types,
working better with the diversity algorithm and giving a fresher feel.
2026-04-05 20:39:13 -05:00
Alex Gleason 517a72cce7 Fix profile avatar/banner lightbox appearing behind right sidebar
Portal ProfileImageLightbox to document.body, matching the fix
already applied to the shared Lightbox component. Without the
portal, the lightbox was trapped inside the center column's z-0
stacking context from MainLayout, causing the right sidebar
(a sibling outside that context) to paint on top.
2026-04-05 20:37:09 -05:00
Mary Kate Fain ebe0cfdf03 Cap Blobbi at 10% of feed, add per-type cap overrides 2026-04-05 20:30:54 -05:00
Mary Kate Fain a501337fd3 Increase content-type gap from 3 to 4 for better diversity spacing 2026-04-05 20:11:37 -05:00
Mary Kate Fain e3916b3bc1 Fix same-type clustering: drop excess deferred items instead of dumping them
The final drain loop now tries all deferred items (not just the front),
and any items that still can't satisfy the gap constraint are dropped
rather than appended back-to-back. This prevents runs like 3 Blobbis
in a row that occurred when the graceful degradation path blindly
appended all leftover deferred items.
2026-04-05 19:59:32 -05:00
Mary Kate Fain de22e921d4 Fix diversity reordering causing feed jumps on new page load
Process each page independently with gap state carrying forward from
the previous page's tail. Earlier pages never change when new pages
arrive, eliminating the visible re-render/jump. The proportional cap
now applies per-page instead of across the full flattened list.
2026-04-05 19:55:19 -05:00
Mary Kate Fain 3a512f04e2 Add content-type diversity reordering to Ditto feed
Prevent the same content type from appearing within 3 positions of
itself and cap any single type at 20% of the feed. Uses a two-phase
algorithm: proportional cap first (trims excess from least-hot items),
then greedy gap-enforced interleave that keeps items as close to their
original hotness rank as possible. Only applies to the Ditto/landing
feed — follows, global, and other feeds are untouched.
2026-04-05 19:47:04 -05:00
Chad Curtis 6fc68766c9 Merge branch 'refactor/blobbi-remove-item-quantity-selection' into 'main'
Remove quantity selectors from Blobbi item usage flows

See merge request soapbox-pub/ditto!163
2026-04-06 00:38:27 +00:00
Mary Kate Fain bfd1daf7ba Curate Ditto feed by curator follow list with photos, divines, videos, and music
Filter the Ditto tab and logged-out landing feed to only show content
from people followed by the curator npub (npub1jvnpg4c6ljadf5t6ry0w9q0rnm4mksde87kglkrc993z46c39axsgq89sc),
inclusive of the curator. Add Kind 20 (photos), 21/22 (videos),
34236 (divines), and 36787/34139 (music) to the curated feed kinds.
2026-04-05 19:24:00 -05:00
filemon ae81c13cc1 Merge branch 'main' into fix-items-blobbi-companion 2026-04-05 21:21:31 -03:00
filemon 41358d27ce Update comments and docs to reflect item-as-ability model
Replace outdated references to 'inventory items', 'consume',
'quantity', and 'storage decrement' across 14 files. Comments
now consistently describe items as reusable abilities sourced
from the shop catalog, not consumable inventory.
2026-04-05 21:18:35 -03:00
Chad Curtis ac8bffba23 Merge branch 'fix-items-blobbi-companion' into 'main'
Remove inventory ownership requirement from Blobbi companion items

See merge request soapbox-pub/ditto!162
2026-04-05 23:59:18 +00:00
filemon 748365de40 Remove quantity selectors from Blobbi item usage flows
Items are now single-use abilities — tap item, press Use, effect
happens immediately. No confirmation dialogs or quantity selectors.

Changes:
- Remove BlobbiUseItemConfirmDialog and InventoryUseConfirmDialog
- Remove quantity state, selectors, and multi-use loops from modals
- Simplify mutation hooks to always apply item effects once
- Drop quantity parameter from UseItemFunction type signature
- Update all call sites through the full stack (BlobbiPage, context,
  companion layer, companion item use hook)
2026-04-05 20:58:31 -03:00
filemon 361f8b9506 Remove inventory ownership requirement from Blobbi companion items
Items are now treated as abilities/tools unlocked by stage, not
consumable inventory that must be purchased. All catalog items are
shown in the companion action menu regardless of inventory quantity.

Changes:
- Source items from shop catalog instead of user inventory storage
- Remove quantity validation and storage decrement on item use
- Remove quantity badges and 'in inventory' text from all modals
- Keep stage-based filtering (egg vs baby/adult restrictions)
- Cap quantity selector at 99 instead of inventory count
2026-04-05 19:59:30 -03:00
Alex Gleason c1ec7a25ed Use published_at tag to show created/updated verbs in event action headers
Replaceable and addressable event headers now distinguish between
first publish and subsequent updates using the published_at tag:
- published_at == created_at → 'created' verb (e.g. 'created an emoji pack')
- published_at != created_at → 'updated' verb (e.g. 'updated an emoji pack')
- no published_at → 'shared' fallback for backward compatibility
2026-04-05 15:12:19 -05:00
Alex Gleason 272586d033 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-05 14:54:41 -05:00
Alex Gleason c77c098843 Add NIP-24 published_at to useNostrPublish for replaceable/addressable events
Extend useNostrPublish with an optional `prev` property on the event
template. For replaceable and addressable kinds, the hook automatically
manages published_at:

- First publish (no prev): set published_at equal to created_at
- Update (prev provided): preserve published_at from the old event
- Old event lacks published_at: don't fabricate one
- Caller already set published_at in tags: leave it alone

Callers pass `prev` when they have the old event from fetchFreshEvent,
giving the hook everything it needs without extra network requests.
Updated all 11 call sites that publish replaceable or addressable events.
Documents the prev convention in AGENTS.md.
2026-04-05 14:23:58 -05:00
Chad Curtis ea7afa94f7 Fix infinite scroll on custom profile tab feeds reloading same content
Two issues caused custom tab feeds (e.g. Magic Decks) to loop:

1. ProfileSavedFeedContent flattened pages without deduplication, so
   events returned by multiple pages rendered as visible duplicates.

2. useTabFeed only stopped paginating when rawCount === 0. For
   addressable events the relay keeps returning the same latest
   versions, so rawCount never hit zero. Changed to rawCount < limit
   (relay returned fewer than requested = exhausted).
2026-04-05 12:23:25 -05:00
Chad Curtis 0c29506402 Fix all 50 ESLint warnings by extracting non-component exports and adding missing deps
- Extract utility functions from component files into dedicated modules
  to fix react-refresh/only-export-components warnings:
  - parseBadgeDefinition -> src/lib/parseBadgeDefinition.ts
  - parseProfileBadges -> src/lib/parseProfileBadges.ts
  - getColors, paletteToTheme -> src/lib/colorMomentUtils.ts
  - parseDimToAspectRatio, eventToMediaItem -> src/lib/mediaUtils.ts
  - isAudioUrl, isImageUrl, isVideoUrl -> src/lib/mediaTypeDetection.ts
  - buildKindOptions, parseSelectedKinds -> src/lib/feedFilterUtils.ts
  - useVideoThumbnail -> src/hooks/useVideoThumbnail.ts
  - useEnvelopeDimensions -> src/hooks/useEnvelopeDimensions.ts
  - usePortalContainer -> src/hooks/usePortalContainer.ts
  - useAudioPlayer -> src/contexts/audioPlayerContextDef.ts
  - SubHeaderBar context/hooks -> src/components/SubHeaderBarContext.ts
  - EmotionDev hooks -> src/blobbi/dev/useEmotionDev.ts
  - BlobbiActions context def -> BlobbiActionsContextDef.ts

- Remove export from internal-only functions (useEventComments,
  parseEmojiPack, useScrollCarets, formatEffectSummary, getSortedEffectEntries)

- Fix react-hooks/exhaustive-deps warnings by adding missing dependencies
  to useEffect/useCallback/useMemo hooks across 14 files

- Fix logical expression dependency warnings by wrapping conditional
  values (tasks, pubkeys, authorPubkeys) in useMemo

- Move module-level constants (CORE_TAB_IDS, CORE_TAB_LABELS,
  DEFAULT_TAB_LABELS) out of ProfilePage component body

- Reorder usePushNotifications hooks so syncPreferences is defined
  before enable to fix block-scoped variable error
2026-04-05 11:57:31 -05:00
Chad Curtis b0609e7877 Make reaction emoji visible per-row in interactions modal
Replace grouped-by-emoji layout with a flat list where each reaction
row shows an inline emoji badge (similar to the zap amount badge).
Add an emoji summary bar at the top when multiple emoji types are
present. This makes it immediately obvious who reacted with what.
2026-04-05 11:07:55 -05:00
Chad Curtis 946be28b81 Use standard author layout for follow pack/set cards instead of content-first 2026-04-05 10:59:01 -05:00
Chad Curtis 89250c7472 Add kind action headers for follow packs and follow sets 2026-04-05 10:56:22 -05:00
Chad Curtis cfc7a0d31c Extract useActiveTabIndicator hook to deduplicate TabButton and SortableTabChip
The scroll-aware active indicator reporting and scroll listener logic was
duplicated between TabButton and SortableTabChip. Extract into a shared
useActiveTabIndicator hook in SubHeaderBar.
2026-04-05 10:47:34 -05:00
Chad Curtis 21003e3aed Add edit button for custom profile tabs and clear underline on tab removal
- Add pencil icon to SortableTabChip for editing existing custom tabs
- Wire onEdit to open ProfileTabEditModal with the existing tab data
- Clear the active arc underline when an active tab is removed (cleanup in useLayoutEffect)
- Round dnd-kit transform values to avoid sub-pixel rendering issues
2026-04-05 10:42:23 -05:00
Chad Curtis 93e8a6290f Merge branch 'fix/167-post-compose-box-renders-too-small-and-unclickable' into 'main'
fix: intermittent mobile compose modal collapse and unclickable input

Closes #167

See merge request soapbox-pub/ditto!146
2026-04-05 15:28:55 +00:00
Dmitriy E 47831ffa64 fix: intermittent mobile compose modal collapse and unclickable input 2026-04-05 10:27:43 -05:00
Chad Curtis 1533420320 Fix desktop tab overflow and add interest tab management in settings
SubHeaderBar: add left/right chevron scroll arrows on desktop when tabs
overflow, with gradient fade. Auto-scroll active tab into view and keep
arc hover/active indicators aligned during horizontal scroll.

ContentSettings: add Interest Tabs section with inline add/remove for
hashtags and geotags. Remove buttons always visible (mobile-friendly),
X icons use strokeWidth 4.
2026-04-05 10:20:58 -05:00
Chad Curtis e3ef542875 Fix desktop tab bar overflow: add scroll arrows and auto-scroll active tab into view
On desktop, overflowing feed tabs were completely inaccessible since the
scrollbar was hidden and there was no swipe gesture. Add left/right
chevron scroll buttons that appear only on desktop when tabs overflow,
with gradient fade indicators. Also auto-scrolls the active tab into
view when switching tabs, and keeps the arc hover/active indicators
aligned during horizontal scroll.
2026-04-05 10:15:01 -05:00
Chad Curtis 3bf55990c0 Fix missing bottom border on collapsed thread expand button
When a depth-collapsed 'Show X more replies' button was the last item
in a reply sequence, it lacked a bottom border separator. Added an
isLast prop to ExpandThreadButton that adds border-b when the button
terminates the visual sequence.
2026-04-05 09:50:52 -05:00
172 changed files with 6242 additions and 3348 deletions
+112
View File
@@ -0,0 +1,112 @@
---
name: lockdown-mode
description: Apple Lockdown Mode restrictions and their impact on web APIs inside WKWebView/Safari/WebView. Reference when debugging or building features for lockdown-enabled devices.
---
# Apple Lockdown Mode
Apple's Lockdown Mode is an opt-in security hardening profile that disables or restricts many web platform APIs inside Safari and WKWebView. Since this app ships inside a Capacitor WKWebView shell, **every restriction that applies to Safari also applies to our app**.
## Platform Availability
Lockdown Mode is available on:
- **iOS 16** or later (iPhone)
- **iPadOS 16** or later (iPad)
- **watchOS 10** or later (Apple Watch)
- **macOS Ventura** or later (Mac)
Additional protections are available starting in iOS 17, iPadOS 17, watchOS 10, and macOS Sonoma.
For full details, see [About Lockdown Mode](https://support.apple.com/en-us/105120) on Apple Support.
## Testing Baseline
This document is based on testing against **iOS 18.7 / Safari 26.4** on an iPhone with Lockdown Mode enabled (April 2026). The web API restrictions documented below apply to Safari and WKWebView across all supported platforms (iOS, iPadOS, and macOS).
## Blocked APIs
These APIs are **completely unavailable** (return `undefined`, `null`, or throw) when Lockdown Mode is active.
| API | Impact | Notes |
|-----|--------|-------|
| **IndexedDB** | Critical | `indexedDB` global is missing entirely. Any library that relies on IndexedDB for storage will fail (Dexie, idb, localForage with IndexedDB driver, etc.). |
| **Service Workers** | High | `navigator.serviceWorker` is absent. No offline caching, no background sync, no push notifications via SW. |
| **Cache API** | High | `caches` global is absent. Often used alongside Service Workers for offline strategies. |
| **WebAssembly** | High | `WebAssembly` global is `undefined`. Libraries compiled to WASM (e.g. libsodium-wrappers, secp256k1-wasm, SQLite WASM) will not load. |
| **Web Locks** | High | `navigator.locks` is absent. Cross-tab coordination patterns that depend on this will break silently. |
| **WebRTC** | High | `RTCPeerConnection` is absent. No peer-to-peer audio/video/data channels. |
| **WebGL / WebGL2** | Medium | All canvas `getContext('webgl'*)` calls return `null`. GPU-accelerated rendering, maps (Mapbox GL, deck.gl), and 3D are broken. |
| **FileReader** | Medium | `FileReader` constructor is absent. Cannot read `Blob`/`File` objects client-side (e.g. image preview before upload). Use the `File` constructor + `URL.createObjectURL()` as a workaround for previews. |
| **SharedArrayBuffer** | Medium | `SharedArrayBuffer` is `undefined`. May also require COOP/COEP headers on non-lockdown browsers, so this is often already unavailable. |
| **Speech Synthesis** | Low | `window.speechSynthesis` is absent. Text-to-speech features won't work. |
| **Notifications API** | Low | `Notification` is absent. Web push permission prompts won't appear. (Capacitor local notifications via the native plugin are unaffected.) |
| **WebCodecs** | Low | `VideoDecoder` / `VideoEncoder` are absent (`AudioDecoder` remains). Low-level media processing is unavailable. |
| **Gamepad API** | Low | `navigator.getGamepads` is absent. |
| **OPFS** | Medium | `navigator.storage.getDirectory` method does not exist. The `navigator.storage` object is present but the Origin Private File System API is stripped. SQLite-over-OPFS and any other OPFS-based storage will fail. |
| **Web Share API** | Low | `navigator.share` is absent. Use Capacitor's `@capacitor/share` plugin instead -- the native share sheet still works. |
## Available APIs
These APIs **still work** under Lockdown Mode and can be relied on.
| API | Notes |
|-----|-------|
| **File constructor** | `new File(...)` works. You can create File/Blob objects. |
| **FontFace API** | Dynamic font loading via `new FontFace()` succeeds. Remote font fetches may fail with a network error (data URIs rejected). |
| **JIT compilation** | JavaScript JIT appears active (~110ms for 1M iterations). Performance is not interpreter-level degraded. |
| **PDF viewer** | `navigator.pdfViewerEnabled` is `true`. Inline `<embed type="application/pdf">` works. |
| **Cookies** | `navigator.cookieEnabled` is `true`. |
| **Credential Management** | `navigator.credentials` is available. |
| **localStorage / sessionStorage** | Standard Web Storage APIs remain functional. |
## Implications for This App
### Storage
- **localStorage works** -- our primary client-side storage (app config, relay lists, etc.) is unaffected.
- **IndexedDB is gone** -- if any dependency silently uses IndexedDB (e.g. some Nostr caching layers, TanStack Query persisters), it will fail. Ensure all storage paths fall back to localStorage or in-memory.
- **OPFS is gone** -- `navigator.storage.getDirectory` is stripped (the method doesn't exist, though the `navigator.storage` object itself remains). SQLite-over-OPFS (e.g. wa-sqlite, sql.js with OPFS backend) and any other OPFS-based persistence will not work.
### Cryptography
- **WebAssembly is blocked** -- any WASM-based crypto libraries (secp256k1 compiled to WASM, libsodium WASM builds) will not load. Use pure-JS implementations (e.g. `@noble/secp256k1`, `@noble/hashes`) which are already what nostr-tools uses.
- **WebCrypto (`crypto.subtle`)** -- not listed as blocked in testing. The SubtleCrypto API should still be available for NIP-44 encryption via the standard Web Crypto path.
### Media & Rendering
- **WebGL is gone** -- map libraries that require WebGL (Mapbox GL JS, Google Maps WebGL renderer) will show blank canvases. Use raster tile alternatives or static map images.
- **FileReader is gone** -- image/file preview workflows that use `FileReader.readAsDataURL()` need a workaround. Use `URL.createObjectURL(file)` directly for `<img src>` previews instead.
### Communication
- **WebRTC is gone** -- any peer-to-peer features (voice/video calls, WebRTC data channels) are completely unavailable.
- **Fetch / XMLHttpRequest** -- standard network requests appear unaffected. Relay WebSocket connections should work normally.
### Native Plugin Workarounds
Several blocked web APIs have Capacitor plugin equivalents that bypass WKWebView restrictions entirely:
| Blocked Web API | Capacitor Alternative |
|---|---|
| Web Share | `@capacitor/share` (already installed) |
| Notifications | `@capacitor/local-notifications` (already installed) |
| File downloads | `@capacitor/filesystem` + share (already implemented in `downloadFile.ts`) |
### Detection
The report used a scoring heuristic (8/12 key APIs blocked = 70%) to detect Lockdown Mode. There is no official API to query Lockdown Mode status. Detection relies on probing for the absence of multiple APIs that are specifically disabled by Lockdown Mode but normally present in Safari.
## Raw Diagnostic Report
For exact error messages, navigator properties, weight scores, and per-API diagnostic output, see [ios-report.txt](ios-report.txt).
## Guidance for Feature Decisions
When building new features, consider:
1. **Always provide pure-JS fallbacks** for any crypto or data-processing library that might ship a WASM build.
2. **Never depend on IndexedDB or OPFS** as the sole storage mechanism. Both are completely stripped. Always fall back to localStorage or in-memory stores.
3. **Avoid WebGL-dependent UI** for core functionality. Use it as a progressive enhancement with a CSS/Canvas 2D fallback.
4. **Use Capacitor plugins** for sharing, notifications, and file operations rather than web APIs -- they work on all native platforms regardless of Lockdown Mode.
5. **Test on a Lockdown Mode device** when shipping features that touch storage, crypto, or media APIs.
+229
View File
@@ -0,0 +1,229 @@
============================================================
LOCKDOWN MODE DETECTOR REPORT
2026-04-06T23:40:58.170Z
============================================================
VERDICT: Lockdown Mode Likely Active
8 of 12 key APIs are blocked, consistent with iOS/macOS Lockdown Mode.
Score: 70% (8/12 key APIs blocked)
============================================================
API TEST RESULTS (detailed)
============================================================
------------------------------------------------------------
[BLOCKED] IndexedDB (weight: 3)
Client-side structured storage
Result: Can't find variable: indexedDB
Diagnostics:
uncaught: Can't find variable: indexedDB
------------------------------------------------------------
[BLOCKED] WebAssembly (weight: 2)
Binary instruction execution
Result: WebAssembly is undefined
Diagnostics:
typeof WebAssembly: undefined
WebAssembly global does not exist
------------------------------------------------------------
[BLOCKED] Web Locks API (weight: 3)
Cross-tab resource coordination
Result: navigator.locks is undefined
Diagnostics:
typeof navigator.locks: undefined
'locks' in navigator: false
navigator.locks is falsy
------------------------------------------------------------
[BLOCKED] Speech Synthesis (weight: 3)
Web Speech API (text-to-speech)
Result: window.speechSynthesis is undefined
Diagnostics:
typeof window.speechSynthesis: undefined
'speechSynthesis' in window: false
typeof SpeechSynthesisUtterance: undefined
speechSynthesis is falsy
------------------------------------------------------------
[BLOCKED] FileReader API (weight: 2)
Local file reading interface
Result: FileReader is undefined
Diagnostics:
typeof FileReader: undefined
FileReader constructor does not exist
------------------------------------------------------------
[AVAILABLE] File Constructor (weight: 2)
File object creation
Result: File created: name=test.txt size=4
Diagnostics:
typeof File: function
calling new File(['test'], 'test.txt', {type:'text/plain'})...
succeeded
f.name: test.txt
f.size: 4
f.type: text/plain
f instanceof Blob: true
------------------------------------------------------------
[BLOCKED] WebGL (weight: 2)
GPU-accelerated graphics
Result: all WebGL contexts returned null
Diagnostics:
getContext('webgl2'): null
getContext('webgl'): null
getContext('experimental-webgl'): null
------------------------------------------------------------
[BLOCKED] WebGL2 (weight: 1)
Advanced GPU graphics context
Result: getContext('webgl2') returned null
Diagnostics:
getContext('webgl2'): null
------------------------------------------------------------
[BLOCKED] Service Worker (weight: 1)
Background script registration
Result: navigator.serviceWorker not present
Diagnostics:
'serviceWorker' in navigator: false
typeof navigator.serviceWorker: undefined
------------------------------------------------------------
[BLOCKED] Web Share API (weight: 0)
Native sharing interface
Result: navigator.share is undefined
Diagnostics:
typeof navigator.share: undefined
typeof navigator.canShare: undefined
------------------------------------------------------------
[BLOCKED] Gamepad API (weight: 1)
Game controller input
Result: navigator.getGamepads not present
Diagnostics:
'getGamepads' in navigator: false
------------------------------------------------------------
[BLOCKED] WebRTC (weight: 2)
Real-time peer communication
Result: RTCPeerConnection is undefined
Diagnostics:
typeof RTCPeerConnection: undefined
typeof webkitRTCPeerConnection: undefined
------------------------------------------------------------
[AVAILABLE] FontFace API (weight: 1)
Dynamic font loading
Result: status: loaded
Diagnostics:
typeof FontFace: function
new FontFace() succeeded
ff.status: unloaded
ff.family: test
ff.status after load: loaded
------------------------------------------------------------
[AVAILABLE] Remote Fonts (weight: 2)
Loading fonts from network via data URI
Result: API works, load rejected: A network error occurred.
Diagnostics:
FontFace created with data URI
ff.status before load: unloaded
caught: DOMException: A network error occurred.
------------------------------------------------------------
[AVAILABLE] JIT Compilation (weight: 2)
JavaScript JIT optimization heuristic
Result: 99.0ms for 1M iterations (JIT likely)
Diagnostics:
running 1,000,000 iterations of Math.sqrt*Math.sin...
elapsed: 99.00ms
sum (to prevent dead-code elimination): -681.7597
threshold: <150ms suggests JIT active
verdict: likely JIT
------------------------------------------------------------
[BLOCKED] Notifications API (weight: 1)
Push notification permission
Result: Notification not in window
Diagnostics:
'Notification' in window: false
typeof Notification: undefined
------------------------------------------------------------
[BLOCKED] WebCodecs (weight: 1)
Low-level VideoDecoder API
Result: VideoDecoder is undefined
Diagnostics:
typeof VideoDecoder: undefined
typeof VideoEncoder: undefined
typeof AudioDecoder: function
------------------------------------------------------------
[AVAILABLE] PDF Embed (weight: 2)
Inline PDF rendering via embed/pdfViewerEnabled
Result: pdfViewerEnabled is true
Diagnostics:
navigator.pdfViewerEnabled: true
typeof navigator.pdfViewerEnabled: boolean
created and appended <embed type=application/pdf>
navigator.mimeTypes['application/pdf']: [object MimeType]
------------------------------------------------------------
[BLOCKED] SharedArrayBuffer (weight: 1)
Shared memory between workers
Result: SharedArrayBuffer is undefined
Diagnostics:
typeof SharedArrayBuffer: undefined
requires COOP/COEP headers to be present; may not indicate Lockdown Mode specifically
------------------------------------------------------------
[BLOCKED] Cache API (weight: 1)
Programmatic HTTP cache (CacheStorage)
Result: caches not in window
Diagnostics:
'caches' in window: false
typeof caches: undefined
------------------------------------------------------------
[BLOCKED] OPFS (weight: 2)
Origin Private File System (navigator.storage.getDirectory)
Result: navigator.storage.getDirectory is not a function
Diagnostics:
typeof navigator.storage: object
typeof navigator.storage.getDirectory: undefined
getDirectory method does not exist
============================================================
NAVIGATOR INFO
============================================================
userAgent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1
platform: iPhone
vendor: Apple Computer, Inc.
language: en-US
languages: en-US
cookieEnabled: true
doNotTrack: null
maxTouchPoints: 5
hardwareConcurrency: 4
deviceMemory: N/A
pdfViewerEnabled: true
webdriver: false
connection: unavailable
mediaDevices: unavailable
storage: available
serviceWorker: unavailable
credentials: available
bluetooth: unavailable
gpu (WebGPU): unavailable
screenWidth: 393
screenHeight: 852
devicePixelRatio: 3
colorDepth: 24
============================================================
END OF REPORT
============================================================
+3 -1
View File
@@ -2,4 +2,6 @@ VITE_SENTRY_DSN="https://********************************@*****************.exam
VITE_PLAUSIBLE_DOMAIN="example.tld"
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey")
VITE_NOSTR_PUSH_PUBKEY=""
VITE_NOSTR_PUSH_PUBKEY=""
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
# ALLOWED_HOSTS="*"
+18 -1
View File
@@ -57,6 +57,22 @@ deploy-nsite:
--use-fallback-relays
--use-fallback-servers
build-web:
stage: build
timeout: 10 minutes
needs: []
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
script:
- npm ci
- npm run build
- cp dist/index.html dist/404.html
artifacts:
paths:
- dist/
build-apk:
stage: build
image: eclipse-temurin:21-jdk
@@ -129,8 +145,9 @@ build-apk:
- npx vite build -l error
- cp dist/index.html dist/404.html
# Sync web assets to Capacitor Android project
# Sync web assets to Capacitor Android project and register local plugins
- npx cap sync android
- node scripts/patch-cap-config.mjs
# Build signed release APK
- cd android && chmod +x gradlew && ./gradlew assembleRelease bundleRelease && cd ..
+28 -4
View File
@@ -699,23 +699,47 @@ The `useCurrentUser` hook should be used to ensure that the user is logged in be
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a read-modify-write cycle: fetch the current event, modify its tags, then publish a new version. **Never read from TanStack Query cache before mutating** -- the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation:
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
```typescript
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// Inside a mutation function:
const freshEvent = await fetchFreshEvent(nostr, {
const prev = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = freshEvent?.tags ?? [];
const currentTags = prev?.tags ?? [];
// ...modify tags...
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
await publishEvent({
kind: 10003,
content: prev?.content ?? '',
tags: newTags,
prev: prev ?? undefined,
});
```
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
#### The `prev` Property on Event Templates
`useNostrPublish` accepts an optional `prev` property on the event template. This is the **previous version** of the event being replaced. The hook uses it to automatically manage the `published_at` tag (NIP-24) for replaceable and addressable events:
- **First publish (no `prev`)**: `published_at` is set equal to `created_at`
- **Update (`prev` provided)**: `published_at` is preserved from the old event
- **Old event lacks `published_at`**: nothing is fabricated
- **Caller already set `published_at` in tags**: left alone
**Convention**: Name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
```typescript
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// ...
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
```
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
### D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
+43
View File
@@ -1,5 +1,48 @@
# Changelog
## [2.6.2] - 2026-04-08
### Added
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
- "Write a letter" option on profile menus for a more personal way to reach out
- Push vs persistent notification delivery option on Android
### Changed
- Webxdc games and apps always open fullscreen for a more immersive experience
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
- Profile fields now appear inline instead of in a separate right sidebar
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
### Fixed
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
- File downloads now save directly to Documents on iOS and Android instead of silently failing
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
- iOS swipe-back navigation works correctly throughout the app
- Blobbi companions appear reliably on profiles instead of sometimes going missing
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
## [2.6.1] - 2026-04-06
### Added
- Manage your interest tabs (hashtags and locations) from the settings page
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
- Follow packs and follow sets now show author info and action headers in the feed
- Posts now show whether they were created or updated, so you can tell when something's been edited
### Changed
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
- Nsite previews now use the same secure sandbox as webxdc apps
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
### Fixed
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
- Mobile compose box no longer randomly collapses or becomes unclickable
- Profile avatar and banner lightbox no longer hides behind the right sidebar
- Infinite scroll on custom profile tab feeds no longer reloads the same content
- Reaction emoji are now visible on each row in the interactions modal
- Missing bottom border on collapsed thread expand button restored
## [2.6.0] - 2026-04-05
### Added
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.6.0"
versionName "2.6.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2
View File
@@ -11,9 +11,11 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
implementation project(':capacitor-secure-storage-plugin')
}
@@ -1,7 +1,10 @@
package pub.ditto.app;
import android.content.SharedPreferences;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.util.Log;
import com.getcapacitor.Plugin;
@@ -14,6 +17,10 @@ import org.json.JSONArray;
/**
* Capacitor plugin that allows the JS layer to configure the native
* notification polling service with the user's pubkey and relay URLs.
*
* Supports two notification styles:
* - "push" (default): no foreground service, relies on push notifications
* - "persistent": starts NotificationRelayService as a foreground service
*/
@CapacitorPlugin(name = "DittoNotification")
public class DittoNotificationPlugin extends Plugin {
@@ -24,6 +31,7 @@ public class DittoNotificationPlugin extends Plugin {
@PluginMethod
public void configure(PluginCall call) {
String userPubkey = call.getString("userPubkey");
String notificationStyle = call.getString("notificationStyle", "push");
String relayUrlsRaw = null;
String enabledKindsRaw = null;
String authorsRaw = null;
@@ -60,7 +68,8 @@ public class DittoNotificationPlugin extends Plugin {
if (userPubkey != null && relayUrlsRaw != null) {
SharedPreferences.Editor editor = prefs.edit()
.putString("userPubkey", userPubkey)
.putString("relayUrls", relayUrlsRaw);
.putString("relayUrls", relayUrlsRaw)
.putString("notificationStyle", notificationStyle);
if (enabledKindsRaw != null) {
editor.putString("enabledKinds", enabledKindsRaw);
}
@@ -70,13 +79,46 @@ public class DittoNotificationPlugin extends Plugin {
editor.remove("authors");
}
editor.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., style=" + notificationStyle + ", relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
} else {
// Clear config (user logged out)
prefs.edit().clear().apply();
Log.d(TAG, "Config cleared (user logged out)");
}
// Start or stop the foreground service based on style
manageService(notificationStyle, userPubkey != null && relayUrlsRaw != null);
call.resolve();
}
/**
* Start the foreground service when style is "persistent" and config is valid.
* Stop it otherwise.
*/
private void manageService(String style, boolean hasConfig) {
Context ctx = getContext();
Intent serviceIntent = new Intent(ctx, NotificationRelayService.class);
if ("persistent".equals(style) && hasConfig) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(serviceIntent);
} else {
ctx.startService(serviceIntent);
}
Log.d(TAG, "Started NotificationRelayService (persistent mode)");
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w(TAG, "Could not start foreground service: " + e.getMessage());
} else {
Log.w(TAG, "Failed to start service", e);
}
}
} else {
ctx.stopService(serviceIntent);
Log.d(TAG, "Stopped NotificationRelayService (push mode or no config)");
}
}
}
@@ -1,7 +1,9 @@
package pub.ditto.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@@ -11,32 +13,36 @@ import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
private static final String PREFS_NAME = "ditto_notification_config";
@Override
protected void onCreate(Bundle savedInstanceState) {
// Register the native notification config plugin before super.onCreate
// Register native plugins before super.onCreate.
registerPlugin(DittoNotificationPlugin.class);
registerPlugin(SandboxPlugin.class);
super.onCreate(savedInstanceState);
// Start the persistent relay connection service.
// On Android 12+ (API 31+) the system may throw
// ForegroundServiceStartNotAllowedException if the foreground service
// time limit for this type has already been exhausted. We catch it so
// the app continues to run normally; the alarm inside the service will
// retry at the next scheduled interval.
try {
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
startService(serviceIntent);
}
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
} else {
throw e;
// Only start the foreground service if the user has opted into
// "persistent" notification style. Default is "push" (no service).
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String style = prefs.getString("notificationStyle", "push");
if ("persistent".equals(style)) {
try {
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
startService(serviceIntent);
}
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
} else {
throw e;
}
}
}
@@ -0,0 +1,469 @@
package pub.ditto.app;
import android.graphics.Color;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.JavascriptInterface;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
*
* Each sandbox uses shouldInterceptRequest to intercept all requests and forward
* them to the JS layer as fetch events — the same protocol iframe.diy uses.
* The React code can serve files identically regardless of platform.
*/
@CapacitorPlugin(name = "SandboxPlugin")
public class SandboxPlugin extends Plugin {
private static final String TAG = "SandboxPlugin";
private final Map<String, SandboxInstance> sandboxes = new HashMap<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
@PluginMethod
public void create(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject frame = call.getObject("frame");
if (frame == null) {
call.reject("Missing required parameter: frame");
return;
}
int x = frame.optInt("x", 0);
int y = frame.optInt("y", 0);
int width = frame.optInt("width", 0);
int height = frame.optInt("height", 0);
if (sandboxes.containsKey(sandboxId)) {
call.reject("Sandbox already exists: " + sandboxId);
return;
}
float density = getActivity().getResources().getDisplayMetrics().density;
int pxX = Math.round(x * density);
int pxY = Math.round(y * density);
int pxWidth = Math.round(width * density);
int pxHeight = Math.round(height * density);
mainHandler.post(() -> {
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
sandboxes.put(sandboxId, sandbox);
// Add the WebView on top of the Capacitor WebView.
// The parent is a CoordinatorLayout — using the wrong LayoutParams
// type causes a ClassCastException when it intercepts touch events.
View capWebView = getBridge().getWebView();
ViewGroup parent = (ViewGroup) capWebView.getParent();
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
parent.addView(sandbox.webView, params);
// Load the initial page.
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
call.resolve();
});
}
@PluginMethod
public void updateFrame(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject frame = call.getObject("frame");
if (frame == null) {
call.reject("Missing required parameter: frame");
return;
}
int x = frame.optInt("x", 0);
int y = frame.optInt("y", 0);
int width = frame.optInt("width", 0);
int height = frame.optInt("height", 0);
float density = getActivity().getResources().getDisplayMetrics().density;
int pxX = Math.round(x * density);
int pxY = Math.round(y * density);
int pxWidth = Math.round(width * density);
int pxHeight = Math.round(height * density);
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
sandbox.webView.setLayoutParams(params);
call.resolve();
});
}
@PluginMethod
public void respondToFetch(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
String requestId = call.getString("requestId");
if (requestId == null) {
call.reject("Missing required parameter: requestId");
return;
}
JSObject response = call.getObject("response");
if (response == null) {
call.reject("Missing required parameter: response");
return;
}
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
int status = response.optInt("status", 200);
String statusText = response.optString("statusText", "OK");
String bodyBase64 = response.optString("body", null);
Map<String, String> headers = new HashMap<>();
JSONObject headersObj = response.optJSONObject("headers");
if (headersObj != null) {
for (java.util.Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
String key = it.next();
headers.put(key, headersObj.optString(key));
}
}
sandbox.resolveRequest(requestId, status, statusText, headers, bodyBase64);
call.resolve();
}
@PluginMethod
public void postMessage(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject message = call.getObject("message");
if (message == null) {
call.reject("Missing required parameter: message");
return;
}
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
call.resolve();
}
@PluginMethod
public void destroy(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.remove(sandboxId);
if (sandbox != null) {
ViewGroup parent = (ViewGroup) sandbox.webView.getParent();
if (parent != null) {
parent.removeView(sandbox.webView);
}
sandbox.webView.destroy();
}
call.resolve();
});
}
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
JSObject data = new JSObject();
data.put("id", sandboxId);
data.put("requestId", requestId);
data.put("request", request);
notifyListeners("fetch", data);
}
void emitScriptMessage(String sandboxId, JSObject message) {
JSObject data = new JSObject();
data.put("id", sandboxId);
data.put("message", message);
notifyListeners("scriptMessage", data);
}
/**
* A single sandboxed WebView instance.
*/
private static class SandboxInstance {
final String id;
final WebView webView;
final SandboxPlugin plugin;
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
SandboxInstance(String id, SandboxPlugin plugin) {
this.id = id;
this.plugin = plugin;
this.webView = new WebView(plugin.getActivity());
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setDatabaseEnabled(true);
webView.setBackgroundColor(Color.WHITE);
// Add JavaScript interface for script->native communication.
webView.addJavascriptInterface(new SandboxBridge(this), "__sandboxNative");
// Inject the bridge script and intercept requests.
webView.setWebViewClient(new SandboxWebViewClient(this));
}
void postMessageToWebView(String jsonString) {
String js = "(function() { " +
"if (window.__sandboxBridge && window.__sandboxBridge.onMessage) { " +
"window.__sandboxBridge.onMessage(" + jsonString + "); " +
"} " +
"})();";
webView.evaluateJavascript(js, null);
}
void resolveRequest(String requestId, int status, String statusText,
Map<String, String> headers, String bodyBase64) {
PendingRequest pending = pendingRequests.remove(requestId);
if (pending == null) return;
byte[] bodyBytes = null;
if (bodyBase64 != null && !bodyBase64.equals("null")) {
try {
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
} catch (Exception e) {
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
}
}
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
String encoding = contentType.contains("text/") ? "UTF-8" : null;
InputStream body = bodyBytes != null
? new ByteArrayInputStream(bodyBytes)
: new ByteArrayInputStream(new byte[0]);
WebResourceResponse response = new WebResourceResponse(
contentType, encoding, status, statusText, headers, body
);
pending.resolve(response);
}
}
/**
* WebViewClient that intercepts all requests and forwards them to JS.
*/
private static class SandboxWebViewClient extends WebViewClient {
private final SandboxInstance sandbox;
private boolean bridgeInjected = false;
SandboxWebViewClient(SandboxInstance sandbox) {
this.sandbox = sandbox;
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
// Only intercept requests to the sandbox domain.
if (!url.contains(".sandbox.native")) {
return null;
}
String requestId = UUID.randomUUID().toString();
// Create a pending request with a blocking latch.
PendingRequest pending = new PendingRequest();
sandbox.pendingRequests.put(requestId, pending);
// Rewrite URL to include the sandbox ID for the JS handler.
String path = request.getUrl().getPath();
if (path == null || path.isEmpty()) path = "/";
String rewrittenURL = "https://" + sandbox.id + ".sandbox.native" + path;
// Serialise the request.
JSObject serialisedRequest = new JSObject();
serialisedRequest.put("url", rewrittenURL);
serialisedRequest.put("method", request.getMethod());
JSObject headers = new JSObject();
for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
headers.put(entry.getKey(), entry.getValue());
}
serialisedRequest.put("headers", headers);
serialisedRequest.put("body", JSONObject.NULL);
// Emit to JS.
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
// Block this thread until JS responds (with a timeout).
WebResourceResponse response = pending.awaitResponse(10000);
if (response != null) {
return response;
}
// Timeout — return error response.
sandbox.pendingRequests.remove(requestId);
return new WebResourceResponse(
"text/plain", "UTF-8", 504,
"Gateway Timeout", new HashMap<>(),
new ByteArrayInputStream("Request timed out".getBytes())
);
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
if (!bridgeInjected) {
bridgeInjected = true;
view.evaluateJavascript(getBridgeScript(), null);
}
}
private String getBridgeScript() {
return "(function() {" +
"'use strict';" +
"var messageListeners = [];" +
"window.__sandboxBridge = {" +
" onMessage: function(data) {" +
" var event = {" +
" data: data," +
" origin: 'https://" + sandbox.id + ".sandbox.native'," +
" source: window.parent," +
" type: 'message'" +
" };" +
" for (var i = 0; i < messageListeners.length; i++) {" +
" try { messageListeners[i](event); } catch(e) {}" +
" }" +
" }" +
"};" +
"var origAdd = window.addEventListener;" +
"window.addEventListener = function(type, fn, opts) {" +
" if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);" +
" return origAdd.call(window, type, fn, opts);" +
"};" +
"var origRemove = window.removeEventListener;" +
"window.removeEventListener = function(type, fn, opts) {" +
" if (type === 'message') {" +
" var idx = messageListeners.indexOf(fn);" +
" if (idx !== -1) messageListeners.splice(idx, 1);" +
" }" +
" return origRemove.call(window, type, fn, opts);" +
"};" +
"if (!window.parent || window.parent === window) window.parent = {};" +
"window.parent.postMessage = function(data) {" +
" if (data && typeof data === 'object' && data.jsonrpc === '2.0') {" +
" try { window.__sandboxNative.postMessage(JSON.stringify(data)); } catch(e) {}" +
" }" +
"};" +
"})();";
}
}
/**
* JavaScript interface exposed to the sandbox WebView.
*/
private static class SandboxBridge {
private final SandboxInstance sandbox;
SandboxBridge(SandboxInstance sandbox) {
this.sandbox = sandbox;
}
@JavascriptInterface
public void postMessage(String json) {
try {
JSONObject obj = new JSONObject(json);
JSObject jsObj = new JSObject();
for (java.util.Iterator<String> it = obj.keys(); it.hasNext(); ) {
String key = it.next();
jsObj.put(key, obj.get(key));
}
sandbox.plugin.emitScriptMessage(sandbox.id, jsObj);
} catch (JSONException e) {
Log.w(TAG, "Failed to parse script message", e);
}
}
}
/**
* A pending request that blocks the WebViewClient thread until resolved.
*/
private static class PendingRequest {
private WebResourceResponse response;
private final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
void resolve(WebResourceResponse response) {
this.response = response;
latch.countDown();
}
WebResourceResponse awaitResponse(long timeoutMs) {
try {
latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return response;
}
}
}
+6
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-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
@@ -16,3 +19,6 @@ project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/sh
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':capacitor-secure-storage-plugin'
project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android')
+7 -2
View File
@@ -17,9 +17,14 @@ const config: CapacitorConfig = {
},
ios: {
backgroundColor: '#14161f',
contentInset: 'automatic',
contentInset: 'never',
scheme: 'Ditto'
}
},
plugins: {
Keyboard: {
resizeOnFullScreen: true,
},
},
};
export default config;
+1 -1
View File
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
import customRules from "./eslint-rules/index.js";
export default tseslint.config(
{ ignores: ["dist", "android"] },
{ ignores: ["dist", "android", "ios"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
+10 -2
View File
@@ -15,6 +15,8 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -28,6 +30,8 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
B1A2C3D40001000100000002 /* SandboxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPlugin.swift; sourceTree = "<group>"; };
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoBridgeViewController.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -64,6 +68,8 @@
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
@@ -156,6 +162,8 @@
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -303,7 +311,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.0;
MARKETING_VERSION = 2.6.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,7 +333,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.0;
MARKETING_VERSION = 2.6.2;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+1 -1
View File
@@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="DittoBridgeViewController" customModule="App" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
@@ -0,0 +1,9 @@
import UIKit
import Capacitor
class DittoBridgeViewController: CAPBridgeViewController {
override func capacitorDidLoad() {
super.capacitorDidLoad()
webView?.allowsBackForwardNavigationGestures = true
}
}
+475
View File
@@ -0,0 +1,475 @@
import Foundation
import Capacitor
import WebKit
// MARK: - Plugin
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
///
/// Each sandbox gets a unique custom URL scheme (`sbx-<id>://`) so that
/// every embedded app has its own origin (separate localStorage, cookies, etc.).
/// All requests on the custom scheme are intercepted via `WKURLSchemeHandler`
/// and forwarded to the JS layer as fetch events the same protocol
/// iframe.diy uses. This lets the existing React code serve files identically.
@objc(SandboxPlugin)
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "SandboxPlugin"
public let jsName = "SandboxPlugin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateFrame", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "postMessage", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "destroy", returnType: CAPPluginReturnPromise),
]
/// Active sandbox instances, keyed by sandbox ID.
private var sandboxes: [String: SandboxInstance] = [:]
// MARK: - Plugin Methods
@objc func create(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let frame = call.getObject("frame"),
let x = frame["x"] as? Double,
let y = frame["y"] as? Double,
let width = frame["width"] as? Double,
let height = frame["height"] as? Double else {
call.reject("Missing or invalid parameter: frame")
return
}
if sandboxes[sandboxId] != nil {
call.reject("Sandbox already exists: \(sandboxId)")
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let webViewFrame = CGRect(x: x, y: y, width: width, height: height)
let sandbox = SandboxInstance(
id: sandboxId,
frame: webViewFrame,
plugin: self
)
self.sandboxes[sandboxId] = sandbox
// Add the WebView on top of the Capacitor WebView.
if let bridge = self.bridge,
let webView = bridge.webView {
webView.superview?.addSubview(sandbox.webView)
}
call.resolve()
}
}
@objc func updateFrame(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let frame = call.getObject("frame"),
let x = frame["x"] as? Double,
let y = frame["y"] as? Double,
let width = frame["width"] as? Double,
let height = frame["height"] as? Double else {
call.reject("Missing or invalid parameter: frame")
return
}
DispatchQueue.main.async { [weak self] in
guard let sandbox = self?.sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.webView.frame = CGRect(x: x, y: y, width: width, height: height)
call.resolve()
}
}
@objc func respondToFetch(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let requestId = call.getString("requestId") else {
call.reject("Missing required parameter: requestId")
return
}
guard let response = call.getObject("response") else {
call.reject("Missing required parameter: response")
return
}
guard let sandbox = sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.schemeHandler.resolveRequest(
requestId: requestId,
status: response["status"] as? Int ?? 200,
statusText: response["statusText"] as? String ?? "OK",
headers: response["headers"] as? [String: String] ?? [:],
bodyBase64: response["body"] as? String
)
call.resolve()
}
@objc func postMessage(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let message = call.getObject("message") else {
call.reject("Missing required parameter: message")
return
}
guard let sandbox = sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
DispatchQueue.main.async {
sandbox.postMessageToWebView(message)
}
call.resolve()
}
@objc func destroy(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
sandbox.webView.removeFromSuperview()
sandbox.schemeHandler.cancelAll()
}
call.resolve()
}
}
// MARK: - Event Forwarding
/// Forward a fetch request from the native WebView to JS.
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
notifyListeners("fetch", data: [
"id": sandboxId,
"requestId": requestId,
"request": request,
])
}
/// Forward a script message from the sandbox to JS.
func emitScriptMessage(sandboxId: String, message: [String: Any]) {
notifyListeners("scriptMessage", data: [
"id": sandboxId,
"message": message,
])
}
}
// MARK: - SandboxInstance
/// Manages a single sandboxed WKWebView instance.
private class SandboxInstance: NSObject, WKScriptMessageHandler {
let id: String
let webView: WKWebView
let schemeHandler: SandboxSchemeHandler
private weak var plugin: SandboxPlugin?
private let customScheme: String
init(id: String, frame: CGRect, plugin: SandboxPlugin) {
self.id = id
self.plugin = plugin
// Each sandbox gets a unique custom URL scheme so that WKWebView
// assigns a distinct origin, isolating localStorage/IndexedDB/cookies.
self.customScheme = "sbx-\(id)"
self.schemeHandler = SandboxSchemeHandler(
sandboxId: id,
scheme: self.customScheme,
plugin: plugin
)
let config = WKWebViewConfiguration()
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: self.customScheme)
// Add a script message handler for communication from injected scripts.
let userContentController = WKUserContentController()
// Inject a bridge script that:
// 1. Provides window.parent.postMessage()-like functionality
// 2. Routes messages through the native bridge
let bridgeScript = WKUserScript(
source: SandboxInstance.bridgeScript(scheme: self.customScheme),
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
userContentController.addUserScript(bridgeScript)
config.userContentController = userContentController
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.defaultWebpagePreferences.allowsContentJavaScript = true
self.webView = WKWebView(frame: frame, configuration: config)
self.webView.isOpaque = false
self.webView.backgroundColor = .white
self.webView.scrollView.bounces = false
super.init()
// Register the message handler after super.init().
userContentController.add(self, name: "sandboxBridge")
// Load the initial page via the custom scheme.
let initialURL = URL(string: "\(self.customScheme)://app/index.html")!
self.webView.load(URLRequest(url: initialURL))
}
/// Post a JSON-RPC message to injected scripts inside the WebView.
func postMessageToWebView(_ message: [String: Any]) {
guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
let jsonString = String(data: jsonData, encoding: .utf8) else {
return
}
let js = """
(function() {
if (window.__sandboxBridge && window.__sandboxBridge.onMessage) {
window.__sandboxBridge.onMessage(\(jsonString));
}
})();
"""
webView.evaluateJavaScript(js, completionHandler: nil)
}
// MARK: - WKScriptMessageHandler
/// Receive messages from injected scripts via webkit.messageHandlers.sandboxBridge.
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "sandboxBridge",
let body = message.body as? [String: Any] else {
return
}
plugin?.emitScriptMessage(sandboxId: id, message: body)
}
// MARK: - Bridge Script
/// JavaScript injected at document start that provides:
/// - `window.parent.postMessage()` emulation via WKScriptMessageHandler
/// - `window.__sandboxBridge.onMessage()` for receiving messages from parent
/// - `window.addEventListener("message", ...)` support for injected scripts
private static func bridgeScript(scheme: String) -> String {
return """
(function() {
'use strict';
// Message listeners registered by injected scripts.
var messageListeners = [];
// Bridge object for native communication.
window.__sandboxBridge = {
onMessage: function(data) {
// Dispatch to all registered message listeners.
var event = {
data: data,
origin: '\(scheme)://app',
source: window.parent,
type: 'message'
};
for (var i = 0; i < messageListeners.length; i++) {
try {
messageListeners[i](event);
} catch (e) {
console.error('[SandboxBridge] Listener error:', e);
}
}
}
};
// Override addEventListener to capture "message" listeners.
var originalAddEventListener = window.addEventListener;
window.addEventListener = function(type, listener, options) {
if (type === 'message' && typeof listener === 'function') {
messageListeners.push(listener);
}
return originalAddEventListener.call(window, type, listener, options);
};
var originalRemoveEventListener = window.removeEventListener;
window.removeEventListener = function(type, listener, options) {
if (type === 'message') {
var idx = messageListeners.indexOf(listener);
if (idx !== -1) messageListeners.splice(idx, 1);
}
return originalRemoveEventListener.call(window, type, listener, options);
};
// Emulate window.parent.postMessage for scripts that use it
// (e.g. the webxdc bridge script, preview injected script).
if (!window.parent || window.parent === window) {
window.parent = {};
}
window.parent.postMessage = function(data, targetOrigin, transfer) {
if (data && typeof data === 'object' && data.jsonrpc === '2.0') {
try {
window.webkit.messageHandlers.sandboxBridge.postMessage(data);
} catch (e) {
console.error('[SandboxBridge] postMessage failed:', e);
}
}
};
})();
""";
}
}
// MARK: - SandboxSchemeHandler
/// WKURLSchemeHandler that intercepts all requests on the sandbox's custom
/// URL scheme and forwards them to the JS layer as fetch events.
private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
private let sandboxId: String
private let scheme: String
private weak var plugin: SandboxPlugin?
/// Pending scheme tasks waiting for a response from JS.
/// Key: requestId (UUID string), Value: the WKURLSchemeTask to respond to.
private var pendingTasks: [String: WKURLSchemeTask] = [:]
private let lock = NSLock()
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
self.sandboxId = sandboxId
self.scheme = scheme
self.plugin = plugin
}
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
let request = urlSchemeTask.request
guard let url = request.url else {
urlSchemeTask.didFailWithError(NSError(
domain: "SandboxPlugin", code: -1,
userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
))
return
}
let requestId = UUID().uuidString
lock.lock()
pendingTasks[requestId] = urlSchemeTask
lock.unlock()
// Serialise the request for the fetch event.
// Rewrite the URL so it looks like a normal HTTP URL to the parent
// (e.g. "sbx-abc123://app/index.html" -> "https://<sandboxId>.sandbox.native/index.html")
// The JS side only cares about the pathname.
var headers: [String: String] = [:]
if let allHeaders = request.allHTTPHeaderFields {
headers = allHeaders
}
var bodyBase64: String? = nil
if let bodyData = request.httpBody {
bodyBase64 = bodyData.base64EncodedString()
}
let path = url.path.isEmpty ? "/" : url.path
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
let serialisedRequest: [String: Any] = [
"url": rewrittenURL,
"method": request.httpMethod ?? "GET",
"headers": headers,
"body": bodyBase64 as Any,
]
plugin?.emitFetchRequest(
sandboxId: sandboxId,
requestId: requestId,
request: serialisedRequest
)
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
// Remove the task from pending JS response will be ignored if it arrives later.
lock.lock()
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
if let key = removed?.key {
pendingTasks.removeValue(forKey: key)
}
lock.unlock()
}
/// Called by the plugin when JS responds to a fetch request.
func resolveRequest(
requestId: String,
status: Int,
statusText: String,
headers: [String: String],
bodyBase64: String?
) {
lock.lock()
guard let task = pendingTasks.removeValue(forKey: requestId) else {
lock.unlock()
return
}
lock.unlock()
// Decode the base64 body.
var bodyData: Data? = nil
if let b64 = bodyBase64 {
bodyData = Data(base64Encoded: b64)
}
// Build the response.
// Use the task's original URL for the response.
let responseURL = task.request.url ?? URL(string: "\(scheme)://app/")!
let response = HTTPURLResponse(
url: responseURL,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
DispatchQueue.main.async {
task.didReceive(response)
if let data = bodyData {
task.didReceive(data)
}
task.didFinish()
}
}
/// Cancel all pending tasks (called on destroy).
func cancelAll() {
lock.lock()
let tasks = pendingTasks
pendingTasks.removeAll()
lock.unlock()
for (_, task) in tasks {
task.didFailWithError(NSError(
domain: "SandboxPlugin", code: -999,
userInfo: [NSLocalizedDescriptionKey: "Sandbox destroyed"]
))
}
}
}
+6 -2
View File
@@ -14,9 +14,11 @@ 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: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar"),
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
],
targets: [
.target(
@@ -26,9 +28,11 @@ let package = Package(
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
.product(name: "CapacitorShare", package: "CapacitorShare"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar"),
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
]
)
]
+118 -119
View File
@@ -1,16 +1,17 @@
{
"name": "ditto",
"version": "2.5.2",
"version": "2.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.5.2",
"version": "2.6.1",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/keyboard": "^8.0.2",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capacitor/status-bar": "^8.0.0",
@@ -59,7 +60,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.4.1",
"@nostrify/react": "^0.5.0",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -95,6 +96,7 @@
"@unhead/react": "^2.0.10",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
@@ -379,6 +381,15 @@
"@capacitor/core": "^8.2.0"
}
},
"node_modules/@capacitor/keyboard": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.2.tgz",
"integrity": "sha512-he6xKmTBp5AhVrWJeEi6RYkJ25FjLLdNruBU2wafpITk3Nb7UdzOj96x3K6etFuEj8/rtn9WXBTs1o2XA86A1A==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/local-notifications": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.0.1.tgz",
@@ -2389,20 +2400,22 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@noble/ciphers": {
@@ -2527,9 +2540,9 @@
"license": "MIT"
},
"node_modules/@nostrify/react": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.1.tgz",
"integrity": "sha512-2JXxEl4e6FIFhbi96Dwv2knu5qAACYulo1a0oVell/aS8KCWsBTPd1+v0EUra0yqiUA3Q1nVLrk8mx7kQYH/yQ==",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.0.tgz",
"integrity": "sha512-IQf74SSusSIyhI9FkUQSUTsX20yeww5xHIUeexvxcWXEpVhYJYCwduK2yRB75NvYgXjcqYeDUGA2RvzBhDc/eA==",
"dependencies": {
"@nostrify/nostrify": "0.51.1",
"@nostrify/types": "0.36.9"
@@ -2556,9 +2569,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz",
"integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==",
"dev": true,
"license": "MIT",
"funding": {
@@ -5391,9 +5404,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz",
"integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==",
"cpu": [
"arm64"
],
@@ -5408,9 +5421,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz",
"integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==",
"cpu": [
"arm64"
],
@@ -5425,9 +5438,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz",
"integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==",
"cpu": [
"x64"
],
@@ -5442,9 +5455,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz",
"integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==",
"cpu": [
"x64"
],
@@ -5459,9 +5472,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz",
"integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==",
"cpu": [
"arm"
],
@@ -5476,9 +5489,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==",
"cpu": [
"arm64"
],
@@ -5493,9 +5506,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz",
"integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==",
"cpu": [
"arm64"
],
@@ -5510,9 +5523,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==",
"cpu": [
"ppc64"
],
@@ -5527,9 +5540,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==",
"cpu": [
"s390x"
],
@@ -5544,9 +5557,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==",
"cpu": [
"x64"
],
@@ -5561,9 +5574,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz",
"integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==",
"cpu": [
"x64"
],
@@ -5578,9 +5591,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz",
"integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==",
"cpu": [
"arm64"
],
@@ -5595,9 +5608,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz",
"integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==",
"cpu": [
"wasm32"
],
@@ -5605,16 +5618,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
"@emnapi/core": "1.9.1",
"@emnapi/runtime": "1.9.1",
"@napi-rs/wasm-runtime": "^1.1.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz",
"integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==",
"cpu": [
"arm64"
],
@@ -5629,9 +5644,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz",
"integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==",
"cpu": [
"x64"
],
@@ -5699,7 +5714,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5713,7 +5727,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5727,7 +5740,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5741,7 +5753,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5755,7 +5766,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5769,7 +5779,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5783,7 +5792,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5797,7 +5805,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5811,7 +5818,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5825,7 +5831,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5839,7 +5844,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5853,7 +5857,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5867,7 +5870,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5881,7 +5883,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5895,7 +5896,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5909,7 +5909,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5923,7 +5922,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5937,7 +5935,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5951,7 +5948,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5965,7 +5961,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5979,7 +5974,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5993,7 +5987,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6007,7 +6000,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6021,7 +6013,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6035,7 +6026,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7670,6 +7660,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/capacitor-secure-storage-plugin": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.13.0.tgz",
"integrity": "sha512-+rLC/9Z0LTaRRt6L6HjBwcDh5gqgI3NPmDSwo4hk41XQOy3EBrRo81VleIqFsowsMA3oMT+E59Bl8/HiWk0nhQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@@ -12589,14 +12588,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz",
"integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.122.0",
"@rolldown/pluginutils": "1.0.0-rc.12"
"@oxc-project/types": "=0.123.0",
"@rolldown/pluginutils": "1.0.0-rc.13"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -12605,27 +12604,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
"@rolldown/binding-android-arm64": "1.0.0-rc.13",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.13",
"@rolldown/binding-darwin-x64": "1.0.0-rc.13",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.13",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.13",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.13",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.13",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
"dev": true,
"license": "MIT"
},
@@ -14037,16 +14036,16 @@
}
},
"node_modules/vite": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
"integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.12",
"rolldown": "1.0.0-rc.13",
"tinyglobby": "^0.2.15"
},
"bin": {
@@ -14064,7 +14063,7 @@
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
@@ -14653,9 +14652,9 @@
}
},
"node_modules/vite-node/node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15354,9 +15353,9 @@
}
},
"node_modules/vitest/node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+5 -2
View File
@@ -1,12 +1,13 @@
{
"name": "ditto",
"private": true,
"version": "2.6.0",
"version": "2.6.2",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
"cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs",
"keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload",
"icons": "bash scripts/generate-icons.sh"
},
@@ -17,6 +18,7 @@
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/keyboard": "^8.0.2",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capacitor/status-bar": "^8.0.0",
@@ -65,7 +67,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.4.1",
"@nostrify/react": "^0.5.0",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -101,6 +103,7 @@
"@unhead/react": "^2.0.10",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
+43
View File
@@ -1,5 +1,48 @@
# Changelog
## [2.6.2] - 2026-04-08
### Added
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
- "Write a letter" option on profile menus for a more personal way to reach out
- Push vs persistent notification delivery option on Android
### Changed
- Webxdc games and apps always open fullscreen for a more immersive experience
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
- Profile fields now appear inline instead of in a separate right sidebar
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
### Fixed
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
- File downloads now save directly to Documents on iOS and Android instead of silently failing
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
- iOS swipe-back navigation works correctly throughout the app
- Blobbi companions appear reliably on profiles instead of sometimes going missing
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
## [2.6.1] - 2026-04-06
### Added
- Manage your interest tabs (hashtags and locations) from the settings page
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
- Follow packs and follow sets now show author info and action headers in the feed
- Posts now show whether they were created or updated, so you can tell when something's been edited
### Changed
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
- Nsite previews now use the same secure sandbox as webxdc apps
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
### Fixed
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
- Mobile compose box no longer randomly collapses or becomes unclickable
- Profile avatar and banner lightbox no longer hides behind the right sidebar
- Infinite scroll on custom profile tab feeds no longer reloads the same content
- Reaction emoji are now visible on each row in the interactions modal
- Missing bottom border on collapsed thread expand button restored
## [2.6.0] - 2026-04-05
### Added
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Patch capacitor.config.json to include local (non-SPM) plugin classes.
*
* `npx cap sync` regenerates the `packageClassList` array from SPM packages
* only, so local plugins compiled directly into the app binary (like
* SandboxPlugin) are not included. This script appends them after sync so
* the Capacitor bridge eagerly registers them at startup.
*
* Usage: node scripts/patch-cap-config.mjs
* Typically run after `npx cap sync`.
*/
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
/** Local plugin class names to ensure are registered. */
const LOCAL_PLUGINS = ['SandboxPlugin'];
const platforms = ['ios/App/App', 'android/app/src/main/assets'];
for (const platform of platforms) {
const configPath = resolve(platform, 'capacitor.config.json');
let config;
try {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
} catch {
// Platform may not exist or config not yet generated — skip.
continue;
}
const classList = new Set(config.packageClassList ?? []);
let changed = false;
for (const plugin of LOCAL_PLUGINS) {
if (!classList.has(plugin)) {
classList.add(plugin);
changed = true;
}
}
if (changed) {
config.packageClassList = [...classList];
writeFileSync(configPath, JSON.stringify(config, null, '\t') + '\n');
console.log(`Patched ${configPath}: added ${LOCAL_PLUGINS.join(', ')}`);
}
}
+4 -1
View File
@@ -24,6 +24,7 @@ import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
@@ -149,6 +150,8 @@ const hardcodedConfig: AppConfig = {
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
savedFeeds: [],
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
};
/**
@@ -198,7 +201,7 @@ export function App() {
<SentryProvider>
<PlausibleProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey="nostr:login">
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<NostrProvider>
<NostrSync />
<NativeNotifications />
@@ -1,19 +1,16 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo, useState } from 'react';
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
import { useMemo } from 'react';
import { Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
@@ -37,8 +34,8 @@ interface BlobbiActionInventoryModalProps {
action: InventoryAction;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
/** Called when user confirms using item(s). Now accepts quantity. */
onUseItem: (itemId: string, quantity: number) => void;
/** Called when user taps Use on an item. Always uses once. */
onUseItem: (itemId: string) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
@@ -49,24 +46,19 @@ export function BlobbiActionInventoryModal({
onOpenChange,
action,
companion,
profile,
profile: _profile,
onUseItem,
onOpenShop,
onOpenShop: _onOpenShop,
isUsingItem,
usingItemId,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
// State for confirmation dialog
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// Filter inventory by action type, respecting egg-compatible effects
// Get all available items for this action from the catalog (not inventory).
// Items are abilities/tools — no ownership required.
const availableItems = useMemo(() => {
if (!profile) return [];
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
}, [profile, action, companion.stage]);
return filterInventoryByAction([], action, { stage: companion.stage });
}, [action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
@@ -74,46 +66,9 @@ export function BlobbiActionInventoryModal({
const isEmpty = availableItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
const handleUseItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowConfirmDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
// Reset after starting use
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseConfirmDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
const handleOpenShop = () => {
onOpenChange(false);
onOpenShop();
};
// Quantity controls
const maxQuantity = selectedItem?.quantity ?? 1;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxQuantity));
}
onUseItem(item.itemId);
};
return (
@@ -161,14 +116,10 @@ export function BlobbiActionInventoryModal({
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<span className="text-3xl">{actionMeta.icon}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No Items</h3>
<p className="text-sm text-muted-foreground max-w-sm mb-4">
You don't have any items for this action. Visit the shop to get some!
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for this action at your Blobbi's current stage.
</p>
<Button onClick={handleOpenShop} className="gap-2">
<ShoppingBag className="size-4" />
Open Shop
</Button>
</div>
)}
@@ -181,7 +132,7 @@ export function BlobbiActionInventoryModal({
item={item}
companion={companion}
action={action}
onUse={() => handleSelectItem(item)}
onUse={() => handleUseItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
@@ -190,24 +141,6 @@ export function BlobbiActionInventoryModal({
)}
</div>
</DialogContent>
{/* Confirmation Dialog with Quantity Selector */}
{selectedItem && (
<BlobbiUseItemConfirmDialog
open={showConfirmDialog}
onOpenChange={handleCloseConfirmDialog}
item={selectedItem}
companion={companion}
action={action}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
)}
</Dialog>
);
}
@@ -238,15 +171,12 @@ function BlobbiInventoryUseRow({
// Preview stat changes - handle egg-specific preview for medicine and clean
const { normalStatChanges, eggStatChanges } = useMemo(() => {
if (isEgg && isMedicine) {
// For eggs using medicine, show health preview
// Eggs use the 3-stat model: health, hygiene, happiness
return {
normalStatChanges: [],
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
};
}
if (isEgg && isClean) {
// For eggs using hygiene items, show hygiene (and possibly happiness) preview
return {
normalStatChanges: [],
eggStatChanges: previewCleanForEgg(
@@ -255,7 +185,6 @@ function BlobbiInventoryUseRow({
),
};
}
// Normal stats preview
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
@@ -280,16 +209,12 @@ function BlobbiInventoryUseRow({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs shrink-0">
x{item.quantity}
</Badge>
</div>
{/* Effect Preview - shown inline on desktop */}
<div className="hidden sm:block">
{hasChanges && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -308,7 +233,6 @@ function BlobbiInventoryUseRow({
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -350,7 +274,6 @@ function BlobbiInventoryUseRow({
{/* Effect Preview - shown below on mobile */}
{hasChanges && (
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -369,7 +292,6 @@ function BlobbiInventoryUseRow({
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -393,222 +315,3 @@ function BlobbiInventoryUseRow({
</div>
);
}
// ─── Use Item Confirmation Dialog ─────────────────────────────────────────────
interface BlobbiUseItemConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function BlobbiUseItemConfirmDialog({
open,
onOpenChange,
item,
companion,
action,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: BlobbiUseItemConfirmDialogProps) {
const actionMeta = ACTION_METADATA[action];
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes for the selected quantity
const statPreview = useMemo(() => {
if (!item.effect) return { normalChanges: [], eggChanges: [] };
if (isEgg && isMedicine) {
// Calculate health change for N items
const healthDelta = item.effect.health ?? 0;
let currentHealth = companion.stats.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = Math.max(0, Math.min(100, currentHealth + healthDelta));
}
const totalDelta = currentHealth - (companion.stats.health ?? 0);
return {
normalChanges: [],
eggChanges: totalDelta !== 0 ? [{ stat: 'health' as const, delta: totalDelta }] : [],
};
}
if (isEgg && isClean) {
// Calculate hygiene and happiness changes for N items
const hygieneDelta = item.effect.hygiene ?? 0;
const happinessDelta = item.effect.happiness ?? 0;
let currentHygiene = companion.stats.hygiene ?? 0;
let currentHappiness = companion.stats.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = Math.max(0, Math.min(100, currentHygiene + hygieneDelta));
currentHappiness = Math.max(0, Math.min(100, currentHappiness + happinessDelta));
}
const changes: Array<{ stat: 'health' | 'hygiene' | 'happiness'; delta: number }> = [];
const totalHygieneDelta = currentHygiene - (companion.stats.hygiene ?? 0);
const totalHappinessDelta = currentHappiness - (companion.stats.happiness ?? 0);
if (totalHygieneDelta !== 0) changes.push({ stat: 'hygiene', delta: totalHygieneDelta });
if (totalHappinessDelta !== 0) changes.push({ stat: 'happiness', delta: totalHappinessDelta });
return { normalChanges: [], eggChanges: changes };
}
// Normal stats preview - simulate N applications
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
if (delta !== undefined) {
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
}
}
}
const changes: Array<{ stat: string; delta: number }> = [];
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
changes.push({ stat, delta });
}
}
return { normalChanges: changes, eggChanges: [] };
}, [item.effect, companion.stats, quantity, isEgg, isMedicine, isClean]);
const hasChanges = statPreview.normalChanges.length > 0 || statPreview.eggChanges.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>{actionMeta.label}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Item Preview */}
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
<p className="text-sm text-muted-foreground">
{item.quantity} in inventory
</p>
</div>
</div>
{/* Quantity Selector */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
<span className="text-xs text-muted-foreground">
Max: {maxQuantity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onDecrease}
disabled={quantity <= 1 || isUsing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxQuantity}
value={quantity}
onChange={onQuantityChange}
disabled={isUsing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={onIncrease}
disabled={quantity >= maxQuantity || isUsing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Effects Summary */}
{hasChanges && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
</h4>
<div className="flex flex-wrap gap-2">
{statPreview.normalChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
{statPreview.eggChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUsing}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isUsing}
className="min-w-24"
>
{isUsing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Using...
</>
) : (
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -98,13 +98,6 @@ export function InlineSingCard({
cleanup: cleanupPlayback,
} = useAudioPlayback();
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, []);
// Cleanup all resources
const cleanupAll = useCallback(() => {
// Stop timer
@@ -138,6 +131,13 @@ export function InlineSingCard({
}
}, [audioUrl, cleanupPlayback]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, [cleanupAll]);
// Reset recording
const resetRecording = useCallback(() => {
cleanupAll();
+16 -16
View File
@@ -82,22 +82,6 @@ export function SingModal({
// Track the actual MIME type used by the recorder
const actualMimeTypeRef = useRef<string | undefined>(undefined);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, []);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open]);
const cleanup = useCallback(() => {
// Stop timer
if (timerRef.current) {
@@ -142,6 +126,22 @@ export function SingModal({
// Keep lyrics when re-recording so user can sing the same song
}, [cleanup]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open, cleanup, resetRecording]);
// Handle getting random lyrics
const handleRandomLyrics = useCallback(() => {
const lyrics = getRandomLyrics();
@@ -168,7 +168,7 @@ export function useActiveTaskProcess(
}, [processType, hatchTasks, evolveTasks]);
// Extract tasks and state from active result
const tasks = activeResult?.tasks ?? [];
const tasks = useMemo(() => activeResult?.tasks ?? [], [activeResult]);
const isLoading = activeResult?.isLoading ?? false;
const allCompleted = activeResult?.allCompleted ?? false;
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
@@ -73,7 +73,7 @@ export interface UseBlobbiDirectActionParams {
/**
* Hook to execute a direct action on a Blobbi companion.
* Direct actions (play_music, sing) don't consume inventory items.
* Direct actions (play_music, sing) don't require selecting an item.
* They directly affect happiness stat.
*
* This hook:
@@ -6,19 +6,15 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
getStageRestrictionMessage,
clampStat,
@@ -37,23 +33,19 @@ import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
/**
* Request payload for using an inventory item
* Request payload for using an item on a Blobbi companion
*/
export interface UseItemRequest {
itemId: string;
action: InventoryAction;
/** Number of items to use (defaults to 1) */
quantity?: number;
}
/**
* Result of using an inventory item
* Result of using an item on a Blobbi companion
*/
export interface UseItemResult {
itemName: string;
action: InventoryAction;
quantity: number;
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
statsChanged: Record<string, number>;
xpGained: number;
newXP: number;
@@ -71,9 +63,9 @@ export interface UseBlobbiUseInventoryItemParams {
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration (use instead of profile.allTags) */
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration (use instead of profile.storage) */
/** Latest profile storage after migration */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
@@ -86,29 +78,29 @@ export interface UseBlobbiUseInventoryItemParams {
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to use an inventory item on a Blobbi companion.
* Hook to use an item on a Blobbi companion.
*
* Items are reusable abilities sourced from the shop catalog — no
* inventory ownership or quantity is required.
*
* This hook:
* 1. Validates the companion stage (eggs can't use items)
* 2. Validates the item exists in storage
* 3. Ensures canonical format before action
* 4. Applies item effects to Blobbi stats
* 5. Updates Blobbi state (kind 31124)
* 6. Decrements item from profile storage (kind 11125)
* 7. Invalidates relevant queries
* 1. Validates the companion and item compatibility
* 2. Ensures canonical format before action
* 3. Applies accumulated decay, then item effects to Blobbi stats
* 4. Updates Blobbi state (kind 31124)
*/
export function useBlobbiUseInventoryItem({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent,
updateProfileEvent: _updateProfileEvent,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
@@ -122,11 +114,6 @@ export function useBlobbiUseInventoryItem({
throw new Error('Profile not found');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// Check stage restrictions for this specific action
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
@@ -201,78 +188,25 @@ export function useBlobbiUseInventoryItem({
}
}
// ─── Apply Item Effects ───
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
// won't give more than 100 health total.
//
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
// - clean: count when hygiene or happiness INCREASES
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
//
// Use canonical companion stage for egg checks
// ─── Apply Item Effects (single use) ───
const isEggCompanion = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
let effectiveItemCount = 0; // Number of items that produced intended effects
if (isEggCompanion && action === 'medicine') {
// Egg medicine handling:
// Eggs use the 3-stat model: health, hygiene, happiness
// Medicine with health effect directly affects the egg's health stat
// hunger and energy remain fixed at 100 for eggs
const healthDelta = shopItem.effect.health ?? 0;
// Apply health effect N times in sequence with clamping at each step
// Only count items that actually INCREASED health (positive effect only)
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHealth = currentHealth;
currentHealth = applyStat(currentHealth, healthDelta);
// Only count as effective if health increased (not just changed)
if (healthDelta > 0 && currentHealth > prevHealth) {
effectiveItemCount++;
}
}
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
statsUpdate.health = currentHealth.toString();
// Track total actual change (may be less than healthDelta * quantity due to clamping)
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
// Apply decayed values for other egg stats
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
// Egg clean/hygiene handling:
// Hygiene items affect the egg's hygiene stat
// Some hygiene items also give happiness (e.g., bubble bath)
// hunger and energy remain fixed at 100 for eggs
const hygieneDelta = shopItem.effect.hygiene ?? 0;
const happinessDelta = shopItem.effect.happiness ?? 0;
// Apply effects N times in sequence
// Only count items that INCREASED hygiene or happiness (positive effects only)
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHygiene = currentHygiene;
const prevHappiness = currentHappiness;
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
// Count as effective if hygiene OR happiness increased (positive effects only)
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
if (hygieneIncreased || happinessIncreased) {
effectiveItemCount++;
}
}
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
@@ -283,58 +217,12 @@ export function useBlobbiUseInventoryItem({
statsChanged.happiness = totalHappinessChange;
}
// Apply decayed health
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult
// Apply item effects N times in sequence ON TOP of decayed stats
// Use action-aware effectiveness checking for XP calculation
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
const effect = shopItem.effect;
for (let i = 0; i < quantity; i++) {
const prevStats = { ...currentStats };
currentStats = applyItemEffects(currentStats, effect);
// Action-aware effectiveness check:
// Only count INTENDED positive effects, not negative side effects
let isEffective = false;
if (action === 'feed') {
// Feed: count when hunger/energy/health/happiness INCREASE
// Do NOT count hygiene decrease (that's a side effect)
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
} else if (action === 'clean') {
// Clean: count when hygiene or happiness INCREASES
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hygieneIncreased || happinessIncreased;
} else if (action === 'medicine') {
// Medicine: count when health/energy/happiness INCREASE
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = healthIncreased || energyIncreased || happinessIncreased;
} else if (action === 'play') {
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
// Playing naturally consumes energy, so energy decrease counts as valid
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
isEffective = happinessIncreased || energyDecreased;
}
if (isEffective) {
effectiveItemCount++;
}
}
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
@@ -367,11 +255,8 @@ export function useBlobbiUseInventoryItem({
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (Based on effective item count) ───
// Only grant XP for items that actually changed stats.
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
// This prevents XP farming by mass-using items after stats are already maxed.
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
// ─── Apply XP Gain ───
const xpGained = calculateInventoryActionXP(action, 1);
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
@@ -391,48 +276,25 @@ export function useBlobbiUseInventoryItem({
updateCompanionEvent(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
// Only decrement storage if the item actually exists in inventory.
// Items are free to use regardless of inventory state.
const hasItemInStorage = canonical.profileStorage.some(s => s.itemId === itemId && s.quantity > 0);
if (hasItemInStorage) {
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
}
// No query invalidation needed — the optimistic updates above keep the
// Items are free to use — no storage decrement needed.
// No query invalidation needed — the optimistic update above keeps the
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
// before every mutation (read-modify-write pattern).
return {
itemName: shopItem.name,
action,
quantity,
effectiveItemCount, // How many items actually changed stats
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, quantity, xpGained }) => {
onSuccess: ({ itemName, action, xpGained }) => {
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
description: `Used ${itemName} on your Blobbi. ${xpText}`,
});
// Track daily mission progress
+22 -24
View File
@@ -2,23 +2,23 @@
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Action Types ─────────────────────────────────────────────────────────────
/**
* Actions that consume inventory items
* Item-based care actions (use a shop catalog item on the companion)
*/
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Non-inventory actions that don't consume items
* These actions affect stats directly without using shop items.
* Direct actions that don't use items.
* These actions affect stats directly without selecting a shop item.
*/
export type DirectAction = 'play_music' | 'sing';
/**
* All Blobbi actions (inventory + direct)
* All Blobbi actions (item-based + direct)
*/
export type BlobbiAction = InventoryAction | DirectAction;
@@ -33,7 +33,7 @@ export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
};
/**
* Action metadata for UI display (inventory actions)
* Action metadata for UI display (item-based care actions)
*/
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
feed: {
@@ -59,7 +59,7 @@ export const ACTION_METADATA: Record<InventoryAction, { label: string; descripti
};
/**
* Action metadata for direct actions (non-inventory)
* Action metadata for direct actions (no item required)
*/
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
play_music: {
@@ -270,10 +270,10 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
return effects.happiness !== undefined && effects.happiness !== 0;
}
// ─── Inventory Helpers ────────────────────────────────────────────────────────
// ─── Item Helpers ─────────────────────────────────────────────────────────────
/**
* Resolved inventory item with shop metadata
* Resolved catalog item with shop metadata
*/
export interface ResolvedInventoryItem {
itemId: string;
@@ -285,7 +285,7 @@ export interface ResolvedInventoryItem {
}
/**
* Options for filtering inventory by action
* Options for filtering catalog items by action
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
@@ -293,8 +293,8 @@ export interface FilterInventoryOptions {
}
/**
* Filter inventory items by action type.
* Returns resolved items with shop metadata.
* Get all available items for an action type from the shop catalog.
* Items are abilities/tools — no inventory ownership is required.
*
* Filtering rules:
* - Only items matching the action's item type are included
@@ -304,22 +304,20 @@ export interface FilterInventoryOptions {
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
storage: StorageItem[],
_storage: StorageItem[],
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
const allowedType = ACTION_TO_ITEM_TYPE[action];
const result: ResolvedInventoryItem[] = [];
const isEgg = options.stage === 'egg';
const allItems = getLiveShopItems();
for (const storageItem of storage) {
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
for (const shopItem of allItems) {
if (shopItem.type !== allowedType) continue;
if (storageItem.quantity <= 0) continue;
// Shell Repair Kit: only show for eggs in medicine modal
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
@@ -334,8 +332,8 @@ export function filterInventoryByAction(
}
result.push({
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: shopItem.id,
quantity: Infinity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
@@ -376,7 +374,7 @@ export function decrementStorageItem(
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
/**
* Stages that can use general inventory items (food, toys, hygiene)
* Stages that can use general items (food, toys, hygiene)
*/
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
@@ -409,14 +407,14 @@ export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_m
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
/**
* Check if a companion can use a specific inventory action.
* Check if a companion can use a specific item action.
*
* Note: This function no longer hard-blocks egg actions at the domain layer.
* UI visibility is handled separately by `isActionVisibleForStage()`.
* The domain layer allows all actions - UI chooses what to show.
*/
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
// All stages can technically use all inventory actions at the domain layer.
// All stages can technically use all item actions at the domain layer.
// UI filtering determines what actions are shown to users.
return true;
}
@@ -442,7 +440,7 @@ export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action:
}
/**
* Check if a companion can use general inventory items (feed, play, clean).
* Check if a companion can use general items (feed, play, clean).
* Eggs cannot use food, toys, or hygiene items.
* @deprecated Use canUseAction(companion, action) for action-specific checks
*/
+9 -11
View File
@@ -7,8 +7,8 @@
* Design Philosophy:
* - Different actions award different XP to reflect their complexity/value
* - XP values are balanced to encourage variety in care activities
* - Direct actions (sing, play_music) give moderate XP as they're free
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
* - Item actions (feed, play, clean, medicine) give varied XP per action type
* - Direct actions (sing, play_music) give moderate XP
* - XP accumulates across all life stages and never resets
*/
@@ -17,19 +17,18 @@ import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-actio
// ─── XP Values by Action ──────────────────────────────────────────────────────
/**
* Base XP values for inventory actions (feed, play, clean, medicine).
* These actions consume items from the player's storage.
* Base XP values for item-based care actions (feed, play, clean, medicine).
*/
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
feed: 5, // Feeding is common and essential - moderate XP
play: 8, // Playing toys provides good interaction - higher XP
clean: 6, // Hygiene maintenance is important - moderate-high XP
medicine: 10, // Medicine is costly and critical - highest inventory XP
medicine: 10, // Medicine is critical - highest item XP
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't consume items - they're free activities.
* These actions don't require selecting an item.
*/
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
play_music: 7, // Playing music is engaging - good XP
@@ -58,11 +57,10 @@ export function calculateActionXP(action: BlobbiAction): number {
}
/**
* Calculate total XP gain for using multiple items.
* Each item use counts as a separate action for XP purposes.
* Calculate XP gain for an item-based care action.
*
* @param action - The action performed
* @param quantity - Number of items used (defaults to 1)
* @param quantity - Number of times performed (always 1 in current usage)
* @returns Total XP points earned
*/
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
@@ -88,8 +86,8 @@ export function applyXPGain(currentXP: number | undefined, xpGain: number): numb
* Get XP gain summary for displaying to the user.
*
* @param action - The action performed
* @param quantity - Number of times the action was performed (for inventory actions)
* @returns Object with xpGained and total quantity
* @param quantity - Number of times the action was performed (always 1 in current usage)
* @returns Object with xpGained and quantity
*/
export function getXPGainSummary(
action: BlobbiAction,
@@ -161,7 +161,7 @@ export function BlobbiCompanionLayer() {
}
try {
const result = await contextUseItem(item.id, action, 1);
const result = await contextUseItem(item.id, action);
if (result.success) {
if (import.meta.env.DEV) {
@@ -19,7 +19,7 @@ import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
import { useEffectiveEmotion } from '@/blobbi/dev/useEmotionDev';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
@@ -4,11 +4,9 @@
* Fetches the current companion data from the user's Blobbonaut profile.
* This is the data layer - it handles fetching and provides companion data.
*
* IMPORTANT: This hook shares the same query cache as BlobbiPage via
* useBlobbisCollection. This ensures:
* - Immediate reactivity when stats change (optimistic updates)
* - Projected decay is applied for accurate visual reactions
* - No duplicate queries or stale cache issues
* Uses useBlobbisCollection with a targeted dList (single d-tag) for efficiency.
* Optimistic updates from mutations propagate across all blobbi-collection
* queries (including BlobbiPage's 'all' mode) via updateCompanionEvent.
*/
import { useMemo } from 'react';
@@ -32,16 +30,14 @@ interface UseBlobbiCompanionDataResult {
*
* Flow:
* 1. Use useBlobbonautProfile to get the profile (shared query, reactive)
* 2. Build a dList containing just the currentCompanion
* 3. Use useBlobbisCollection (shared with BlobbiPage) to get the companion
* 2. Build a dList containing just the currentCompanion (targeted fetch)
* 3. Use useBlobbisCollection with the dList to get the companion
* 4. Apply projected decay for accurate UI reactions
* 5. Return the companion data with projected stats
*
* Reactivity:
* - Uses the same query cache as BlobbiPage (blobbi-collection)
* - When Blobbi state is updated, optimistic updates flow through immediately
* - Projected decay recalculates every 60 seconds
* - No separate query or stale cache issues
* - Optimistic updates propagate across all blobbi-collection queries
* - Projected decay recalculates every 60 seconds while mounted
*/
export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
// Use the shared profile hook - this ensures reactivity when profile changes
@@ -102,7 +102,7 @@ export function useBlobbiCompanionState({
setState('walking');
setDirection('right');
setTargetX(targetX);
}, [bounds.maxX]);
}, [bounds.maxX, motionRef]);
/**
* Generate a random observation target on screen.
@@ -136,7 +136,7 @@ export function useBlobbiCompanionState({
setState('walking');
setDirection(newDirection);
setTargetX(targetXPos);
}, [bounds, generateObservationTarget]);
}, [bounds, generateObservationTarget, motionRef]);
// Make a decision about what to do next
const makeDecision = useCallback(() => {
@@ -176,7 +176,7 @@ export function useBlobbiCompanionState({
// Schedule next decision
const duration = transition.duration ?? randomDuration(config.idleTime);
timerRef.current = window.setTimeout(makeDecision, duration);
}, [isActive, isSleeping, bounds, state, config, startObservation]);
}, [isActive, isSleeping, bounds, state, config, startObservation, motionRef]);
// Handle reaching target
const onReachedTarget = useCallback(() => {
@@ -255,7 +255,7 @@ export function useBlobbiCompanionState({
clearTimeout(timerRef.current);
}
};
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision]);
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision, motionRef]);
// Pause decisions while dragging
// We poll isDragging via interval since motionRef changes don't trigger re-renders
@@ -15,17 +15,15 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r
import { useBlobbiItemUse } from './useBlobbiItemUse';
import {
BlobbiActionsContext,
BlobbiActionsProvider,
type UseItemFunction,
type UseItemResult,
type BlobbiActionsContextValue,
type BlobbiActionsContextInternal,
} from './BlobbiActionsProvider';
} from './BlobbiActionsContextDef';
// Re-export everything from the provider module for backward compatibility
// Re-export types and context from the def module for backward compatibility
export {
BlobbiActionsContext,
BlobbiActionsProvider,
type UseItemFunction,
type UseItemResult,
type BlobbiActionsContextValue,
@@ -64,13 +62,13 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
// Create stable useItem function that:
// 1. Uses registered function if available (from BlobbiPage)
// 2. Falls back to built-in hook if no registration
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Try registered function first (from BlobbiPage)
if (context?.registerRef.current) {
if (import.meta.env.DEV) {
console.log('[BlobbiActions] Using registered item-use function');
}
return context.registerRef.current(itemId, action, quantity);
return context.registerRef.current(itemId, action);
}
// Check if fallback can handle it
@@ -88,7 +86,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
if (import.meta.env.DEV) {
console.log('[BlobbiActions] Using fallback item-use hook');
}
return fallbackItemUse.useItem(itemId, action, quantity);
return fallbackItemUse.useItem(itemId, action);
}, [context, fallbackItemUse]);
// Determine canUseItems: true if registered OR fallback can use
@@ -136,14 +134,14 @@ export function useBlobbiActionsRegistration(
useItemRef.current = useItemFn;
// Create a stable wrapper that delegates to the ref
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action) => {
if (!useItemRef.current) {
return {
success: false,
error: 'Item use function not available',
};
}
return useItemRef.current(itemId, action, quantity);
return useItemRef.current(itemId, action);
}, []);
// Update refs and notify only when canUseItems actually changes
@@ -0,0 +1,75 @@
/**
* BlobbiActionsContextDef
*
* Lightweight context definition and types for the Blobbi actions system.
* Separated from the provider component to avoid react-refresh warnings.
*/
import { createContext } from 'react';
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of using an item via the context.
*/
export interface UseItemResult {
/** Whether the use was successful */
success: boolean;
/** Stats that changed (key = stat name, value = delta) */
statsChanged?: Record<string, number>;
/** Error message if failed */
error?: string;
}
/**
* Function signature for using an item (always uses once).
*/
export type UseItemFunction = (
itemId: string,
action: InventoryAction,
) => Promise<UseItemResult>;
/**
* Context value for Blobbi actions (consumer side).
*/
export interface BlobbiActionsContextValue {
/**
* Use an item on the current companion.
* Works even without BlobbiPage registration (uses fallback).
*/
useItem: UseItemFunction;
/** Whether an item use operation is currently in progress */
isUsingItem: boolean;
/** Whether items can be used (companion exists and profile loaded) */
canUseItems: boolean;
/** Check if an item is on cooldown (recently attempted) */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item */
clearItemCooldown: (itemId: string) => void;
}
/**
* Internal context value (includes registration functions).
*/
export interface BlobbiActionsContextInternal {
/** Register item-use functionality (called by BlobbiPage) */
registerRef: React.MutableRefObject<UseItemFunction | null>;
/** Whether items can currently be used (via registration) */
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
/** Whether an item is currently being used (via registration) */
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
/** Force update consumers (called sparingly) */
notifyUpdate: () => void;
/** Subscribe to updates */
subscribe: (callback: () => void) => () => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
@@ -10,75 +10,13 @@
* BlobbiPage, both of which are lazy-loaded.
*/
import { createContext, useCallback, useMemo, useRef, type ReactNode } from 'react';
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of using an item via the context.
*/
export interface UseItemResult {
/** Whether the use was successful */
success: boolean;
/** Stats that changed (key = stat name, value = delta) */
statsChanged?: Record<string, number>;
/** Error message if failed */
error?: string;
}
/**
* Function signature for using an item.
*/
export type UseItemFunction = (
itemId: string,
action: InventoryAction,
quantity?: number
) => Promise<UseItemResult>;
/**
* Context value for Blobbi actions (consumer side).
*/
export interface BlobbiActionsContextValue {
/**
* Use an inventory item on the current companion.
* Works even without BlobbiPage registration (uses fallback).
*/
useItem: UseItemFunction;
/** Whether an item use operation is currently in progress */
isUsingItem: boolean;
/** Whether items can be used (companion exists and profile loaded) */
canUseItems: boolean;
/** Check if an item is on cooldown (recently attempted) */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item */
clearItemCooldown: (itemId: string) => void;
}
/**
* Internal context value (includes registration functions).
*/
export interface BlobbiActionsContextInternal {
/** Register item-use functionality (called by BlobbiPage) */
registerRef: React.MutableRefObject<UseItemFunction | null>;
/** Whether items can currently be used (via registration) */
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
/** Whether an item is currently being used (via registration) */
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
/** Force update consumers (called sparingly) */
notifyUpdate: () => void;
/** Subscribe to updates */
subscribe: (callback: () => void) => () => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
import {
BlobbiActionsContext,
type UseItemFunction,
type BlobbiActionsContextInternal,
} from './BlobbiActionsContextDef';
// ─── Provider ─────────────────────────────────────────────────────────────────
@@ -1,26 +1,24 @@
/**
* HangingItems
*
* Displays inventory items as hanging elements from the top of the screen.
* Displays available items as hanging elements from the top of the screen.
* Each item appears as a circle connected to the top by a thin vertical line,
* creating a playful, spatial feel.
*
* Items are reusable abilities sourced from the shop catalog — they are
* always available and not consumed on use.
*
* State Model:
* - Container states: hidden → opening → open → closing → hidden
* - Hanging items = available inventory that can still be released
* - Hanging items = catalog items available for the selected action
* - Released/dropped items = instances currently in the world (tracked with unique IDs)
* - Multiple instances of the same item type can exist simultaneously on the ground
*
* Key Design Principle:
* The hanging row represents "releasable quantity" - clicking releases ONE instance
* and immediately decrements the visible quantity. A new hanging copy remains if
* quantity > 1. The released instance tracks separately with a unique instance ID.
*
* Features:
* - Smooth open/close slide animations (items descend/ascend)
* - Thin vertical lines from the top of screen
* - Circular containers for hanging items
* - Click releases item: one instance falls, remaining quantity stays hanging
* - Click releases item: one instance falls to the ground
* - Multiple dropped instances of same item type can exist
* - Contact detection: items auto-use when touching Blobbi
* - Click-to-use: click landed items to use them
@@ -119,7 +117,7 @@ interface HangingItemsProps {
onItemUse?: (item: CompanionItem) => Promise<ItemUseAttemptResult>;
/**
* Callback when an item is collected by Blobbi (contact).
* @deprecated Use onItemUse instead for proper item consumption flow.
* @deprecated Use onItemUse instead for proper item-use flow.
*/
onItemCollected?: (item: CompanionItem) => void;
/**
@@ -156,7 +154,7 @@ const HANGING_CONFIG = {
baseFallDistance: 500,
/** Ground offset from bottom of viewport */
defaultGroundOffset: 40,
/** Size of quantity badge */
/** Size of badge (unused — kept for config consistency) */
badgeSize: 20,
/** Size of landed item hitbox for contact detection */
landedItemSize: 40,
@@ -406,7 +404,7 @@ export function HangingItems({
// Track how many instances of each item type have been released (not yet used)
// Key: item.id (type ID), Value: count of released instances
const [releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
const [_releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
// Counter for generating unique instance IDs
const instanceCounterRef = useRef(0);
@@ -566,7 +564,7 @@ export function HangingItems({
// Start the loop
animationRef.current = requestAnimationFrame(animate);
}, []);
}, [calculateFallDuration]);
// Cleanup animation on unmount
useEffect(() => {
@@ -670,7 +668,7 @@ export function HangingItems({
});
// Also remove from zone tracking
itemsInZoneRef.current.delete(instanceId);
// Decrement the released count for this item type (since the instance is now consumed)
// Decrement the released count for this item type (instance removed from screen)
setReleasedCountByItemId(prev => {
const next = new Map(prev);
const currentCount = next.get(item.id) || 0;
@@ -985,15 +983,9 @@ export function HangingItems({
return viewportCenterX + startX + index * HANGING_CONFIG.itemSpacing;
};
// Calculate hanging items with their remaining quantities
// An item appears in the hanging row if (quantity - releasedCount) > 0
const hangingItems = items
.map(item => {
const releasedCount = releasedCountByItemId.get(item.id) || 0;
const remainingQuantity = item.quantity - releasedCount;
return { ...item, quantity: remainingQuantity };
})
.filter(item => item.quantity > 0);
// All items are always visible — they are abilities, not consumable inventory.
// No quantity filtering needed.
const hangingItems = items;
// Should we render the hanging container?
const shouldRenderContainer = containerState !== 'hidden' || (isVisible && selectedAction);
@@ -1033,7 +1025,7 @@ export function HangingItems({
>
<div className="bg-background/95 backdrop-blur-sm rounded-2xl px-6 py-4 shadow-lg border">
<p className="text-sm text-muted-foreground text-center">
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items in your inventory
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items available
</p>
</div>
</div>
@@ -1102,8 +1094,8 @@ export function HangingItems({
marginLeft: (HANGING_CONFIG.circleSize / 2) * -1 + HANGING_CONFIG.lineWidth / 2,
}}
onClick={() => handleItemClick(item, itemX)}
title={`${item.name} (x${item.quantity})`}
aria-label={`${item.name}, quantity ${item.quantity}. Click to release.`}
title={item.name}
aria-label={`${item.name}. Click to release.`}
>
{/* Item emoji */}
<span
@@ -1114,24 +1106,6 @@ export function HangingItems({
>
{item.emoji}
</span>
{/* Quantity badge */}
<span
className={cn(
"absolute -top-1 -right-1",
"flex items-center justify-center",
"bg-primary text-primary-foreground",
"text-xs font-semibold rounded-full",
"shadow-md"
)}
style={{
minWidth: HANGING_CONFIG.badgeSize,
height: HANGING_CONFIG.badgeSize,
padding: '0 5px',
}}
>
{item.quantity}
</span>
</button>
</div>
);
+1 -1
View File
@@ -76,10 +76,10 @@ export { useBlobbiItemUse } from './useBlobbiItemUse';
// Context
export {
BlobbiActionsContext,
BlobbiActionsProvider,
useBlobbiActions,
useBlobbiActionsRegistration,
} from './BlobbiActionsContext';
export { BlobbiActionsProvider } from './BlobbiActionsProvider';
// Components
export { CompanionActionMenu } from './CompanionActionMenu';
+2 -2
View File
@@ -63,7 +63,7 @@ export function getItemCategoryForAction(actionId: CompanionMenuAction): ShopIte
/**
* Normalized item representation for the companion UI.
* This is a simplified view of inventory items optimized for rendering.
* This is a simplified view of shop catalog items optimized for rendering.
*/
export interface CompanionItem {
/** Unique item ID (matches shop item ID) */
@@ -74,7 +74,7 @@ export interface CompanionItem {
emoji: string;
/** Item category */
category: ShopItemCategory;
/** Quantity available in inventory */
/** Availability (always Infinity — items are reusable abilities) */
quantity: number;
/** Item effects when used */
effect?: ItemEffect;
@@ -27,13 +27,10 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
parseBlobbiEvent,
isValidBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
@@ -41,7 +38,6 @@ import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
canUseItemForStage,
getStageRestrictionMessage,
@@ -59,7 +55,7 @@ import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
import { HATCH_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useEvolveTasks';
import type { UseItemFunction } from './BlobbiActionsProvider';
import type { UseItemFunction } from './BlobbiActionsContextDef';
// ─── Configuration ────────────────────────────────────────────────────────────
@@ -126,7 +122,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
const queryClient = useQueryClient();
// Fetch profile if not provided
const { profile: fetchedProfile, updateProfileEvent } = useBlobbonautProfile();
const { profile: fetchedProfile } = useBlobbonautProfile();
const profile = options.profile ?? fetchedProfile;
// Per-item cooldown tracking (ref to avoid re-renders)
@@ -232,16 +228,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
}, [queryClient, user?.pubkey, profile?.currentCompanion]);
// Core mutation for using items
// Core mutation for using items (always uses once)
const mutation = useMutation({
mutationFn: async ({
itemId,
action,
quantity = 1,
}: {
itemId: string;
action: InventoryAction;
quantity?: number;
}): Promise<{ statsChanged: Record<string, number> }> => {
// ─── Validation ───
if (!user?.pubkey) {
@@ -259,11 +253,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
throw new Error('No companion selected');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// Check stage restrictions
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
@@ -283,15 +272,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
}
// Validate item exists in storage with sufficient quantity
const storageItem = profile.storage.find(s => s.itemId === itemId);
if (!storageItem || storageItem.quantity <= 0) {
throw new Error('Item not found in your inventory');
}
if (storageItem.quantity < quantity) {
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
@@ -319,17 +299,13 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Apply Item Effects ───
// ─── Apply Item Effects (single use) ───
const isEggCompanion = companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
if (isEggCompanion && action === 'medicine') {
const healthDelta = shopItem.effect.health ?? 0;
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = applyStat(currentHealth, healthDelta);
}
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
statsUpdate.health = currentHealth.toString();
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
@@ -339,15 +315,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
const hygieneDelta = shopItem.effect.hygiene ?? 0;
const happinessDelta = shopItem.effect.happiness ?? 0;
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
}
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
@@ -362,11 +331,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
for (let i = 0; i < quantity; i++) {
currentStats = applyItemEffects(currentStats, shopItem.effect);
}
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
@@ -414,36 +380,19 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
updateCompanionInCache(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
const newStorage = decrementStorageItem(profile.storage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(profile.allTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
// ─── Invalidate Queries ───
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
// Items are free to use — no storage decrement needed.
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
return { statsChanged };
},
onSuccess: (_, { itemId, action, quantity = 1 }) => {
onSuccess: (_, { itemId, action }) => {
const shopItem = getShopItemById(itemId);
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${shopItem?.name ?? 'item'}${quantityText} on your Blobbi.`,
description: `Used ${shopItem?.name ?? 'item'} on your Blobbi.`,
});
// Track daily mission progress
@@ -468,7 +417,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
// Wrapper function that matches UseItemFunction signature and includes cooldown check
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Check cooldown first
if (isItemOnCooldown(itemId)) {
if (import.meta.env.DEV) {
@@ -481,7 +430,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
}
try {
const result = await mutation.mutateAsync({ itemId, action, quantity });
const result = await mutation.mutateAsync({ itemId, action });
return {
success: true,
statsChanged: result.statsChanged,
@@ -18,8 +18,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import type {
@@ -68,7 +67,10 @@ interface UseCompanionActionMenuResult {
}
/**
* Resolve inventory items for a specific action/category.
* Resolve available items for a specific action/category from the shop catalog.
*
* Items are sourced from the full shop catalog — all items are
* available as reusable abilities/tools, filtered only by stage.
*
* Uses the centralized `canUseItemForStage` function to ensure consistent
* stage-based filtering across all UIs:
@@ -80,7 +82,6 @@ interface UseCompanionActionMenuResult {
* filters out all egg-only items from the companion interaction system.
*/
function resolveItemsForAction(
storage: StorageItem[],
action: CompanionMenuAction,
stage: 'egg' | 'baby' | 'adult'
): CompanionItem[] {
@@ -89,13 +90,10 @@ function resolveItemsForAction(
// Sleep action has no items
if (!category) return [];
const allItems = getLiveShopItems();
const items: CompanionItem[] = [];
for (const storageItem of storage) {
if (storageItem.quantity <= 0) continue;
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
for (const shopItem of allItems) {
if (shopItem.type !== category) continue;
// Use centralized stage-based filtering
@@ -104,17 +102,17 @@ function resolveItemsForAction(
// - Food/Toys: only for baby/adult (excluded for eggs)
// - Medicine: must have health effect
// - Hygiene: must have hygiene or happiness effect
const usability = canUseItemForStage(storageItem.itemId, stage);
const usability = canUseItemForStage(shopItem.id, stage);
if (!usability.canUse) {
continue;
}
items.push({
id: storageItem.itemId,
id: shopItem.id,
name: shopItem.name,
emoji: shopItem.icon,
category: shopItem.type,
quantity: storageItem.quantity,
quantity: Infinity,
effect: shopItem.effect,
});
}
@@ -197,8 +195,8 @@ export function useCompanionActionMenu({
return;
}
// Resolve items for this action
const items = resolveItemsForAction(profile.storage, action, stage);
// Resolve items for this action from the catalog (not inventory)
const items = resolveItemsForAction(action, stage);
setMenuState(prev => ({
...prev,
@@ -42,7 +42,6 @@ export interface ItemUseResult {
export type UseItemCallback = (
itemId: string,
action: InventoryAction,
quantity: number
) => Promise<{ success: boolean; statsChanged?: Record<string, number>; error?: string }>;
/**
@@ -67,14 +66,14 @@ export interface UseCompanionItemUseResult {
isUsingItem: boolean;
/** Get the action type for an item category */
getActionForCategory: (category: ShopItemCategory) => InventoryAction | null;
/** Get the inventory action for a menu action */
/** Get the care action for a menu action */
getInventoryAction: (menuAction: CompanionMenuAction) => InventoryAction | null;
}
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* Map item categories to inventory actions.
* Map item categories to care actions.
* This is the canonical mapping for how items are used.
*/
export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null> = {
@@ -85,14 +84,14 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
};
/**
* Map menu actions to inventory actions (they match by design).
* Map menu actions to item-based care actions (they match by design).
*/
export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, InventoryAction | null> = {
feed: 'feed',
play: 'play',
medicine: 'medicine',
clean: 'clean',
sleep: null, // Sleep is a special action, not an inventory action
sleep: null, // Sleep is a special action, not item-based
};
// ─── Hook Implementation ──────────────────────────────────────────────────────
@@ -108,8 +107,8 @@ export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, Invent
* Usage:
* ```tsx
* const { useItem, isUsingItem } = useCompanionItemUse({
* onUseItem: async (itemId, action, qty) => {
* return await executeUseItem({ itemId, action, quantity: qty });
* onUseItem: async (itemId, action) => {
* return await executeUseItem({ itemId, action });
* },
* onSuccess: (result) => removeItemFromScreen(result.item),
* onFailure: (result) => keepItemOnScreen(result.item),
@@ -134,7 +133,7 @@ export function useCompanionItemUse({
}, []);
/**
* Get the inventory action for a menu action.
* Get the care action for a menu action.
*/
const getInventoryAction = useCallback((menuAction: CompanionMenuAction): InventoryAction | null => {
return MENU_ACTION_TO_INVENTORY_ACTION[menuAction];
@@ -187,7 +186,7 @@ export function useCompanionItemUse({
try {
// Execute the use callback
const useResult = await onUseItem(item.id, inventoryAction, 1);
const useResult = await onUseItem(item.id, inventoryAction);
if (useResult.success) {
const result: ItemUseResult = {
+65 -36
View File
@@ -6,6 +6,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
KIND_BLOBBI_STATE,
BLOBBI_ECOSYSTEM_NAMESPACE,
isValidBlobbiEvent,
parseBlobbiEvent,
type BlobbiCompanion,
@@ -26,62 +27,90 @@ function chunkArray<T>(array: T[], size: number): T[][] {
}
/**
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
* Hook to fetch Blobbi companions (Kind 31124) owned by the logged-in user.
*
* Two modes:
* - **No dList** (default): Fetches ALL the user's blobbi events by author +
* ecosystem namespace tag. This is the authoritative source of truth —
* the user authored these events, so we don't need a secondary index.
* - **With dList**: Fetches only the specified d-tags. Use this when you only
* need a specific subset (e.g. the companion layer needs just one blobbi).
*
* Features:
* - Fetches ALL pets by d-tag list (no limit: 1)
* - Chunks large d-lists into multiple queries for relay compatibility
* - Keeps only the newest event per d-tag
* - Returns both a lookup record and array of companions
* - Provides invalidation and optimistic update helpers
*/
export function useBlobbisCollection(dList: string[] | undefined) {
export function useBlobbisCollection(dList?: string[] | undefined) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
// Create a stable query key based on sorted d-tags
// Determine the mode: 'all' fetches everything, 'dlist' fetches by specific d-tags
const mode = dList === undefined ? 'all' : 'dlist';
// Create a stable query key based on sorted d-tags (for dlist mode)
const sortedDList = useMemo(() => {
if (!dList || dList.length === 0) return null;
if (mode === 'all' || !dList || dList.length === 0) return null;
return [...dList].sort();
}, [dList]);
}, [mode, dList]);
const queryKeyDTags = sortedDList?.join(',') ?? '';
// Query key segment: 'all' for fetch-all mode, comma-joined d-tags for dlist mode
const queryKeySegment = mode === 'all' ? 'all' : (sortedDList?.join(',') ?? '');
// Main query to fetch all companions from relays
// Main query to fetch companions from relays
const query = useQuery({
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
queryKey: ['blobbi-collection', user?.pubkey, queryKeySegment],
queryFn: async ({ signal }) => {
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
if (!user?.pubkey) {
console.log('[useBlobbisCollection] No pubkey, returning empty');
return { companionsByD: {}, companions: [] };
}
// Log the dList we're about to query
console.log('[Blobbi] dList:', sortedDList);
let allEvents: NostrEvent[];
// Chunk the d-list for relay compatibility
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
// Query all chunks in parallel
const allEvents: NostrEvent[] = [];
for (const chunk of chunks) {
if (mode === 'all') {
// Fetch ALL the user's blobbi events — author is the source of truth
const filter = {
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': chunk,
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
'#b': [BLOBBI_ECOSYSTEM_NAMESPACE],
};
// Log the filter immediately before query
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
console.log('[Blobbi] 31124 query filter (all):', JSON.stringify(filter, null, 2));
const events = await nostr.query([filter], { signal });
allEvents.push(...events);
allEvents = await nostr.query([filter], { signal });
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
console.log('[useBlobbisCollection] Fetch-all returned', allEvents.length, 'events');
} else {
// Fetch by specific d-tags (for companion layer etc.)
if (!sortedDList || sortedDList.length === 0) {
console.log('[useBlobbisCollection] Empty dList, returning empty');
return { companionsByD: {}, companions: [] };
}
console.log('[Blobbi] dList:', sortedDList);
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
allEvents = [];
for (const chunk of chunks) {
const filter = {
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': chunk,
};
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
const events = await nostr.query([filter], { signal });
allEvents.push(...events);
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
}
}
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
@@ -123,7 +152,7 @@ export function useBlobbisCollection(dList: string[] | undefined) {
return { companionsByD, companions };
},
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
enabled: !!user?.pubkey && (mode === 'all' || (!!sortedDList && sortedDList.length > 0)),
staleTime: 30_000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
@@ -137,17 +166,17 @@ export function useBlobbisCollection(dList: string[] | undefined) {
// pattern (fetch fresh → mutate → optimistic update) keeps the cache correct.
// Only call this when the set of d-tags itself changes (e.g. adoption, deletion).
const invalidate = useCallback(() => {
if (user?.pubkey && queryKeyDTags) {
if (user?.pubkey) {
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
queryKey: ['blobbi-collection', user.pubkey, queryKeySegment],
});
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
}, [queryClient, user?.pubkey, queryKeySegment]);
// Update a single companion event in the query cache (optimistic update).
// CRITICAL: Updates ALL blobbi-collection queries for this user, not just the
// one matching the current queryKeyDTags. This ensures the BlobbiPage cache
// and companion layer cache stay in sync (they use different d-tag lists).
// one matching the current queryKeySegment. This ensures the BlobbiPage cache
// and companion layer cache stay in sync (they use different query modes).
const updateCompanionEvent = useCallback((event: NostrEvent) => {
const parsed = parseBlobbiEvent(event);
if (!parsed || !user?.pubkey) return;
@@ -169,14 +198,14 @@ export function useBlobbisCollection(dList: string[] | undefined) {
// If no existing queries matched (first load), set our own query key
if (matchingQueries.length === 0) {
queryClient.setQueryData<CollectionData>(
['blobbi-collection', user.pubkey, queryKeyDTags],
['blobbi-collection', user.pubkey, queryKeySegment],
{
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
},
);
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
}, [queryClient, user?.pubkey, queryKeySegment]);
// Memoize return values for stability
const companionsByD = query.data?.companionsByD ?? {};
+2 -2
View File
@@ -288,7 +288,7 @@ export interface BlobbiCompanion {
}
/**
* Stored item in user's profile inventory
* Stored item in user's profile (from purchases)
*/
export interface StorageItem {
itemId: string; // Must match a ShopItem.id
@@ -316,7 +316,7 @@ export interface BlobbonautProfile {
coins: number;
/** Petting level (interaction counter) */
pettingLevel: number;
/** Purchased items inventory */
/** Purchased items storage */
storage: StorageItem[];
/** All tags preserved for republishing */
allTags: string[][];
+1 -1
View File
@@ -9,7 +9,7 @@ import { Theater } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useEmotionDev } from './EmotionDevContext';
import { useEmotionDev } from './useEmotionDev';
import { isLocalhostDev } from './index';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
+2 -54
View File
@@ -10,26 +10,10 @@
* - Is purely for visual testing/debugging
*/
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { useState, useCallback, type ReactNode } from 'react';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { isLocalhostDev } from './index';
// ─── Types ────────────────────────────────────────────────────────────────────
interface EmotionDevContextValue {
/** Current dev emotion override (null = use default/neutral) */
devEmotion: BlobbiEmotion | null;
/** Set the dev emotion override */
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
/** Clear the dev emotion override (back to neutral) */
clearDevEmotion: () => void;
/** Whether dev emotion is active */
isDevEmotionActive: boolean;
}
// ─── Context ──────────────────────────────────────────────────────────────────
const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
import { EmotionDevContext, type EmotionDevContextValue } from './useEmotionDev';
// ─── Provider ─────────────────────────────────────────────────────────────────
@@ -68,40 +52,4 @@ export function EmotionDevProvider({ children }: EmotionDevProviderProps) {
);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to access dev emotion state.
* Returns null values in production for safety.
*/
export function useEmotionDev(): EmotionDevContextValue {
const context = useContext(EmotionDevContext);
// Outside localhost dev or if no provider, return safe defaults
if (!isLocalhostDev() || !context) {
return {
devEmotion: null,
setDevEmotion: () => {},
clearDevEmotion: () => {},
isDevEmotionActive: false,
};
}
return context;
}
/**
* Get the effective emotion for a Blobbi.
* In dev mode with an override, returns the dev emotion.
* Otherwise returns the provided emotion or 'neutral'.
*/
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
const { devEmotion, isDevEmotionActive } = useEmotionDev();
// Dev override takes precedence (only in localhost dev)
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
return devEmotion;
}
return baseEmotion ?? 'neutral';
}
+2 -1
View File
@@ -35,5 +35,6 @@ export { BlobbiDevEditor, type BlobbiDevUpdates } from './BlobbiDevEditor';
export { useBlobbiDevUpdate } from './useBlobbiDevUpdate';
// Emotion testing tools
export { EmotionDevProvider, useEmotionDev, useEffectiveEmotion } from './EmotionDevContext';
export { EmotionDevProvider } from './EmotionDevContext';
export { useEmotionDev, useEffectiveEmotion } from './useEmotionDev';
export { BlobbiEmotionPanel } from './BlobbiEmotionPanel';
+58
View File
@@ -0,0 +1,58 @@
import { createContext, useContext } from 'react';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { isLocalhostDev } from './index';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface EmotionDevContextValue {
/** Current dev emotion override (null = use default/neutral) */
devEmotion: BlobbiEmotion | null;
/** Set the dev emotion override */
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
/** Clear the dev emotion override (back to neutral) */
clearDevEmotion: () => void;
/** Whether dev emotion is active */
isDevEmotionActive: boolean;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
// ─── Hooks ────────────────────────────────────────────────────────────────────
/**
* Hook to access dev emotion state.
* Returns null values in production for safety.
*/
export function useEmotionDev(): EmotionDevContextValue {
const context = useContext(EmotionDevContext);
// Outside localhost dev or if no provider, return safe defaults
if (!isLocalhostDev() || !context) {
return {
devEmotion: null,
setDevEmotion: () => {},
clearDevEmotion: () => {},
isDevEmotionActive: false,
};
}
return context;
}
/**
* Get the effective emotion for a Blobbi.
* In dev mode with an override, returns the dev emotion.
* Otherwise returns the provided emotion or 'neutral'.
*/
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
const { devEmotion, isDevEmotionActive } = useEmotionDev();
// Dev override takes precedence (only in localhost dev)
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
return devEmotion;
}
return baseEmotion ?? 'neutral';
}
@@ -1,17 +1,15 @@
import { useMemo, useState } from 'react';
import { Package, Loader2, Minus, Plus, X } from 'lucide-react';
import { useMemo } from 'react';
import { Package, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
@@ -20,7 +18,7 @@ import {
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { ShopItem } from '../types/shop.types';
import { getShopItemById } from '../lib/blobbi-shop-items';
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn } from '@/lib/utils';
import { ItemEffectDisplay } from './ItemEffectDisplay';
@@ -31,228 +29,169 @@ interface BlobbiInventoryModalProps {
profile: BlobbonautProfile | null;
/** The current companion (needed for stage-based restrictions) */
companion: BlobbiCompanion | null;
/** Called when user wants to use an item. Opens the use flow. */
onUseItem?: (itemId: string, quantity: number) => void;
/** Called when user wants to use an item. Always uses once. */
onUseItem?: (itemId: string) => void;
/** Whether an item is currently being used */
isUsingItem?: boolean;
}
/** Resolved inventory item with shop metadata and usability info */
/** Resolved catalog item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
canUse: boolean;
reason?: string;
}
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
// ── Shared items content (used by both standalone modal and unified shop modal) ──
interface BlobbiInventoryContentProps {
profile: BlobbonautProfile | null;
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
onUseItem?: (itemId: string) => void;
isUsingItem?: boolean;
}
export function BlobbiInventoryContent({
profile,
profile: _profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryContentProps) {
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showUseDialog, setShowUseDialog] = useState(false);
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
const allItems = getLiveShopItems();
const result: ResolvedInventoryItem[] = [];
for (const storageItem of profile.storage) {
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
const usability = canUseItemForStage(storageItem.itemId, stage);
for (const item of allItems) {
const usability = canUseItemForStage(item.id, stage);
result.push({
...item,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: item.id,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [profile, companion?.stage]);
}, [companion?.stage]);
const isEmpty = inventoryItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowUseDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || !onUseItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseUseDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
const maxQuantity = selectedItem?.quantity ?? 1;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxQuantity));
}
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
onUseItem(item.itemId);
};
return (
<>
<div className="px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
<p className="text-sm text-muted-foreground max-w-sm">
Visit the Shop tab to purchase items for your Blobbi. Items you buy will appear here.
</p>
<div className="px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className={cn(
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
!item.canUse && "grayscale"
)}>
{item.icon}
</div>
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for your Blobbi's current stage.
</p>
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className={cn(
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
!item.canUse && "grayscale"
)}>
{item.icon}
</div>
{/* Item Info - Name and Type */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
{/* Quantity Badge */}
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
×{item.quantity}
</Badge>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleSelectItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
Use
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{/* Item Info - Name and Type */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Use Item Confirmation Dialog */}
{selectedItem && companion && (
<InventoryUseConfirmDialog
open={showUseDialog}
onOpenChange={handleCloseUseDialog}
item={selectedItem}
companion={companion}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleUseItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
{isUsingItem ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{item.type}
</Badge>
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
</>
</div>
);
}
@@ -298,153 +237,3 @@ export function BlobbiInventoryModal({
</Dialog>
);
}
// ─── Use Confirmation Dialog ──────────────────────────────────────────────────
interface InventoryUseConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function InventoryUseConfirmDialog({
open,
onOpenChange,
item,
companion,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: InventoryUseConfirmDialogProps) {
const totalEffect = useMemo(() => {
if (!item.effect) return null;
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
if (delta !== undefined) {
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
}
}
}
const result: Record<string, number> = {};
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
result[stat] = delta;
}
}
return Object.keys(result).length > 0 ? result : null;
}, [item.effect, companion.stats, quantity]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>Use Item</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Item Preview */}
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
<p className="text-sm text-muted-foreground">
{item.quantity} in inventory
</p>
</div>
</div>
{/* Quantity Selector */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
<span className="text-xs text-muted-foreground">
Max: {maxQuantity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onDecrease}
disabled={quantity <= 1 || isUsing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxQuantity}
value={quantity}
onChange={onQuantityChange}
disabled={isUsing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={onIncrease}
disabled={quantity >= maxQuantity || isUsing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Effects Summary */}
{totalEffect && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
</h4>
<ItemEffectDisplay effect={totalEffect} variant="badges" />
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUsing}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isUsing}
className="min-w-24"
>
{isUsing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Using...
</>
) : (
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+15 -31
View File
@@ -16,17 +16,16 @@ import {
import type { ShopItem } from '../types/shop.types';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { getLiveShopItems, getShopItemById } from '../lib/blobbi-shop-items';
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn, formatCompactNumber } from '@/lib/utils';
type TopTab = 'items' | 'shop';
/** Resolved inventory item with shop metadata and usability info */
/** Resolved catalog item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
canUse: boolean;
reason?: string;
}
@@ -39,7 +38,7 @@ interface BlobbiShopModalProps {
initialTab?: TopTab;
// ── Inventory props (passed through) ──
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
onUseItem?: (itemId: string) => void;
isUsingItem?: boolean;
}
@@ -80,28 +79,24 @@ export function BlobbiShopModal({
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
// ── Inventory items resolution ──
// ── Items resolution — sourced from the full catalog (not inventory) ──
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
const allCatalogItems = getLiveShopItems();
const result: ResolvedInventoryItem[] = [];
for (const storageItem of profile.storage) {
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
const usability = canUseItemForStage(storageItem.itemId, stage);
for (const item of allCatalogItems) {
const usability = canUseItemForStage(item.id, stage);
result.push({
...item,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: item.id,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [profile, companion?.stage]);
}, [companion?.stage]);
// ── Inventory use item handler ──
const [usingItemId, setUsingItemId] = useState<string | null>(null);
@@ -109,7 +104,7 @@ export function BlobbiShopModal({
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
setUsingItemId(item.itemId);
onUseItem(item.itemId, 1);
onUseItem(item.itemId);
};
// Clear usingItemId when isUsingItem goes false
@@ -138,7 +133,7 @@ export function BlobbiShopModal({
Items
{!inventoryEmpty && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 min-w-4">
{inventoryItems.reduce((sum, i) => sum + i.quantity, 0)}
{inventoryItems.length}
</Badge>
)}
{topTab === 'items' && (
@@ -265,7 +260,7 @@ function ShopGrid({ items, availableCoins, onBuy, purchasingItemId }: ShopGridPr
);
}
// ─── Items Grid (inventory, tile layout) ──────────────────────────────────────
// ─── Items Grid (catalog, tile layout) ────────────────────────────────────────
interface ItemsGridProps {
items: ResolvedInventoryItem[];
@@ -275,20 +270,16 @@ interface ItemsGridProps {
onGoToShop: () => void;
}
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: ItemsGridProps) {
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _onGoToShop }: ItemsGridProps) {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-8 text-muted-foreground/60" />
</div>
<p className="text-sm text-muted-foreground mb-4">
No items yet. Visit the shop to stock up!
<p className="text-sm text-muted-foreground">
No items are available for your Blobbi's current stage.
</p>
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
<ShoppingBag className="size-3.5" />
Browse Shop
</Button>
</div>
);
}
@@ -308,13 +299,6 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: I
item.canUse ? 'hover:border-primary/40 hover:bg-accent/40' : 'opacity-60',
)}
>
{/* Quantity badge */}
<Badge
className="absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0 h-4 min-w-4 bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0"
>
{item.quantity}
</Badge>
{/* Icon */}
<div className={cn('text-3xl leading-none mt-1', !item.canUse && 'grayscale')}>{item.icon}</div>
@@ -7,7 +7,7 @@
* Used by:
* - BlobbiShopItemRow (shop listing)
* - BlobbiPurchaseDialog (purchase confirmation)
* - BlobbiInventoryModal (inventory listing)
* - BlobbiInventoryModal (items listing)
* - BlobbiActionInventoryModal (action item selection)
*
* All consumers should use this component to ensure consistent display of item effects.
@@ -192,30 +192,6 @@ export function ItemEffectDisplay({
return null;
}
// ─── Utility Exports ──────────────────────────────────────────────────────────
/**
* Format effects as a summary string (for compatibility with existing code).
* This is a drop-in replacement for formatEffectSummary in blobbi-shop-utils.ts.
*
* @deprecated Use <ItemEffectDisplay variant="inline" /> instead
*/
export function formatEffectSummary(effect: ItemEffect | undefined, maxEffects = 4): string {
const entries = getSortedEffectEntries(effect);
if (entries.length === 0) {
return 'No effects';
}
const displayEntries = maxEffects !== undefined ? entries.slice(0, maxEffects) : entries;
return displayEntries
.map(([stat, value]) => `${formatStatValue(value)} ${STAT_LABELS[stat]}`)
.join(', ');
}
/**
* Get sorted effect entries for custom rendering.
* Useful when you need to iterate over effects yourself.
*/
export { getSortedEffectEntries };
+1 -1
View File
@@ -7,7 +7,7 @@
import { useMemo } from 'react';
import { Play, Pause, Music, ListMusic, Podcast, Clock } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
import { useAuthor } from '@/hooks/useAuthor';
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
/**
* Auto-minimizes the audio player when the user navigates to a different page.
+1 -33
View File
@@ -3,39 +3,7 @@ import { Award } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCardTilt } from '@/hooks/useCardTilt';
/** Parsed NIP-58 badge definition data. */
export interface BadgeData {
identifier: string;
name: string;
description?: string;
image?: string;
imageDimensions?: string;
thumbs: Array<{ url: string; dimensions?: string }>;
}
/** Parse a kind 30009 badge definition event into structured data. */
export function parseBadgeDefinition(event: NostrEvent): BadgeData | null {
if (event.kind !== 30009) return null;
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
if (!identifier) return null;
const name = event.tags.find(([n]) => n === 'name')?.[1] || identifier;
const description = event.tags.find(([n]) => n === 'description')?.[1];
const imageTag = event.tags.find(([n]) => n === 'image');
const image = imageTag?.[1];
const imageDimensions = imageTag?.[2];
const thumbs: Array<{ url: string; dimensions?: string }> = [];
for (const tag of event.tags) {
if (tag[0] === 'thumb' && tag[1]) {
thumbs.push({ url: tag[1], dimensions: tag[2] });
}
}
return { identifier, name, description, image, imageDimensions, thumbs };
}
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
interface BadgeContentProps {
event: NostrEvent;
+1 -1
View File
@@ -28,7 +28,7 @@ import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { parseBadgeDefinition } from '@/components/BadgeContent';
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
import { useCardTilt } from '@/hooks/useCardTilt';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { AwardBadgeDialog } from '@/components/AwardBadgeDialog';
+2 -2
View File
@@ -8,8 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
+1 -1
View File
@@ -5,7 +5,7 @@ import { nip19 } from 'nostr-tools';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import type { BadgeData } from '@/components/BadgeContent';
import type { BadgeData } from '@/lib/parseBadgeDefinition';
import { cn } from '@/lib/utils';
interface BadgeDisplayItem {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { Award } from 'lucide-react';
import type { BadgeData } from '@/components/BadgeContent';
import type { BadgeData } from '@/lib/parseBadgeDefinition';
import { useCardTilt } from '@/hooks/useCardTilt';
import { cn } from '@/lib/utils';
+2 -84
View File
@@ -2,8 +2,7 @@ import { useMemo, useState, useEffect, useId } from 'react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/useTheme';
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl, hexLuminance } from '@/lib/colorUtils';
import type { CoreThemeColors } from '@/themes';
import { getColors, paletteToTheme } from '@/lib/colorMomentUtils';
import type { NostrEvent } from '@nostrify/nostrify';
type Layout = 'horizontal' | 'vertical' | 'grid' | 'star' | 'checkerboard' | 'diagonalStripes';
@@ -12,12 +11,7 @@ function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
export function getColors(tags: string[][]): string[] {
return tags
.filter(([n]) => n === 'c')
.map(([, v]) => v)
.filter((v) => /^#[0-9A-Fa-f]{6}$/.test(v));
}
/** Compute a best-fit grid: cols × rows for n items. */
function gridDimensions(n: number): { cols: number; rows: number } {
@@ -193,82 +187,6 @@ function DiagonalStripesLayout({ colors }: { colors: string[] }) {
);
}
// ─── Palette → theme mapping ─────────────────────────────
function hexContrast(hex1: string, hex2: string): number {
return getContrastRatio(hexToRgb(hex1), hexToRgb(hex2));
}
function hexSaturation(hex: string): number {
return rgbToHsl(...hexToRgb(hex)).s;
}
/**
* Adjust the lightness of an HSL string until it achieves at least `targetRatio`
* contrast against `bgHsl`. Steps toward white or black depending on which
* direction gives better contrast. Returns the adjusted HSL string.
*/
function enforceContrast(hsl: string, bgHsl: string, targetRatio: number): string {
const bg = parseHsl(bgHsl);
const bgLum = getLuminance(...hslToRgb(bg.h, bg.s, bg.l));
const { h, s, l } = parseHsl(hsl);
// Decide direction: go lighter if bg is dark, darker if bg is light
const goLighter = bgLum < 0.18;
let current = l;
for (let i = 0; i < 50; i++) {
current = goLighter
? Math.min(100, current + 2)
: Math.max(0, current - 2);
const rgb = hslToRgb(h, s, current);
const lum = getLuminance(...rgb);
const lighter = Math.max(bgLum, lum);
const darker = Math.min(bgLum, lum);
if ((lighter + 0.05) / (darker + 0.05) >= targetRatio) break;
}
return formatHsl(h, s, current);
}
/**
* Map palette hex colors to CoreThemeColors with guaranteed readability:
* 1. background = darkest color
* 2. text = lightest color; if contrast < 4.5:1, synthesize white or black
* 3. primary = most saturated remaining color; if contrast < 3:1 against
* background, adjust its lightness until it passes
*/
export function paletteToTheme(colors: string[]): CoreThemeColors {
if (colors.length === 0) {
return { background: '0 0% 10%', text: '0 0% 98%', primary: '258 70% 55%' };
}
const sorted = [...colors].sort((a, b) => hexLuminance(a) - hexLuminance(b));
const bgHex = sorted[0];
const bgHsl = hexToHslString(bgHex);
// Text: lightest palette color; override with white/black if contrast is too low
const textHex = sorted[sorted.length - 1];
let textHsl = hexToHslString(textHex);
if (hexContrast(textHex, bgHex) < 4.5) {
// Pick white or black — whichever contrasts better
const whiteContrast = hexContrast('#ffffff', bgHex);
const blackContrast = hexContrast('#000000', bgHex);
textHsl = whiteContrast >= blackContrast ? '0 0% 98%' : '222 20% 8%';
}
// Primary: most saturated of remaining colors; nudge lightness if needed
const rest = colors.filter((c) => c !== bgHex && c !== textHex);
const pool = rest.length > 0 ? rest : [textHex];
const primaryHex = pool.reduce((best, c) => hexSaturation(c) > hexSaturation(best) ? c : best, pool[0]);
let primaryHsl = hexToHslString(primaryHex);
if (hexContrast(primaryHex, bgHex) < 3) {
primaryHsl = enforceContrast(primaryHsl, bgHsl, 3);
}
return { background: bgHsl, text: textHsl, primary: primaryHsl };
}
// ─── Main component ──────────────────────────────────────
const LAYOUT_MAP: Record<Layout, React.FC<{ colors: string[] }>> = {
+2 -2
View File
@@ -8,7 +8,7 @@ import { Link } from 'react-router-dom';
import { X } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
@@ -26,7 +26,7 @@ function getTag(tags: string[][], name: string): string | undefined {
// ── data hook ─────────────────────────────────────────────────────────────────
export function useEventComments(event: NostrEvent | undefined) {
function useEventComments(event: NostrEvent | undefined) {
const { nostr } = useNostr();
const aTag = event
+1
View File
@@ -1086,6 +1086,7 @@ export function ComposeBox({
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onPointerDown={expand}
onFocus={expand}
onPaste={handlePaste}
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
+180 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { IntroImage } from '@/components/IntroImage';
import {
Users, Download, Loader2, X, Pencil, Home, Globe,
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -17,6 +17,7 @@ import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useToast } from '@/hooks/useToast';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useInterests } from '@/hooks/useInterests';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -24,7 +25,7 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useMuteList, type MuteListItem } from '@/hooks/useMuteList';
import { useAuthor } from '@/hooks/useAuthor';
import { FeedEditModal } from '@/components/FeedEditModal';
import { buildKindOptions } from '@/components/SavedFeedFiltersEditor';
import { buildKindOptions } from '@/lib/feedFilterUtils';
import { genUserName } from '@/lib/genUserName';
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
@@ -556,6 +557,183 @@ function FeedTabsSection() {
{/* Saved Feeds */}
<SavedFeedsSection />
{/* Interests (hashtag & geotag tabs) */}
<InterestsSection />
</div>
);
}
// ─── Interests Section ───────────────────────────────────────────────────────
function InterestsSection() {
const { toast } = useToast();
const { user } = useCurrentUser();
const { hashtags, addInterest: addHashtag, removeInterest: removeHashtag, isLoading: isLoadingHashtags } = useInterests('t');
const { hashtags: geotags, addInterest: addGeotag, removeInterest: removeGeotag, isLoading: isLoadingGeotags } = useInterests('g');
const [newHashtag, setNewHashtag] = useState('');
const [newGeotag, setNewGeotag] = useState('');
const isLoading = isLoadingHashtags || isLoadingGeotags;
if (!user) return null;
const handleRemoveHashtag = async (tag: string) => {
await removeHashtag.mutateAsync(tag);
toast({ title: `#${tag} removed from feed tabs` });
};
const handleRemoveGeotag = async (tag: string) => {
await removeGeotag.mutateAsync(tag);
toast({ title: `${tag} removed from feed tabs` });
};
const handleAddHashtag = async () => {
const tag = newHashtag.trim().toLowerCase().replace(/^#/, '');
if (!tag) return;
if (hashtags.includes(tag)) {
toast({ title: `#${tag} is already followed`, variant: 'destructive' });
return;
}
await addHashtag.mutateAsync(tag);
setNewHashtag('');
toast({ title: `#${tag} added to feed tabs` });
};
const handleAddGeotag = async () => {
const tag = newGeotag.trim().toLowerCase();
if (!tag) return;
if (geotags.includes(tag)) {
toast({ title: `${tag} is already followed`, variant: 'destructive' });
return;
}
await addGeotag.mutateAsync(tag);
setNewGeotag('');
toast({ title: `${tag} added to feed tabs` });
};
return (
<div className="px-3 py-4 space-y-4 border-t border-border">
<div>
<h3 className="text-sm font-semibold">Interest Tabs</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Hashtags and locations you follow appear as tabs on the home feed.
</p>
</div>
{/* Hashtags */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Hash className="size-4 text-muted-foreground shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Hashtags</span>
</div>
<div className="flex gap-2">
<Input
placeholder="ditto"
value={newHashtag}
onChange={(e) => setNewHashtag(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddHashtag(); }}
className="h-9"
disabled={addHashtag.isPending}
/>
<Button
onClick={handleAddHashtag}
disabled={addHashtag.isPending || !newHashtag.trim()}
size="sm"
className="h-9"
>
{addHashtag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
</div>
) : hashtags.length === 0 ? (
<p className="text-xs text-muted-foreground">No followed hashtags yet.</p>
) : (
<div className="space-y-1.5">
{hashtags.map((tag) => (
<div
key={`hashtag:${tag}`}
className="rounded-lg border border-border/50 bg-secondary/30"
>
<div className="flex items-center gap-2 py-2 px-2.5">
<Hash className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
<button
onClick={() => handleRemoveHashtag(tag)}
disabled={removeHashtag.isPending}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
aria-label={`Remove #${tag}`}
>
<X className="size-3.5" strokeWidth={4} />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Geotags */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<MapPin className="size-4 text-muted-foreground shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Locations</span>
</div>
<div className="flex gap-2">
<Input
placeholder="geohash (e.g. u4pru)"
value={newGeotag}
onChange={(e) => setNewGeotag(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddGeotag(); }}
className="h-9"
disabled={addGeotag.isPending}
/>
<Button
onClick={handleAddGeotag}
disabled={addGeotag.isPending || !newGeotag.trim()}
size="sm"
className="h-9"
>
{addGeotag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
</div>
) : geotags.length === 0 ? (
<p className="text-xs text-muted-foreground">No followed locations yet.</p>
) : (
<div className="space-y-1.5">
{geotags.map((tag) => (
<div
key={`geotag:${tag}`}
className="rounded-lg border border-border/50 bg-secondary/30"
>
<div className="flex items-center gap-2 py-2 px-2.5">
<MapPin className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
<button
onClick={() => handleRemoveGeotag(tag)}
disabled={removeGeotag.isPending}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
aria-label={`Remove ${tag}`}
>
<X className="size-3.5" strokeWidth={4} />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+2 -2
View File
@@ -9,9 +9,9 @@ import { getAvatarShape } from '@/lib/avatarShape';
import { Skeleton } from '@/components/ui/skeleton';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
+1 -1
View File
@@ -26,7 +26,7 @@ export interface EmojiPackData {
}
/** Parse a kind 30030 emoji pack event into structured data. */
export function parseEmojiPack(event: NostrEvent): EmojiPackData | null {
function parseEmojiPack(event: NostrEvent): EmojiPackData | null {
if (event.kind !== 30030) return null;
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
+6 -4
View File
@@ -332,14 +332,15 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
// For edit mode, fetch fresh event to preserve any tags we don't manage
let preservedTags: string[][] = [];
let prev: NostrEvent | null = null;
if (isEditMode) {
const fresh = await fetchFreshEvent(nostr, {
prev = await fetchFreshEvent(nostr, {
kinds: [30030],
authors: [user.pubkey],
'#d': [resolvedId],
});
if (fresh) {
preservedTags = fresh.tags.filter(
if (prev) {
preservedTags = prev.tags.filter(
([n]) => n !== 'd' && n !== 'name' && n !== 'about' && n !== 'emoji',
);
}
@@ -357,7 +358,8 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
kind: 30030,
content: '',
tags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
prev: prev ?? undefined,
});
// Clean up blob URLs
for (const e of emojis) {
+31 -38
View File
@@ -14,7 +14,7 @@ import LoginDialog from '@/components/auth/LoginDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { useFeed } from '@/hooks/useFeed';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useInfiniteHotFeed } from '@/hooks/useTrending';
import { DITTO_RELAYS } from '@/lib/appRelays';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useInterests } from '@/hooks/useInterests';
@@ -22,13 +22,15 @@ import { useMuteList } from '@/hooks/useMuteList';
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';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { diversifyFeedPages } from '@/lib/feedDiversity';
import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { TabButton } from '@/components/TabButton';
import { DITTO_RELAYS } from '@/lib/appRelays';
import type { FeedItem } from '@/lib/feedUtils';
import type { NostrEvent } from '@nostrify/nostrify';
import type { SavedFeed } from '@/contexts/AppContext';
@@ -36,23 +38,6 @@ import type { SavedFeed } from '@/contexts/AppContext';
type CoreFeedTab = 'follows' | 'global' | 'communities' | 'ditto';
type FeedTab = CoreFeedTab | string; // string = saved feed id
/** Curated kinds for the logged-out homepage: unique Ditto content types. */
const LANDING_KINDS = [
36767, // Themes
37381, // Magic Decks
3367, // Color Moments
37516, // Treasures
7516, // Treasures (Found Logs)
30030, // Emoji Packs
30009, // Badge Definitions
10008, // Profile Badges
30008, // Profile Badges (legacy)
31124, // Blobbi
];
/** Webxdc needs a MIME-type tag filter, so it gets its own filter object. */
const LANDING_WEBXDC_FILTER = { kinds: [1063], '#m': ['application/x-webxdc'] };
interface FeedProps {
/** Override the kinds list instead of using feed settings. */
kinds?: number[];
@@ -74,6 +59,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const { savedFeeds } = useSavedFeeds();
const { hashtags } = useInterests();
const { hashtags: geotags } = useInterests('g');
const { data: curatorFollowList, isError: isCuratorError } = useCuratorFollowList();
// Tab settings from localStorage
const showGlobalFeed = (() => {
@@ -150,21 +136,17 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
(kinds || tagFilters) ? { kinds, tagFilters } : undefined,
);
// "Hot" sorted feed query (used when logged out on the home page, or on the Ditto tab)
// Shows curated "otherstuff" kinds instead of kind 1. Webxdc needs a
// separate filter with a MIME-type tag constraint.
const topQuery = useInfiniteHotFeed(
LANDING_KINDS,
// Curated Ditto feed: latest content from the curator's follow list.
const topQuery = useCuratedDittoFeed(
curatorFollowList,
useTopFeedForLoggedOut || !!useDittoTab,
undefined,
[LANDING_WEBXDC_FILTER],
);
// Unify the two query shapes behind a single interface
const useDittoQuery = useTopFeedForLoggedOut || useDittoTab;
const activeQuery = useDittoQuery ? topQuery : feedQuery;
const queryKey = useMemo(
() => useDittoQuery ? ['infinite-hot-feed', LANDING_KINDS.join(',')] : ['feed', activeTab],
() => useDittoQuery ? ['ditto-curated-feed'] : ['feed', activeTab],
[useDittoQuery, activeTab],
);
@@ -204,16 +186,25 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const seen = new Set<string>();
if (useDittoQuery) {
return (rawData.pages as unknown as import('@nostrify/nostrify').NostrEvent[][])
.flat()
.filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
if (shouldHideFeedEvent(event)) return false;
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
return true;
})
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
// Deduplicate and filter each page independently, then diversify
// page-by-page so earlier pages never change when new pages arrive.
const dedupedPages = (rawData.pages as unknown as import('@nostrify/nostrify').NostrEvent[][])
.map((page) =>
page
.filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
if (shouldHideFeedEvent(event)) return false;
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
return true;
})
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at })),
);
// Reorder for content-type diversity: cap any single type at 20%
// per page and enforce a minimum gap of 4 positions between same-type
// items, with gap state carrying across page boundaries.
return diversifyFeedPages(dedupedPages);
}
return (rawData.pages as unknown as { items: FeedItem[] }[])
@@ -228,7 +219,9 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
});
}, [rawData?.pages, muteItems, useDittoQuery]);
const showSkeleton = isPending || (isLoading && !rawData);
// Show skeletons while loading, but not if the curator list query errored
// (that would leave logged-out users staring at infinite skeletons).
const showSkeleton = (isPending || (isLoading && !rawData)) && !(useDittoQuery && isCuratorError);
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
// Extra tabs (Ditto, Community, saved feeds, hashtags) are only for the home feed.
+1 -2
View File
@@ -18,14 +18,13 @@ import {
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils';
import {
buildKindOptions,
MultiKindPicker,
AuthorChip,
AuthorFilterDropdown,
ScopeToggle,
ListPackPicker,
parseSelectedKinds,
} from '@/components/SavedFeedFiltersEditor';
import type { ScopeOption } from '@/components/SavedFeedFiltersEditor';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
+23 -34
View File
@@ -21,26 +21,17 @@ import { useStreamPosts } from '@/hooks/useStreamPosts';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { useNostr } from '@nostrify/react';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { genUserName } from '@/lib/genUserName';
import { parsePackEvent } from '@/lib/packUtils';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { SubHeaderBar } from '@/components/SubHeaderBar';
/** Parse a follow pack / starter pack event into structured data. */
function parsePackEvent(event: NostrEvent) {
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
const title = getTag('title') || getTag('name') || 'Untitled Pack';
const description = getTag('description') || getTag('summary') || '';
const image = getTag('image') || getTag('thumb') || getTag('banner');
const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk);
return { title, description, image, pubkeys };
}
type Tab = 'feed' | 'members';
// ─── Feed Tab ─────────────────────────────────────────────────────────────────
function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
export function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
const { muteItems } = useMuteList();
const { posts, isLoading } = useStreamPosts('', {
@@ -101,7 +92,7 @@ function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
// ─── Members Tab ──────────────────────────────────────────────────────────────
function PackMembersTab({
export function PackMembersTab({
pubkeys,
membersMap,
membersLoading,
@@ -186,34 +177,32 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
setIsFollowingAll(true);
try {
const signal = AbortSignal.timeout(10_000);
// 1. Fetch freshest kind 3 from relays (not cache)
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
const followEvents = await nostr.query(
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
{ signal },
);
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
const latestEvent = followEvents.length > 0
? followEvents.reduce((latest, current) => current.created_at > latest.created_at ? current : latest)
: null;
const existingFollows = latestEvent
? latestEvent.tags.filter(([name]) => name === 'p').map(([, pk]) => pk)
: [];
const allFollows = [...new Set([...existingFollows, ...pubkeys])];
const added = pubkeys.filter((pk) => !existingFollows.includes(pk));
// 3. Merge: add new pubkeys that aren't already followed
const newPTags = pubkeys
.filter((pk) => !existingPubkeys.has(pk))
.map((pk) => ['p', pk]);
const added = newPTags.length;
// 4. Publish with prev for published_at preservation
await publishEvent({
kind: 3,
content: latestEvent?.content ?? '',
tags: allFollows.map((pk) => ['p', pk]),
content: prev?.content ?? '',
tags: [...nonPTags, ...existingPTags, ...newPTags],
prev: prev ?? undefined,
});
toast({
title: 'Following all!',
description: added.length > 0
? `Added ${added.length} new account${added.length !== 1 ? 's' : ''} to your follow list.`
description: added > 0
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
: 'You were already following everyone in this pack.',
});
} catch (error) {
@@ -357,7 +346,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
}
/** Individual member card in the follow pack. */
function MemberCard({
export function MemberCard({
pubkey,
metadata,
isFollowed,
@@ -437,7 +426,7 @@ function MemberCard({
);
}
function MemberCardSkeleton() {
export function MemberCardSkeleton() {
return (
<div className="flex items-center gap-3 px-4 py-3">
<Skeleton className="size-11 rounded-full shrink-0" />
+1 -1
View File
@@ -17,7 +17,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { parseBadgeDefinition } from '@/components/BadgeContent';
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAwardBadge } from '@/hooks/useAwardBadge';
import { useToast } from '@/hooks/useToast';
+19 -28
View File
@@ -1,4 +1,3 @@
import { Capacitor } from "@capacitor/core";
import type { NostrEvent, NostrMetadata } from "@nostrify/nostrify";
import { useNostr } from "@nostrify/react";
import { useQueryClient } from "@tanstack/react-query";
@@ -15,6 +14,7 @@ import {
} from "lucide-react";
import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
import { downloadTextFile } from "@/lib/downloadFile";
import { fetchFreshEvent } from "@/lib/fetchFreshEvent";
import {
type ReactNode,
useCallback,
@@ -308,11 +308,6 @@ function SetupQuestionnaire({
await downloadTextFile(filename, nsec);
// Let the user know where the file ended up on Android
if (Capacitor.getPlatform() === "android") {
toast({ title: "Key saved", description: `Saved to Download/${filename}` });
}
// Log in with the new key
login.nsec(nsec);
next();
@@ -671,7 +666,7 @@ function ProfileStep({
);
if (validFields.length > 0)
data.fields = validFields.map((f) => [f.label, f.value]);
await publishEvent({ kind: 0, content: JSON.stringify(data) });
await publishEvent({ kind: 0, content: JSON.stringify(data), tags: [] });
queryClient.invalidateQueries({ queryKey: ["logins"] });
queryClient.invalidateQueries({ queryKey: ["author", user.pubkey] });
} catch {
@@ -942,32 +937,28 @@ function FollowsStep({
.filter(([n]) => n === "p")
.map(([, pk]) => pk);
// Fetch current follow list
const followEvents: NostrEvent[] = await nostr
.query([{ kinds: [3], authors: [user.pubkey], limit: 1 }], {
signal: AbortSignal.timeout(10_000),
})
.catch((): NostrEvent[] => []);
// 1. Fetch freshest kind 3 from relays (not cache)
const prev = await fetchFreshEvent(nostr, {
kinds: [3],
authors: [user.pubkey],
});
const latestEvent =
followEvents.length > 0
? followEvents.reduce((latest, current) =>
current.created_at > latest.created_at ? current : latest,
)
: null;
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
const existingPTags = prev?.tags.filter(([n]) => n === "p") ?? [];
const nonPTags = prev?.tags.filter(([n]) => n !== "p") ?? [];
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
const existingFollows = latestEvent
? latestEvent.tags
.filter(([name]) => name === "p")
.map(([, pk]) => pk)
: [];
const allFollows = [...new Set([...existingFollows, ...packPubkeys])];
// 3. Merge: add new pubkeys that aren't already followed
const newPTags = packPubkeys
.filter((pk) => !existingPubkeys.has(pk))
.map((pk) => ["p", pk]);
// 4. Publish with prev for published_at preservation
await publishEvent({
kind: 3,
content: latestEvent?.content ?? "",
tags: allFollows.map((pk) => ["p", pk]),
content: prev?.content ?? "",
tags: [...nonPTags, ...existingPTags, ...newPTags],
prev: prev ?? undefined,
});
setFollowedPacks((prev) => new Set([...prev, packId]));
+45 -33
View File
@@ -155,16 +155,19 @@ function QuotesTab({ quotes }: { quotes: QuoteEntry[] }) {
/* ──── Reactions Tab ──── */
function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
// Group reactions by emoji
const grouped = useMemo(() => {
const groups = new Map<string, ReactionEntry[]>();
// Summary of unique emojis with counts, sorted by popularity
const emojiSummary = useMemo(() => {
const counts = new Map<string, { count: number; url?: string }>();
for (const r of reactions) {
const key = r.emoji;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(r);
const existing = counts.get(r.emoji);
if (existing) {
existing.count++;
} else {
counts.set(r.emoji, { count: 1, url: r.emojiUrl });
}
}
// Sort groups by count (most popular first)
return Array.from(groups.entries()).sort((a, b) => b[1].length - a[1].length);
return Array.from(counts.entries())
.sort((a, b) => b[1].count - a[1].count);
}, [reactions]);
if (reactions.length === 0) {
@@ -173,32 +176,31 @@ function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
return (
<div>
{grouped.map(([emoji, entries]) => {
// Check if this is a custom emoji — use the URL from the first entry
const firstEntry = entries[0];
const customUrl = firstEntry?.emojiUrl;
const customName = isCustomEmoji(emoji) ? emoji.slice(1, -1) : undefined;
{/* Emoji summary bar */}
{emojiSummary.length > 1 && (
<div className="flex items-center gap-2 px-4 py-2.5 bg-secondary/30 border-b border-border flex-wrap">
{emojiSummary.map(([emoji, { count, url }]) => {
const customName = isCustomEmoji(emoji) ? emoji.slice(1, -1) : undefined;
return (
<span key={emoji} className="inline-flex items-center gap-1 text-sm">
{url && customName ? (
<CustomEmojiImg name={customName} url={url} className="inline-block h-5 w-5 object-contain" />
) : (
<span>{emoji}</span>
)}
<span className="text-xs text-muted-foreground font-medium tabular-nums">{count}</span>
</span>
);
})}
</div>
)}
return (
<div key={emoji}>
{/* Emoji group header */}
<div className="flex items-center gap-2 px-4 py-2 bg-secondary/30 sticky top-0 z-[1]">
{customUrl && customName ? (
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6 object-contain" />
) : (
<span className="text-lg">{emoji}</span>
)}
<span className="text-xs text-muted-foreground font-medium">{entries.length}</span>
</div>
{/* Users who reacted with this emoji — each row links to the reaction event */}
<div className="divide-y divide-border">
{entries.map((entry, i) => (
<ReactionRow key={`${entry.pubkey}-${i}`} entry={entry} />
))}
</div>
</div>
);
})}
{/* Flat list — each row shows the emoji badge inline */}
<div className="divide-y divide-border">
{reactions.map((entry, i) => (
<ReactionRow key={`${entry.pubkey}-${i}`} entry={entry} />
))}
</div>
</div>
);
}
@@ -275,6 +277,7 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) {
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(entry.pubkey);
const nevent = useMemo(() => nip19.neventEncode({ id: entry.eventId, author: entry.pubkey }), [entry.eventId, entry.pubkey]);
const customName = isCustomEmoji(entry.emoji) ? entry.emoji.slice(1, -1) : undefined;
return (
<Link
@@ -302,6 +305,15 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) {
<span className="text-xs text-muted-foreground">{timeAgo(entry.createdAt)}</span>
</div>
{/* Reaction emoji badge */}
<div className="flex items-center justify-center shrink-0 bg-secondary/60 rounded-full size-8">
{entry.emojiUrl && customName ? (
<CustomEmojiImg name={customName} url={entry.emojiUrl} className="inline-block h-5 w-5 object-contain" />
) : (
<span className="text-base leading-none">{entry.emoji}</span>
)}
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</Link>
);
-29
View File
@@ -6,7 +6,6 @@ import { DittoLogo } from '@/components/DittoLogo';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/hooks/useAppContext';
import { useTheme } from '@/hooks/useTheme';
import { useTrendingTags } from '@/hooks/useTrending';
import { themePresets, coreToTokens, type CoreThemeColors } from '@/themes';
import { cn } from '@/lib/utils';
@@ -93,7 +92,6 @@ function ThemeSwatch({
export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
const { config } = useAppContext();
const { theme, customTheme, applyCustomTheme, setTheme } = useTheme();
const { data: trendingData } = useTrendingTags();
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
@@ -116,8 +114,6 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
return null;
}, [theme, customTheme]);
const trendingTags = trendingData?.tags?.slice(0, 12) ?? [];
const updateScrollButtons = () => {
const el = scrollRef.current;
if (!el) return;
@@ -245,31 +241,6 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
</div>
</div>
{/* ── Trending Hashtags ── */}
{trendingTags.length > 0 && (
<div className="px-4 pb-4 landing-hero-fade" style={{ animationDelay: '320ms' }}>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2.5">
Trending now
</p>
<div className="flex flex-wrap gap-1.5">
{trendingTags.map(({ tag, accounts }) => (
<Link
key={tag}
to={`/t/${tag}`}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-secondary/60 hover:bg-secondary text-xs font-medium text-secondary-foreground transition-colors"
>
<span className="text-primary">#</span>{tag}
{accounts > 1 && (
<span className="text-muted-foreground text-[10px] ml-0.5">
{accounts}
</span>
)}
</Link>
))}
</div>
</div>
)}
{/* ── Divider into feed ── */}
<div className="border-b border-border" />
</div>
+5 -58
View File
@@ -1,7 +1,6 @@
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
import { Outlet } from 'react-router-dom';
import { LeftSidebar } from '@/components/LeftSidebar';
import { RightSidebar } from '@/components/RightSidebar';
import { MobileTopBar } from '@/components/MobileTopBar';
import { MobileDrawer } from '@/components/MobileDrawer';
import { MobileBottomNav } from '@/components/MobileBottomNav';
@@ -42,68 +41,15 @@ function PageSkeleton() {
))}
</div>
</main>
{/* Right sidebar skeleton — mirrors RightSidebar's container + widget card styling */}
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3">
{/* Trends widget skeleton */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<div className="flex items-center justify-between mb-3">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-4 w-14" />
</div>
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex justify-between items-center">
<div className="space-y-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-[28px] w-[50px] rounded" />
</div>
))}
</div>
</section>
{/* Hot Posts widget skeleton */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<div className="flex items-center justify-between mb-3">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-12" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-3/4" />
</div>
))}
</div>
</section>
{/* New Accounts widget skeleton */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<Skeleton className="h-6 w-28 mb-3" />
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5 flex-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-36" />
</div>
</div>
))}
</div>
</section>
</aside>
{/* Right sidebar placeholder — preserves layout width */}
<div className="w-[300px] shrink-0 hidden xl:block" />
</>
);
}
/** Inner component that reads layout options from the context store. */
function MainLayoutInner() {
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
const { showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
const [drawerOpen, setDrawerOpen] = useState(false);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const centerColumnRef = useRef<HTMLDivElement>(null);
@@ -158,7 +104,8 @@ function MainLayoutInner() {
</div>
)}
</div>
{rightSidebar !== null && (rightSidebar ?? <RightSidebar />)}
{/* Right sidebar placeholder — preserves layout width */}
<div className="w-[300px] shrink-0 hidden xl:block" />
</Suspense>
</div>
+1 -118
View File
@@ -19,48 +19,7 @@ import { useAppContext } from '@/hooks/useAppContext';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { getContentWarning } from '@/lib/contentWarning';
// ── Media type detection ──────────────────────────────────────────────────────
export type MediaType = 'image' | 'video' | 'audio';
/** Event kinds that are inherently video content (vines, horizontal video, vertical video). */
const VIDEO_KINDS = new Set([34236, 21, 22]);
/** Event kinds that are inherently audio content (music tracks, podcast episodes/trailers). */
const AUDIO_KINDS = new Set([36787, 34139, 30054, 30055, 1222]);
function detectType(url: string, mime?: string, eventKind?: number): MediaType {
if (mime) {
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
if (mime.startsWith('image/')) return 'image';
}
if (/\.(mp4|webm|mov|qt|m3u8)(\?.*)?$/i.test(url)) return 'video';
if (/\.(mp3|ogg|flac|wav|aac|opus)(\?.*)?$/i.test(url)) return 'audio';
// Fall back to event kind for extensionless URLs (e.g. Blossom content-addressed URLs)
if (eventKind !== undefined) {
if (VIDEO_KINDS.has(eventKind)) return 'video';
if (AUDIO_KINDS.has(eventKind)) return 'audio';
}
return 'image';
}
// ── Aspect ratio helpers ──────────────────────────────────────────────────────
/** Default aspect ratio when dim tag is missing or unparseable. */
const DEFAULT_ASPECT_RATIO = 1;
/** Parse a dim string like "1280x720" into a width/height aspect ratio number. */
export function parseDimToAspectRatio(dim?: string): number {
if (!dim) return DEFAULT_ASPECT_RATIO;
const match = dim.match(/^(\d+)x(\d+)$/);
if (!match) return DEFAULT_ASPECT_RATIO;
const w = parseInt(match[1], 10);
const h = parseInt(match[2], 10);
if (!w || !h) return DEFAULT_ASPECT_RATIO;
return w / h;
}
import { parseDimToAspectRatio, eventToMediaItem, type MediaType, type MediaItem } from '@/lib/mediaUtils';
/** A row of items in the justified layout. */
interface JustifiedRow<T> {
@@ -127,82 +86,6 @@ function justifiedLayout<T>(
return { rows, lastRowIncomplete: false };
}
// ── Media extraction ──────────────────────────────────────────────────────────
export interface MediaItem {
url: string;
type: MediaType;
blurhash?: string;
dim?: string;
alt?: string;
mime?: string;
allUrls: string[];
allTypes: MediaType[];
allDims: (string | undefined)[];
event: NostrEvent;
hasMultiple: boolean;
/** NIP-36 content warning reason, or empty string if flagged with no reason, or undefined if clean. */
contentWarning?: string;
}
function parseImeta(tags: string[][]): { url: string; blurhash?: string; dim?: string; alt?: string; mime?: string }[] {
const results: { url: string; blurhash?: string; dim?: string; alt?: string; mime?: string }[] = [];
for (const tag of tags) {
if (tag[0] !== 'imeta') continue;
const parts: Record<string, string> = {};
for (let i = 1; i < tag.length; i++) {
const sp = tag[i].indexOf(' ');
if (sp !== -1) parts[tag[i].slice(0, sp)] = tag[i].slice(sp + 1);
}
if (parts.url) results.push({ url: parts.url, blurhash: parts.blurhash, dim: parts.dim, alt: parts.alt, mime: parts.m });
}
return results;
}
function extractMediaUrls(content: string): string[] {
return content.match(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov|qt|mp3|ogg|flac|wav|aac|opus)(\?[^\s]*)?/gi) ?? [];
}
export function eventToMediaItem(event: NostrEvent): MediaItem | null {
const imeta = parseImeta(event.tags);
const cw = getContentWarning(event);
if (imeta.length > 0) {
const first = imeta[0];
const firstType = detectType(first.url, first.mime, event.kind);
return {
url: first.url,
type: firstType,
blurhash: first.blurhash,
dim: first.dim,
alt: first.alt,
mime: first.mime,
allUrls: imeta.map((e) => e.url),
allTypes: imeta.map((e) => detectType(e.url, e.mime, event.kind)),
allDims: imeta.map((e) => e.dim),
event,
hasMultiple: imeta.length > 1,
contentWarning: cw,
};
}
if (event.kind === 1) {
const urls = extractMediaUrls(event.content);
if (urls.length > 0) {
const types = urls.map((u) => detectType(u));
return {
url: urls[0],
type: types[0],
allUrls: urls,
allTypes: types,
allDims: urls.map(() => undefined),
event,
hasMultiple: urls.length > 1,
contentWarning: cw,
};
}
}
return null;
}
// ── Flat entry — one per media URL across all events ─────────────────────────
interface FlatEntry {
-27
View File
@@ -3,33 +3,6 @@ import { Play, Pause } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatTime } from '@/lib/formatTime';
/** Audio file extensions used to detect audio URLs. */
const AUDIO_EXTENSIONS = /\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i;
/** Image file extensions used to detect image URLs. */
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i;
/** Video file extensions used to detect video URLs. */
const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|qt)(\?.*)?$/i;
/** Check whether a URL points to an audio file by extension. */
export function isAudioUrl(url: string): boolean {
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
return AUDIO_EXTENSIONS.test(url);
}
/** Check whether a URL points to an image file by extension. */
export function isImageUrl(url: string): boolean {
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
return IMAGE_EXTENSIONS.test(url);
}
/** Check whether a URL points to a video file by extension. */
export function isVideoUrl(url: string): boolean {
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
return VIDEO_EXTENSIONS.test(url);
}
interface MiniAudioPlayerProps {
src: string;
label?: string;
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Pause, SkipBack, SkipForward, Maximize2, X, GripVertical } from 'lucide-react';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { cn } from '@/lib/utils';
const POSITION_KEY = 'audio-minibar-position';
+4 -4
View File
@@ -40,8 +40,8 @@ export function MobileBottomNav() {
setSearchOpen((v) => !v);
}, []);
// Keep the nav visible while search is open regardless of scroll
const isHidden = hidden && !searchOpen;
// Hide the nav when search sheet is open so it doesn't compete for space
const isHidden = hidden || searchOpen;
const displayName = metadata?.name || metadata?.display_name;
const isOnProfile = user && location.pathname === profileUrl;
@@ -137,8 +137,8 @@ export function MobileBottomNav() {
</div>
</div>
{/* Safe area spacer — fully opaque so any subpixel gap is invisible */}
<div className="safe-area-bottom bg-background" />
{/* Safe area fill — matches the arc's semi-transparent background */}
<div className="safe-area-bottom bg-background/85" />
</nav>
</>
);
+31 -11
View File
@@ -101,6 +101,28 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
const wikipediaIndex = hasWikipedia ? nextMobileIdx++ : -1;
const archiveIndex = hasArchive ? nextMobileIdx++ : -1;
// Lock body scroll while the search sheet is open.
// overflow:hidden alone is unreliable on mobile Safari, so we also
// block touchmove on the document (except inside the results scroller).
useEffect(() => {
if (!open) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const preventScroll = (e: TouchEvent) => {
// Allow scrolling inside the results list
const target = e.target as HTMLElement;
if (target.closest?.('[data-mobile-search-results]')) return;
e.preventDefault();
};
document.addEventListener('touchmove', preventScroll, { passive: false });
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener('touchmove', preventScroll);
};
}, [open]);
// Focus input when opened
useEffect(() => {
if (open) {
@@ -224,8 +246,8 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
onClick={handleClose}
/>
{/* Bottom sheet — sits above the bottom nav bar */}
<div className="fixed left-0 right-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 bottom-mobile-nav">
{/* Bottom sheet — sits at the bottom of the screen with safe area clearance */}
<div className="fixed left-0 right-0 bottom-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 pb-6">
{/* Results list — reversed so closest to input = most relevant */}
{hasResults && (
@@ -293,7 +315,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
)}
{/* Input bar */}
<div className="flex items-center px-6 py-3">
<div className="flex items-center px-6 py-3 safe-area-bottom">
<div className="flex items-center gap-2 flex-1 bg-secondary rounded-full px-4 py-2.5">
{isFetching ? (
<svg
@@ -321,14 +343,12 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
autoCapitalize="off"
spellCheck={false}
/>
{query.length > 0 && (
<button
onClick={() => setQuery('')}
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
>
<X strokeWidth={4} className="size-3" />
</button>
)}
<button
onClick={handleClose}
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
>
<X strokeWidth={4} className="size-3" />
</button>
</div>
</div>
</div>
+1 -1
View File
@@ -31,7 +31,7 @@ import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ZapDialog } from '@/components/ZapDialog';
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
import { NoteCard } from '@/components/NoteCard';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
+77 -136
View File
@@ -16,6 +16,7 @@ import {
Radio,
Share2,
SmilePlus,
PartyPopper,
Sparkles,
Users,
Zap,
@@ -104,6 +105,7 @@ import { isSingleImagePost } from "@/lib/noteContent";
import { shareOrCopy } from "@/lib/share";
import { timeAgo } from "@/lib/timeAgo";
import { formatNumber } from "@/lib/formatNumber";
import { publishedAtAction } from "@/lib/publishedAtAction";
import { getEffectiveStreamStatus } from "@/lib/streamStatus";
import { cn } from "@/lib/utils";
@@ -1040,7 +1042,7 @@ export const NoteCard = memo(function NoteCard({
? isLive ? "text-primary" : "text-muted-foreground"
: cfg.iconClassName
}
action={typeof cfg.action === "function" ? cfg.action(event.tags, event) : cfg.action}
action={typeof cfg.action === "function" ? cfg.action(event) : cfg.action}
noun={cfg.noun}
nounRoute={cfg.nounRoute}
/>
@@ -1059,39 +1061,29 @@ export const NoteCard = memo(function NoteCard({
onAuxClick={handleAuxClick}
>
{threadedKindHeader}
{isFollowPack ? (
<div className={cn("min-w-0", threaded && "pb-3")}>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{avatarElement}
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div className={cn("flex-1 min-w-0", threaded && "pb-3")}>
{authorInfo}
{contentBlock}
<FollowPackAuthorLine pubkey={event.pubkey} createdAt={event.created_at} />
{actionButtons}
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</div>
) : (
<div className="flex gap-3">
<div className="flex flex-col items-center">
{avatarElement}
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div className={cn("flex-1 min-w-0", threaded && "pb-3")}>
{authorInfo}
{contentBlock}
{actionButtons}
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</div>
</div>
)}
</div>
</article>
);
}
@@ -1134,7 +1126,7 @@ export const NoteCard = memo(function NoteCard({
}
action={
typeof cfg.action === "function"
? cfg.action(event.tags, event)
? cfg.action(event)
: cfg.action
}
noun={cfg.noun}
@@ -1144,46 +1136,29 @@ export const NoteCard = memo(function NoteCard({
})()
)}
{/* For follow packs / lists: content-first layout with subtle author attribution */}
{isFollowPack ? (
<>
{contentBlock}
<FollowPackAuthorLine pubkey={event.pubkey} createdAt={event.created_at} />
{!compact && (
<>
{actionButtons}
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
</>
)}
</>
) : (
<>
{/* Header: avatar + name/handle stacked */}
<div className="flex items-center gap-3">
{avatarElement}
{authorInfo}
{isColor && <ColorMomentEyeButton event={event} />}
</div>
{/* Header: avatar + name/handle stacked */}
<div className="flex items-center gap-3">
{avatarElement}
{authorInfo}
{isColor && <ColorMomentEyeButton event={event} />}
</div>
{contentBlock}
{contentBlock}
{/* Action buttons — hidden in compact/embed mode */}
{!compact && (
<>
{actionButtons}
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</>
)}
{/* Action buttons — hidden in compact/embed mode */}
{!compact && (
<>
{actionButtons}
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</>
)}
</article>
@@ -1703,52 +1678,6 @@ function StreamContent({ event }: { event: NostrEvent }) {
);
}
/** Subtle author attribution line for follow pack / list cards. */
function FollowPackAuthorLine({ pubkey, createdAt }: { pubkey: string; createdAt: number }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = getDisplayName(metadata, pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
return (
<div className="flex items-center gap-1.5 mt-2 text-xs text-muted-foreground">
{author.isLoading ? (
<>
<Skeleton className="size-4 rounded-full shrink-0" />
<Skeleton className="h-3 w-20" />
</>
) : (
<>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className="size-4">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[7px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={profileUrl}
className="hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
</ProfileHoverCard>
<span className="shrink-0">·</span>
<span className="shrink-0">{timeAgo(createdAt)}</span>
</>
)}
</div>
);
}
export interface EventActionHeaderProps {
/** Pubkey of the person performing the action. */
pubkey: string;
@@ -1768,8 +1697,8 @@ export interface EventActionHeaderProps {
interface KindHeaderConfig {
icon: React.ComponentType<{ className?: string }>;
iconClassName?: string;
/** Static action string, or a function that computes it from the event's tags (and optionally the full event). */
action: string | ((tags: string[][], event?: NostrEvent) => string);
/** Static action string, or a function that computes it from the event. */
action: string | ((event: NostrEvent) => string);
noun?: string;
nounRoute?: string;
}
@@ -1794,7 +1723,7 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
37516: {
icon: ChestIcon,
action: "hid a",
action: (event) => publishedAtAction(event, { created: "hid a", updated: "updated a", fallback: "hid a" }),
noun: "treasure",
nounRoute: "/treasures",
},
@@ -1806,61 +1735,61 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
37381: {
icon: CardsIcon,
action: "shared a",
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "deck",
nounRoute: "/decks",
},
36767: {
icon: Sparkles,
action: "shared a",
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "theme",
nounRoute: "/themes",
},
16767: {
icon: Sparkles,
action: "updated their",
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated their", fallback: "updated their" }),
noun: "theme",
nounRoute: "/themes",
},
30030: {
icon: SmilePlus,
action: "shared an",
action: (event) => publishedAtAction(event, { created: "created an", updated: "updated an", fallback: "shared an" }),
noun: "emoji pack",
nounRoute: "/emojis",
},
30009: {
icon: Award,
action: "created a",
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
noun: "badge",
nounRoute: "/badges",
},
10008: {
icon: Award,
action: "updated their",
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
noun: "badges",
nounRoute: "/badges",
},
30008: {
icon: Award,
action: "updated their",
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
noun: "badges",
nounRoute: "/badges",
},
30311: {
icon: Radio,
iconClassName: undefined, // computed dynamically below
action: (_tags, event) =>
event && getEffectiveStreamStatus(event) === "live"
action: (event) =>
getEffectiveStreamStatus(event) === "live"
? "is streaming"
: "streamed",
},
32267: {
icon: Package,
action: "published a Zapstore app",
action: (event) => publishedAtAction(event, { created: "published a Zapstore app", updated: "updated a Zapstore app", fallback: "published a Zapstore app" }),
},
30063: {
icon: Package,
action: "published a Zapstore release",
action: (event) => publishedAtAction(event, { created: "published a Zapstore release", updated: "updated a Zapstore release", fallback: "published a Zapstore release" }),
},
3063: {
icon: Package,
@@ -1868,11 +1797,11 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
31990: {
icon: Package,
action: "published an app",
action: (event) => publishedAtAction(event, { created: "published an app", updated: "updated an app", fallback: "published an app" }),
},
30617: {
icon: GitBranch,
action: "shared a",
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "repository",
nounRoute: "/development",
},
@@ -1890,19 +1819,19 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
30817: {
icon: FileCode,
action: "proposed a",
action: (event) => publishedAtAction(event, { created: "proposed a", updated: "updated a", fallback: "proposed a" }),
noun: "NIP",
nounRoute: "/development",
},
15128: {
icon: Rocket,
action: "deployed an",
action: (event) => publishedAtAction(event, { created: "deployed an", updated: "redeployed an", fallback: "deployed an" }),
noun: "nsite",
nounRoute: "/development",
},
35128: {
icon: Rocket,
action: "deployed an",
action: (event) => publishedAtAction(event, { created: "deployed an", updated: "redeployed an", fallback: "deployed an" }),
noun: "nsite",
nounRoute: "/development",
},
@@ -1912,10 +1841,22 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
31124: {
icon: Egg,
action: "updated their",
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
noun: "Blobbi",
nounRoute: "/blobbi",
},
39089: {
icon: PartyPopper,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "follow pack",
nounRoute: "/packs",
},
30000: {
icon: PartyPopper,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "follow set",
nounRoute: "/packs",
},
};
/** Generic action header: icon · [author name] [action] [linked noun] */
+10 -10
View File
@@ -5,7 +5,7 @@ import { NoteContent } from './NoteContent';
import type { NostrEvent } from '@nostrify/nostrify';
describe('NoteContent', () => {
it('linkifies URLs in kind 1 events', () => {
it('linkifies URLs in kind 1 events', async () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
@@ -22,13 +22,13 @@ describe('NoteContent', () => {
</TestApp>
);
const link = screen.getByRole('link', { name: 'https://example.com' });
const link = await screen.findByRole('link', { name: 'https://example.com' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveAttribute('target', '_blank');
});
it('linkifies URLs in kind 1111 events (comments)', () => {
it('linkifies URLs in kind 1111 events (comments)', async () => {
const event: NostrEvent = {
id: 'test-comment-id',
pubkey: 'test-pubkey',
@@ -49,13 +49,13 @@ describe('NoteContent', () => {
</TestApp>
);
const link = screen.getByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
const link = await screen.findByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://nostrbook.dev/kinds/1111');
expect(link).toHaveAttribute('target', '_blank');
});
it('handles text without URLs correctly', () => {
it('handles text without URLs correctly', async () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
@@ -72,11 +72,11 @@ describe('NoteContent', () => {
</TestApp>
);
expect(screen.getByText('This is just plain text without any links.')).toBeInTheDocument();
expect(await screen.findByText('This is just plain text without any links.')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('renders hashtags as links', () => {
it('renders hashtags as links', async () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
@@ -93,7 +93,7 @@ describe('NoteContent', () => {
</TestApp>
);
const nostrHashtag = screen.getByRole('link', { name: '#nostr' });
const nostrHashtag = await screen.findByRole('link', { name: '#nostr' });
const bitcoinHashtag = screen.getByRole('link', { name: '#bitcoin' });
expect(nostrHashtag).toBeInTheDocument();
@@ -102,7 +102,7 @@ describe('NoteContent', () => {
expect(bitcoinHashtag).toHaveAttribute('href', '/t/bitcoin');
});
it('generates deterministic names for users without metadata and styles them differently', () => {
it('generates deterministic names for users without metadata and styles them differently', async () => {
// Use a valid npub for testing
const event: NostrEvent = {
id: 'test-id',
@@ -121,7 +121,7 @@ describe('NoteContent', () => {
);
// The mention should be rendered with a deterministic name
const mention = screen.getByRole('link');
const mention = await screen.findByRole('link');
expect(mention).toBeInTheDocument();
// Should have muted styling for generated names (muted-foreground instead of primary)
+50 -207
View File
@@ -1,13 +1,18 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Package, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { SandboxFrame } from '@/components/SandboxFrame';
import { useCenterColumn } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { APP_BLOSSOM_SERVERS, getEffectiveBlossomServers } from '@/lib/appBlossom';
import { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
import { getPreviewInjectedScript } from '@/lib/previewInjectedScript';
import { getMimeType } from '@/lib/sandbox';
import type { FileResponse, InjectedScript } from '@/lib/sandbox';
interface Rect { left: number; top: number; width: number; height: number }
@@ -33,38 +38,6 @@ function useElementRect(el: HTMLElement | null): Rect | null {
return rect;
}
/** The wildcard-to-localhost preview domain used by Shakespeare's iframe-fetch-client. */
const PREVIEW_DOMAIN = 'local-shakespeare.dev';
interface JSONRPCFetchRequest {
jsonrpc: '2.0';
method: 'fetch';
params: {
request: {
url: string;
method: string;
headers: Record<string, string>;
body: string | null;
};
};
id: number;
}
interface JSONRPCResponse {
jsonrpc: '2.0';
result?: {
status: number;
statusText: string;
headers: Record<string, string>;
body: string | null;
};
error?: {
code: number;
message: string;
};
id: number;
}
/**
* Build the path→sha256 manifest from a nsite event's `path` tags.
* Each path tag has the format: ["path", "/file/path", "<sha256>"]
@@ -114,43 +87,6 @@ async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Resp
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
}
/**
* Guess a MIME type from a file path extension.
* Falls back to 'application/octet-stream' for unknown extensions.
*/
function guessMimeType(path: string): string {
const ext = path.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
html: 'text/html',
htm: 'text/html',
css: 'text/css',
js: 'application/javascript',
mjs: 'application/javascript',
json: 'application/json',
svg: 'image/svg+xml',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
ico: 'image/x-icon',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
otf: 'font/otf',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
wav: 'audio/wav',
wasm: 'application/wasm',
xml: 'application/xml',
txt: 'text/plain',
md: 'text/markdown',
};
return map[ext] ?? 'application/octet-stream';
}
interface NsitePreviewDialogProps {
/** The nsite event (kind 15128 or 35128) containing path and server tags. */
event: NostrEvent;
@@ -164,30 +100,25 @@ interface NsitePreviewDialogProps {
/**
* An in-app preview panel that covers the center column and loads an nsite in
* a sandboxed iframe, using the Shakespeare iframe-fetch-client protocol over
* local-shakespeare.dev.
* a sandboxed iframe.
*
* Instead of proxying requests through an nsite gateway, this component serves
* files directly from Blossom servers using the manifest data embedded in the
* nsite event's `path` tags. Each path tag maps a file path to its sha256 hash,
* which is used to construct a Blossom content-addressed URL.
* Files are served directly from Blossom servers using the manifest data
* embedded in the nsite event's `path` tags. Each path tag maps a file path
* to its sha256 hash, which is used to construct a Blossom content-addressed URL.
*
* The panel is portaled into the center column DOM element (via CenterColumnContext)
* and uses `position: fixed` to fill the viewport column area.
*
* The parent window intercepts JSON-RPC `fetch` requests from the iframe and
* serves them directly from Blossom, so the SPA can run without any gateway dependency.
*/
export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenChange }: NsitePreviewDialogProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const centerColumn = useCenterColumn();
const columnRect = useElementRect(open ? centerColumn : null);
const { config } = useAppContext();
// Derive the iframe origin from the NIP-5A canonical subdomain for this event
const subdomain = getNsiteSubdomain(event);
const iframeOrigin = `https://${subdomain}.${PREVIEW_DOMAIN}`;
const iframeSrc = `${iframeOrigin}/`;
// Use the NIP-5A canonical subdomain as the stable identifier, then derive
// a private HMAC-SHA256 subdomain so the raw identifier is never exposed as
// a sandbox origin (preventing cross-app localStorage/IndexedDB collisions).
const nsiteSubdomain = getNsiteSubdomain(event);
const previewSubdomain = useMemo(() => deriveIframeSubdomain('nsite', nsiteSubdomain), [nsiteSubdomain]);
// Build the manifest and server list from the event (memoised per event identity)
const manifest = useRef<Map<string, string>>(new Map());
@@ -202,128 +133,40 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
servers.current = resolveServers(event, appServers.length > 0 ? appServers : APP_BLOSSOM_SERVERS.servers);
}, [event, config.blossomServerMetadata, config.useAppBlossomServers]);
/** Send a JSON-RPC response back to the iframe. */
const sendResponse = useCallback((message: JSONRPCResponse) => {
iframeRef.current?.contentWindow?.postMessage(message, iframeOrigin);
}, [iframeOrigin]);
/** Injected scripts: just the path normalisation snippet for SPA support. */
const injectedScripts = useMemo<InjectedScript[]>(() => [{
path: '__injected__/preview.js',
content: getPreviewInjectedScript(),
}], []);
/** Handle a fetch request from the iframe by serving files directly from Blossom. */
const handleFetch = useCallback(async (request: JSONRPCFetchRequest) => {
const { params, id } = request;
const { request: fetchRequest } = params;
/** Resolve a pathname to file content from the Blossom manifest. */
const resolveFile = useCallback(async (pathname: string): Promise<FileResponse | null> => {
// Look up the sha256 for this path in the manifest.
// If not found, fall back to /index.html (SPA client-side routing).
let sha256 = manifest.current.get(pathname);
let servingPath = pathname;
try {
const requestedUrl = new URL(fetchRequest.url);
// Only serve requests for our iframe origin
if (requestedUrl.origin !== iframeOrigin) {
sendResponse({
jsonrpc: '2.0',
error: { code: -32003, message: 'Origin mismatch' },
id,
});
return;
}
// Strip query string from path for manifest lookup
const requestedPath = requestedUrl.pathname;
// Look up the sha256 for this path in the manifest.
// If not found, fall back to /index.html (SPA client-side routing).
let sha256 = manifest.current.get(requestedPath);
let servingPath = requestedPath;
if (!sha256) {
sha256 = manifest.current.get('/index.html');
servingPath = '/index.html';
}
if (!sha256) {
sendResponse({
jsonrpc: '2.0',
result: {
status: 404,
statusText: 'Not Found',
headers: { 'Content-Type': 'text/plain' },
body: btoa('Not Found'),
},
id,
});
return;
}
// Fetch the blob from Blossom, trying each server in order
const res = await fetchFromBlossom(sha256, servers.current);
// Read as ArrayBuffer → base64 so binary assets work correctly
const buffer = await res.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const bodyBase64 = btoa(binary);
// Always determine content type from the file extension.
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
// files), which causes browsers to reject module scripts. The file path from
// the manifest is authoritative for the correct MIME type.
const contentType = guessMimeType(servingPath);
// The iframe-fetch-client (main.js) checks headers with Title-Case keys
// (e.g. "Content-Type"), and does an exact equality check against "text/html"
// for routing decisions.
const responseHeaders: Record<string, string> = {
'Content-Type': contentType,
'Content-Length': String(bytes.byteLength),
};
sendResponse({
jsonrpc: '2.0',
result: {
status: 200,
statusText: 'OK',
headers: responseHeaders,
body: bodyBase64,
},
id,
});
} catch (err) {
sendResponse({
jsonrpc: '2.0',
error: { code: -32002, message: String(err) },
id,
});
if (!sha256) {
sha256 = manifest.current.get('/index.html');
servingPath = '/index.html';
}
}, [iframeOrigin, sendResponse]);
/** Handle navigation state updates from the iframe (no-op). */
const handleNavigationState = useCallback((_params: {
currentUrl: string;
canGoBack: boolean;
canGoForward: boolean;
}) => {
// intentionally empty
if (!sha256) return null;
// Fetch the blob from Blossom, trying each server in order.
const res = await fetchFromBlossom(sha256, servers.current);
const buffer = await res.arrayBuffer();
const body = new Uint8Array(buffer);
// Always determine content type from the file extension.
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
// files), which causes browsers to reject module scripts. The file path from
// the manifest is authoritative for the correct MIME type.
const contentType = getMimeType(servingPath);
return { status: 200, contentType, body };
}, []);
// Listen for messages from the iframe
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== iframeOrigin) return;
const message = event.data;
if (message?.jsonrpc !== '2.0') return;
if (message.method === 'fetch') {
handleFetch(message as JSONRPCFetchRequest);
} else if (message.method === 'updateNavigationState') {
handleNavigationState(message.params);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [iframeOrigin, handleFetch, handleNavigationState]);
if (!open || !centerColumn || !columnRect) return null;
// If the user has scrolled down, columnRect.top is negative (the column top
@@ -343,7 +186,7 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
}}
>
{/* Nav bar */}
<div className="h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0">
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{appPicture ? (
@@ -372,15 +215,15 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
</Button>
</div>
{/* iframe */}
{/* Sandboxed iframe */}
<div className="flex-1 min-h-0 bg-background">
<iframe
key={`${subdomain}-${open}`}
ref={iframeRef}
src={iframeSrc}
<SandboxFrame
key={`${previewSubdomain}-${open}`}
id={previewSubdomain}
resolveFile={resolveFile}
injectedScripts={injectedScripts}
className="w-full h-full border-0"
title={`${appName} preview`}
sandbox="allow-scripts allow-same-origin allow-forms"
/>
</div>
</div>,
+1 -1
View File
@@ -31,7 +31,7 @@ import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ZapDialog } from '@/components/ZapDialog';
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
import { NoteCard } from '@/components/NoteCard';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
/** Format a full date. */
+2 -57
View File
@@ -7,68 +7,13 @@ import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { isProfileBadgesKind } from '@/lib/badgeUtils';
/** Maximum badges to show in the preview grid before truncating. */
const PREVIEW_LIMIT = 12;
/** A parsed badge reference from a profile badges event. */
interface BadgeRef {
/** The `a` tag value referencing a kind 30009 badge definition. */
aTag: string;
/** The `e` tag value referencing a kind 8 badge award event. */
eTag?: string;
/** Parsed components from the `a` tag. */
kind: number;
pubkey: string;
identifier: string;
}
/** Parse a profile badges event (kind 10008 or legacy 30008) into badge references. */
export function parseProfileBadges(event: NostrEvent): BadgeRef[] {
if (!isProfileBadgesKind(event.kind)) return [];
// Legacy kind 30008 requires d=profile_badges; kind 10008 doesn't need it
if (event.kind === 30008) {
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (dTag !== 'profile_badges') return [];
}
const refs: BadgeRef[] = [];
const tags = event.tags;
for (let i = 0; i < tags.length; i++) {
if (tags[i][0] === 'a' && tags[i][1]) {
const aTag = tags[i][1];
const parts = aTag.split(':');
if (parts.length < 3) continue;
const kind = parseInt(parts[0], 10);
if (kind !== 30009) continue;
const pubkey = parts[1];
const identifier = parts.slice(2).join(':');
// Look for the corresponding `e` tag immediately after
let eTag: string | undefined;
if (i + 1 < tags.length && tags[i + 1][0] === 'e') {
eTag = tags[i + 1][1];
}
refs.push({ aTag, eTag, kind, pubkey, identifier });
}
}
// Deduplicate by aTag — keep first occurrence only
const seen = new Set<string>();
return refs.filter((r) => {
if (seen.has(r.aTag)) return false;
seen.add(r.aTag);
return true;
});
}
interface ProfileBadgesContentProps {
event: NostrEvent;
}
+3 -2
View File
@@ -19,9 +19,10 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
import { getContentWarning } from '@/lib/contentWarning';
import { MiniAudioPlayer, isAudioUrl, isImageUrl, isVideoUrl } from '@/components/MiniAudioPlayer';
import { MiniAudioPlayer } from '@/components/MiniAudioPlayer';
import { isAudioUrl, isImageUrl, isVideoUrl } from '@/lib/mediaTypeDetection';
import { VideoPlayer } from '@/components/VideoPlayer';
import { parseDimToAspectRatio } from '@/components/MediaCollage';
import { parseDimToAspectRatio } from '@/lib/mediaUtils';
import { isWeatherFieldLabel } from '@/lib/weatherStation';
import { WeatherStationCard } from '@/components/WeatherStationCard';
+2 -3
View File
@@ -21,17 +21,16 @@ import {
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils';
import {
buildKindOptions,
MultiKindPicker,
ScopeToggle,
parseSelectedKinds,
AuthorChip,
AuthorFilterDropdown,
ListPackPicker,
} from '@/components/SavedFeedFiltersEditor';
import type { ScopeOption } from '@/components/SavedFeedFiltersEditor';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
import { useFollowPacks } from '@/hooks/useFollowPacks';
import type { ProfileTab, TabFilter } from '@/lib/profileTabsEvent';
+2 -2
View File
@@ -7,7 +7,7 @@ import {
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { ComposeBox } from '@/components/ComposeBox';
@@ -107,7 +107,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogContentRef}
className="max-w-[520px] max-h-[85vh] rounded-2xl p-0 gap-0 border-border overflow-visible [&>button]:hidden flex flex-col"
className="max-w-[520px] max-h-[85dvh] rounded-2xl p-0 gap-0 border-border overflow-visible [&>button]:hidden !flex !flex-col"
onOpenAutoFocus={(e) => {
e.preventDefault();
const target = e.target as HTMLElement;
+646
View File
@@ -0,0 +1,646 @@
import {
useRef,
useEffect,
useCallback,
useMemo,
forwardRef,
useImperativeHandle,
type IframeHTMLAttributes,
} from 'react';
import { Capacitor } from '@capacitor/core';
import { useAppContext } from '@/hooks/useAppContext';
import {
bytesToBase64,
utf8ToBase64,
injectScriptTags,
} from '@/lib/sandbox';
import type {
FileResponse,
InjectedScript,
JsonRpcResponse,
SerialisedRequest,
} from '@/lib/sandbox';
import {
SandboxPlugin,
type SandboxFetchEvent,
type SandboxScriptMessageEvent,
} from '@/lib/sandboxPlugin';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface SandboxFrameProps
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, 'src' | 'id'> {
/** HMAC-derived subdomain identifier. */
id: string;
/**
* Resolve a pathname to file content.
* Return a `FileResponse` to serve the file, or `null` for a 404.
*/
resolveFile: (pathname: string) => Promise<FileResponse | null>;
/**
* Handle non-fetch, non-lifecycle JSON-RPC methods (e.g. `webxdc.*`).
* Receives the method name, params, and a `post` function for sending
* arbitrary messages back into the sandbox (e.g. push notifications).
* Return the result value to send as the JSON-RPC response.
*/
onRpc?: (
method: string,
params: unknown,
post: (msg: Record<string, unknown>) => void,
) => Promise<unknown>;
/**
* Virtual scripts to inject into HTML responses.
* Each entry is served at its `path` and a `<script src="...">` tag is
* prepended into `<head>` of every HTML response.
*/
injectedScripts?: InjectedScript[];
/** Optional Content-Security-Policy header added to every response. */
csp?: string;
/**
* Called when the sandbox sends `ready`, **before** `init` is sent back.
* If the returned promise is pending, `init` is deferred until it resolves,
* which prevents fetch requests from arriving before the consumer is ready
* to serve files (e.g. while an archive is still being downloaded).
*/
onReady?: () => void | Promise<void>;
}
/** Imperative handle exposed via ref. */
export interface SandboxFrameHandle {
/** Send a postMessage to the sandbox iframe. */
postMessage: (msg: Record<string, unknown>, transfer?: Transferable[]) => void;
/** Focus the iframe element. */
focus: () => void;
}
// ---------------------------------------------------------------------------
// Shared fetch/RPC handler logic
// ---------------------------------------------------------------------------
/**
* Build a serialised HTTP response and call `respond` with it.
* Shared between the web (postMessage) and native (respondToFetch) paths.
*/
async function handleFetchRequest(
pathname: string,
resolveFile: (pathname: string) => Promise<FileResponse | null>,
scripts: InjectedScript[],
activeCsp: string | undefined,
respond: (result: Record<string, unknown>) => void,
respondError: (code: number, message: string) => void,
): Promise<void> {
// Check if the request is for a virtual injected script.
const virtualScript = scripts.find(
(s) => pathname === `/${s.path}` || pathname === s.path,
);
if (virtualScript) {
const headers: Record<string, string> = {
'Content-Type': 'application/javascript',
'Cache-Control': 'no-cache',
};
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
respond({
status: 200,
statusText: 'OK',
headers,
body: utf8ToBase64(virtualScript.content),
});
return;
}
// Delegate to the consumer's file resolver.
try {
const file = await resolveFile(pathname);
if (!file) {
const headers: Record<string, string> = { 'Content-Type': 'text/plain' };
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
respond({
status: 404,
statusText: 'Not Found',
headers,
body: utf8ToBase64('Not Found'),
});
return;
}
// For HTML responses, inject script tags.
let bodyBase64: string;
if (file.contentType === 'text/html' && scripts.length > 0) {
const html = new TextDecoder().decode(file.body);
const injected = injectScriptTags(
html,
scripts.map((s) => `/${s.path}`),
);
bodyBase64 = utf8ToBase64(injected);
} else {
bodyBase64 = bytesToBase64(file.body);
}
const headers: Record<string, string> = {
'Content-Type': file.contentType,
'Cache-Control': 'no-cache',
};
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
// Include Content-Length for non-HTML (binary) responses.
if (file.contentType !== 'text/html') {
headers['Content-Length'] = String(file.body.byteLength);
}
respond({
status: file.status,
statusText: 'OK',
headers,
body: bodyBase64,
});
} catch (err) {
respondError(-32002, String(err));
}
}
// ---------------------------------------------------------------------------
// Web (iframe.diy) implementation
// ---------------------------------------------------------------------------
const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
function SandboxFrameWeb(
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, ...iframeProps },
ref,
) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const { config } = useAppContext();
const origin = useMemo(
() => `https://${id}.${config.sandboxDomain}`,
[id, config.sandboxDomain],
);
// Keep latest callbacks in refs so the message handler always sees
// current values without re-registering the listener.
const resolveFileRef = useRef(resolveFile);
const onRpcRef = useRef(onRpc);
const injectedScriptsRef = useRef(injectedScripts);
const cspRef = useRef(csp);
const onReadyRef = useRef(onReady);
useEffect(() => { resolveFileRef.current = resolveFile; }, [resolveFile]);
useEffect(() => { onRpcRef.current = onRpc; }, [onRpc]);
useEffect(() => { injectedScriptsRef.current = injectedScripts; }, [injectedScripts]);
useEffect(() => { cspRef.current = csp; }, [csp]);
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
// -----------------------------------------------------------------
// Post a message to the iframe
// -----------------------------------------------------------------
const post = useCallback(
(msg: Record<string, unknown>, transfer?: Transferable[]) => {
iframeRef.current?.contentWindow?.postMessage(msg, origin, transfer ?? []);
},
[origin],
);
// Expose imperative handle.
useImperativeHandle(ref, () => ({
postMessage: (msg: Record<string, unknown>, transfer?: Transferable[]) => {
iframeRef.current?.contentWindow?.postMessage(msg, origin, transfer ?? []);
},
focus: () => {
iframeRef.current?.focus();
},
}), [origin]);
// -----------------------------------------------------------------
// Message handler
// -----------------------------------------------------------------
useEffect(() => {
function onMessage(event: MessageEvent) {
if (event.origin !== origin) return;
if (event.source !== iframeRef.current?.contentWindow) return;
const msg = event.data;
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
// Notification: ready -> await onReady, then respond with init
if (msg.method === 'ready' && msg.id === undefined) {
handleReady();
return;
}
// Requests (have an `id`)
if (msg.id !== undefined && msg.method) {
if (msg.method === 'fetch') {
handleFetch(msg.id, msg.params);
} else if (onRpcRef.current) {
handleRpc(msg.id, msg.method, msg.params ?? {});
}
}
}
// ---------------------------------------------------------------
// Ready handler: run consumer setup, then send init
// ---------------------------------------------------------------
async function handleReady() {
try {
await onReadyRef.current?.();
} catch (err) {
console.error('[SandboxFrame] onReady failed:', err);
}
post({ jsonrpc: '2.0', method: 'init', params: { version: 1 } });
}
// ---------------------------------------------------------------
// Fetch handler
// ---------------------------------------------------------------
async function handleFetch(
id: string | number,
params: { request?: SerialisedRequest },
) {
const reqUrl = params?.request?.url;
if (!reqUrl) {
post({ jsonrpc: '2.0', id, error: { code: -32001, message: 'Invalid request' } });
return;
}
let pathname: string;
try {
const url = new URL(reqUrl);
// Only serve requests for our sandbox origin.
if (url.origin !== origin) {
post({ jsonrpc: '2.0', id, error: { code: -32003, message: 'Origin mismatch' } });
return;
}
pathname = url.pathname;
} catch {
post({ jsonrpc: '2.0', id, error: { code: -32003, message: 'Invalid URL' } });
return;
}
await handleFetchRequest(
pathname,
resolveFileRef.current,
injectedScriptsRef.current ?? [],
cspRef.current,
(result) => post({ jsonrpc: '2.0', id, result }),
(code, message) => post({ jsonrpc: '2.0', id, error: { code, message } }),
);
}
// ---------------------------------------------------------------
// Custom RPC handler
// ---------------------------------------------------------------
async function handleRpc(
id: string | number,
method: string,
params: unknown,
) {
try {
const result = await onRpcRef.current!(method, params, post);
post({ jsonrpc: '2.0', id, result: result ?? null } satisfies JsonRpcResponse);
} catch (err) {
post({
jsonrpc: '2.0',
id,
error: { code: -1, message: String(err) },
} satisfies JsonRpcResponse);
}
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [origin, post]);
return (
<iframe
ref={iframeRef}
src={`${origin}/`}
{...iframeProps}
/>
);
},
);
// ---------------------------------------------------------------------------
// Native (Capacitor) implementation
// ---------------------------------------------------------------------------
const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
function SandboxFrameNative(
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, className, style, title },
ref,
) {
const placeholderRef = useRef<HTMLDivElement>(null);
const createdRef = useRef(false);
const destroyedRef = useRef(false);
// Keep latest callbacks in refs.
const resolveFileRef = useRef(resolveFile);
const onRpcRef = useRef(onRpc);
const injectedScriptsRef = useRef(injectedScripts);
const cspRef = useRef(csp);
const onReadyRef = useRef(onReady);
useEffect(() => { resolveFileRef.current = resolveFile; }, [resolveFile]);
useEffect(() => { onRpcRef.current = onRpc; }, [onRpc]);
useEffect(() => { injectedScriptsRef.current = injectedScripts; }, [injectedScripts]);
useEffect(() => { cspRef.current = csp; }, [csp]);
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
// -----------------------------------------------------------------
// Post a message into the native sandbox
// -----------------------------------------------------------------
const postToSandbox = useCallback(
(msg: Record<string, unknown>) => {
if (!createdRef.current || destroyedRef.current) return;
SandboxPlugin.postMessage({ id, message: msg }).catch((err) => {
console.error('[SandboxFrame] postMessage failed:', err);
});
},
[id],
);
// Expose imperative handle.
useImperativeHandle(
ref,
() => ({
postMessage: (msg: Record<string, unknown>) => {
postToSandbox(msg);
},
focus: () => {
// No-op on native — the WebView is overlaid, not an iframe.
},
}),
[postToSandbox],
);
// -----------------------------------------------------------------
// Lifecycle: onReady -> create WebView -> listen for events -> destroy
// -----------------------------------------------------------------
useEffect(() => {
if (createdRef.current) return;
const listeners: Array<{ remove: () => void }> = [];
let cancelled = false;
async function setup() {
// Run onReady first so the consumer can prepare (e.g. download and
// unzip a .xdc archive) before the native WebView starts loading
// resources. This mirrors the web behaviour where onReady runs
// before `init` is sent.
try {
await onReadyRef.current?.();
} catch (err) {
console.error('[SandboxFrame] onReady failed:', err);
}
if (cancelled || destroyedRef.current) return;
// Measure the placeholder position.
const el = placeholderRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
// Register listeners BEFORE creating the WebView. On Android,
// `shouldInterceptRequest` fires on a background thread as soon
// as the WebView starts loading — if the fetch listener isn't
// registered yet, the event is lost and the request times out
// (the thread blocks via CountDownLatch waiting for a response
// that never arrives).
const fetchListener = await SandboxPlugin.addListener(
'fetch',
(event: SandboxFetchEvent) => {
if (event.id !== id) return;
handleNativeFetch(event);
},
);
listeners.push(fetchListener);
const scriptListener = await SandboxPlugin.addListener(
'scriptMessage',
(event: SandboxScriptMessageEvent) => {
if (event.id !== id) return;
handleNativeScriptMessage(event);
},
);
listeners.push(scriptListener);
if (cancelled || destroyedRef.current) return;
// Create the native WebView. Fetch events from the initial load
// will be handled by the listeners registered above.
await SandboxPlugin.create({
id,
frame: {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
});
if (cancelled || destroyedRef.current) {
// Component unmounted while we were awaiting — clean up immediately.
SandboxPlugin.destroy({ id }).catch(() => {});
return;
}
createdRef.current = true;
}
// ---------------------------------------------------------------
// Handle a fetch request from the native WebView
// ---------------------------------------------------------------
async function handleNativeFetch(event: SandboxFetchEvent) {
const reqUrl = event.request.url;
let pathname: string;
try {
pathname = new URL(reqUrl).pathname;
} catch {
// The native handler rewrites custom-scheme URLs to
// https://<id>.sandbox.native/<path> so we can parse them.
// If that fails, try extracting the path directly.
const pathMatch = reqUrl.match(/\/\/[^/]+(\/.*)/);
pathname = pathMatch?.[1] ?? '/';
}
await handleFetchRequest(
pathname,
resolveFileRef.current,
injectedScriptsRef.current ?? [],
cspRef.current,
(result) => {
SandboxPlugin.respondToFetch({
id,
requestId: event.requestId,
response: result as {
status: number;
statusText: string;
headers: Record<string, string>;
body: string | null;
},
}).catch((err) => {
console.error('[SandboxFrame] respondToFetch failed:', err);
});
},
(_code, message) => {
SandboxPlugin.respondToFetch({
id,
requestId: event.requestId,
response: {
status: 500,
statusText: 'Internal Error',
headers: { 'Content-Type': 'text/plain' },
body: btoa(message),
},
}).catch((err) => {
console.error('[SandboxFrame] respondToFetch error failed:', err);
});
},
);
}
// ---------------------------------------------------------------
// Handle a script message from the native WebView
// ---------------------------------------------------------------
async function handleNativeScriptMessage(event: SandboxScriptMessageEvent) {
const msg = event.message;
if (!msg || typeof msg !== 'object') return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rpc = msg as any;
if (rpc.jsonrpc !== '2.0') return;
// Handle RPC requests (have both `id` and `method`).
if (rpc.id !== undefined && rpc.method && onRpcRef.current) {
try {
const result = await onRpcRef.current(
rpc.method,
rpc.params ?? {},
postToSandbox,
);
postToSandbox({
jsonrpc: '2.0',
id: rpc.id,
result: result ?? null,
});
} catch (err) {
postToSandbox({
jsonrpc: '2.0',
id: rpc.id,
error: { code: -1, message: String(err) },
});
}
}
}
setup().catch((err) => {
console.error('[SandboxFrame] native setup failed:', err);
});
return () => {
cancelled = true;
destroyedRef.current = true;
for (const listener of listeners) {
listener.remove();
}
if (createdRef.current) {
SandboxPlugin.destroy({ id }).catch((err) => {
console.error('[SandboxFrame] destroy failed:', err);
});
createdRef.current = false;
}
};
}, [id, postToSandbox]);
// -----------------------------------------------------------------
// Keep frame in sync with placeholder size/position
//
// Both consumers (WebxdcEmbed, NsitePreviewDialog) render inside
// position:fixed panels, so the placeholder never moves on scroll.
// A ResizeObserver is sufficient to track layout changes.
// -----------------------------------------------------------------
useEffect(() => {
const el = placeholderRef.current;
if (!el) return;
function updateFrame() {
if (!createdRef.current || destroyedRef.current) return;
const rect = el!.getBoundingClientRect();
SandboxPlugin.updateFrame({
id,
frame: {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
}).catch(() => {
// Ignore — WebView may not be created yet.
});
}
const ro = new ResizeObserver(updateFrame);
ro.observe(el);
return () => {
ro.disconnect();
};
}, [id]);
return (
<div
ref={placeholderRef}
className={className}
style={style}
title={title}
data-sandbox-id={id}
/>
);
},
);
// ---------------------------------------------------------------------------
// Public component — delegates to web or native implementation
// ---------------------------------------------------------------------------
/**
* Renders a sandboxed content frame.
*
* On web, this creates an iframe on a unique subdomain (`<id>.<sandboxDomain>`)
* and implements the iframe.diy handshake + fetch proxy protocol.
*
* On native platforms (iOS/Android via Capacitor), this creates a native
* WKWebView/WebView overlay with a custom URL scheme handler that intercepts
* all requests and routes them through the same `resolveFile` callback.
*
* All file serving is delegated to the `resolveFile` callback.
* Custom RPC methods are delegated to the optional `onRpc` callback.
* Consumers (Webxdc, NsitePreviewDialog) are platform-agnostic.
*/
export const SandboxFrame = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
function SandboxFrame(props, ref) {
if (Capacitor.isNativePlatform()) {
return <SandboxFrameNative ref={ref} {...props} />;
}
return <SandboxFrameWeb ref={ref} {...props} />;
},
);
export default SandboxFrame;
+4 -40
View File
@@ -26,8 +26,6 @@ import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { useAuthor } from '@/hooks/useAuthor';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
import { useFollowPacks } from '@/hooks/useFollowPacks';
import { EXTRA_KINDS } from '@/lib/extraKinds';
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
import { cn } from '@/lib/utils';
import type { TabFilter } from '@/contexts/AppContext';
import type { SearchProfile } from '@/hooks/useSearchProfiles';
@@ -46,40 +44,11 @@ type KindOption = {
// ─── Kind options (built once) ───────────────────────────────────────────────
export function buildKindOptions(): KindOption[] {
const options: KindOption[] = [];
for (const def of EXTRA_KINDS) {
if (def.subKinds) {
for (const sub of def.subKinds) {
options.push({
value: String(sub.kind),
label: `${sub.label} (${sub.kind})`,
description: sub.description,
parentId: def.id,
icon: CONTENT_KIND_ICONS[def.id],
});
}
} else {
options.push({
value: String(def.kind),
label: `${def.label} (${def.kind})`,
description: def.description,
parentId: def.id,
icon: CONTENT_KIND_ICONS[def.id],
});
}
}
const seen = new Set<string>();
return options.filter((o) => {
if (seen.has(o.value)) return false;
seen.add(o.value);
return true;
});
}
import { buildKindOptions } from '@/lib/feedFilterUtils';
// ─── useScrollCarets ─────────────────────────────────────────────────────────
export function useScrollCarets() {
function useScrollCarets() {
const scrollRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
@@ -534,12 +503,7 @@ export function ListPackPicker({ lists, followPacks, value, onSelectPubkeys, cla
// ─── parseSelectedKinds ───────────────────────────────────────────────────────
/** Parse a TabFilter's kinds array into an array of string kind values. */
export function parseSelectedKinds(filter: TabFilter): string[] {
const kinds = filter.kinds;
if (!Array.isArray(kinds) || kinds.length === 0) return [];
return kinds.map(String);
}
// ─── AuthorChip ───────────────────────────────────────────────────────────────
@@ -630,7 +594,7 @@ export function SavedFeedFiltersEditor({
);
const search = typeof value.search === 'string' ? value.search : '';
const authorPubkeys = Array.isArray(value.authors) ? (value.authors as string[]) : [];
const authorPubkeys = useMemo(() => Array.isArray(value.authors) ? (value.authors as string[]) : [], [value.authors]);
// Local scope state so clicking "People" immediately shows the panel,
// even before any authors have been added. Initialised from the filter value.
const [authorScope, setAuthorScopeState] = useState<'anyone' | 'people'>(
+71 -16
View File
@@ -1,8 +1,9 @@
import { useState, useRef, useEffect } from 'react';
import { createContext, useContext } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { useNavHidden } from '@/contexts/LayoutContext';
import { SubHeaderBarContext } from '@/components/SubHeaderBarContext';
interface HoverSlice {
left: number;
@@ -21,17 +22,6 @@ interface SubHeaderBarProps {
pinned?: boolean;
}
interface SubHeaderBarContextValue {
onHover: (slice: HoverSlice | null) => void;
onActive: (slice: HoverSlice | null) => void;
}
export const SubHeaderBarContext = createContext<SubHeaderBarContextValue>({ onHover: () => {}, onActive: () => {} });
export function useSubHeaderBarHover() {
return useContext(SubHeaderBarContext);
}
/**
* Shared sticky sub-header bar with a unified arc+background drawn as a single
* SVG shape. Eliminates the sub-pixel seam between a bg-background/80 container
@@ -52,6 +42,44 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
const barRef = useRef<HTMLDivElement>(null);
const [atTop, setAtTop] = useState(false);
// Horizontal overflow scroll arrows (desktop only)
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkOverflow = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const tolerance = 2; // sub-pixel rounding tolerance
setCanScrollLeft(el.scrollLeft > tolerance);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - tolerance);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
checkOverflow();
el.addEventListener('scroll', checkOverflow, { passive: true });
const ro = new ResizeObserver(checkOverflow);
ro.observe(el);
return () => {
el.removeEventListener('scroll', checkOverflow);
ro.disconnect();
};
}, [checkOverflow]);
// Also re-check overflow when children change (new tabs added/removed)
useEffect(() => {
checkOverflow();
}, [children, checkOverflow]);
const scrollBy = (direction: 'left' | 'right') => {
const el = scrollRef.current;
if (!el) return;
const amount = el.clientWidth * 0.6;
el.scrollBy({ left: direction === 'left' ? -amount : amount, behavior: 'smooth' });
};
useEffect(() => {
if (!pinned) return;
@@ -76,7 +104,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
const showSafeAreaPadding = pinned && navHidden && atTop;
return (
<SubHeaderBarContext.Provider value={{ onHover: setHover, onActive: setActive }}>
<SubHeaderBarContext.Provider value={{ onHover: setHover, onActive: setActive, scrollContainerRef: scrollRef }}>
<div
ref={barRef}
className={cn(
@@ -132,8 +160,35 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
</svg>
)}
{/* Tab content sits above the SVG background */}
<div className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}>
{children}
<div className="relative">
{/* Left scroll arrow — desktop only, shown when overflowing */}
{canScrollLeft && (
<button
type="button"
aria-label="Scroll tabs left"
onClick={() => scrollBy('left')}
className="hidden sidebar:flex absolute left-0 top-0 bottom-0 z-10 items-center pl-0.5 pr-1 bg-gradient-to-r from-background via-background to-transparent cursor-pointer"
>
<ChevronLeft className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
</button>
)}
<div
ref={scrollRef}
className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}
>
{children}
</div>
{/* Right scroll arrow — desktop only, shown when overflowing */}
{canScrollRight && (
<button
type="button"
aria-label="Scroll tabs right"
onClick={() => scrollBy('right')}
className="hidden sidebar:flex absolute right-0 top-0 bottom-0 z-10 items-center pr-0.5 pl-1 bg-gradient-to-l from-background via-background to-transparent cursor-pointer"
>
<ChevronRight className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
</button>
)}
</div>
</div>
</div>
+83
View File
@@ -0,0 +1,83 @@
import { createContext, useContext, useCallback, useLayoutEffect, useEffect } from 'react';
interface HoverSlice {
left: number;
width: number;
}
interface SubHeaderBarContextValue {
onHover: (slice: HoverSlice | null) => void;
onActive: (slice: HoverSlice | null) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
}
export const SubHeaderBarContext = createContext<SubHeaderBarContextValue>({ onHover: () => {}, onActive: () => {}, scrollContainerRef: { current: null } });
export function useSubHeaderBarHover() {
return useContext(SubHeaderBarContext);
}
/**
* Shared hook for reporting the active tab's position to SubHeaderBar's arc indicator.
* Handles scroll-aware position reporting and cleans up on unmount/deactivation.
*
* @param active Whether this tab is currently active.
* @param elRef Ref to the tab's DOM element (used for offsetLeft/offsetWidth).
*/
export function useActiveTabIndicator(active: boolean, elRef: React.RefObject<HTMLElement | null>) {
const { onActive, scrollContainerRef } = useSubHeaderBarHover();
const reportSlice = useCallback(() => {
const el = elRef.current;
if (!el) return null;
const container = scrollContainerRef.current;
const scrollOffset = container?.scrollLeft ?? 0;
// Account for the scroll container's own offset within its parent
// (e.g. when innerClassName adds mx-auto centering).
const containerOffset = container?.offsetLeft ?? 0;
return { left: el.offsetLeft - scrollOffset + containerOffset, width: el.offsetWidth };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Report active slice to SubHeaderBar so the arc indicator renders.
// Schedule a second report after paint so that layout-dependent values
// (e.g. offsetLeft from mx-auto centering) are fully resolved.
useLayoutEffect(() => {
if (!active) return;
const s = reportSlice();
if (s) onActive(s);
const raf = requestAnimationFrame(() => {
const updated = reportSlice();
if (updated) onActive(updated);
});
return () => {
cancelAnimationFrame(raf);
onActive(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
// Re-report position when the scroll container scrolls or resizes,
// so the SVG clip-path stays aligned with the visually shifted tab.
useEffect(() => {
if (!active) return;
const container = scrollContainerRef.current;
if (!container) return;
const update = () => {
const s = reportSlice();
if (s) onActive(s);
};
container.addEventListener('scroll', update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(container);
return () => {
container.removeEventListener('scroll', update);
ro.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
return { reportSlice };
}
+17 -10
View File
@@ -1,6 +1,6 @@
import { useRef, useLayoutEffect } from 'react';
import { cn } from '@/lib/utils';
import { useSubHeaderBarHover } from '@/components/SubHeaderBar';
import { useSubHeaderBarHover, useActiveTabIndicator } from '@/components/SubHeaderBarContext';
interface TabButtonProps {
/** Tab display label. */
@@ -26,18 +26,25 @@ interface TabButtonProps {
*/
export function TabButton({ label, active, onClick, disabled, className, children }: TabButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
const { onHover, onActive } = useSubHeaderBarHover();
const reportSlice = () => {
const btn = ref.current;
if (!btn) return;
return { left: btn.offsetLeft, width: btn.offsetWidth };
};
const { onHover, scrollContainerRef } = useSubHeaderBarHover();
const { reportSlice } = useActiveTabIndicator(active, ref);
// Auto-scroll the active tab into view when the container overflows
useLayoutEffect(() => {
if (!active) return;
const s = reportSlice();
if (s) onActive(s);
const btn = ref.current;
const container = scrollContainerRef.current;
if (btn && container) {
const btnLeft = btn.offsetLeft;
const btnRight = btnLeft + btn.offsetWidth;
const viewLeft = container.scrollLeft;
const viewRight = viewLeft + container.clientWidth;
if (btnLeft < viewLeft) {
container.scrollTo({ left: btnLeft - 8, behavior: 'smooth' });
} else if (btnRight > viewRight) {
container.scrollTo({ left: btnRight - container.clientWidth + 8, behavior: 'smooth' });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
+18 -19
View File
@@ -9,6 +9,7 @@ import { getAvatarShape } from '@/lib/avatarShape';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useNostr } from '@nostrify/react';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { useAuthors } from '@/hooks/useAuthors';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList } from '@/hooks/useFollowActions';
@@ -87,34 +88,32 @@ export function TeamSoapboxCard({ className }: { className?: string }) {
setIsFollowingAll(true);
try {
const signal = AbortSignal.timeout(10_000);
// 1. Fetch freshest kind 3 from relays (not cache)
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
const followEvents = await nostr.query(
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
{ signal },
);
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
const latestEvent = followEvents.length > 0
? followEvents.reduce((latest, current) => current.created_at > latest.created_at ? current : latest)
: null;
const existingFollows = latestEvent
? latestEvent.tags.filter(([name]) => name === 'p').map(([, pk]) => pk)
: [];
const allFollows = [...new Set([...existingFollows, ...pubkeys])];
const added = pubkeys.filter((pk) => !existingFollows.includes(pk));
// 3. Merge: add new pubkeys that aren't already followed
const newPTags = pubkeys
.filter((pk) => !existingPubkeys.has(pk))
.map((pk) => ['p', pk]);
const added = newPTags.length;
// 4. Publish with prev for published_at preservation
await publishEvent({
kind: 3,
content: latestEvent?.content ?? '',
tags: allFollows.map((pk) => ['p', pk]),
content: prev?.content ?? '',
tags: [...nonPTags, ...existingPTags, ...newPTags],
prev: prev ?? undefined,
});
toast({
title: 'Following Team Soapbox!',
description: added.length > 0
? `Added ${added.length} new account${added.length !== 1 ? 's' : ''} to your follow list.`
description: added > 0
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
: 'You were already following everyone on the team.',
});
} catch (error) {
+1 -1
View File
@@ -20,7 +20,7 @@ import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
/** Extracts HSL color string from a theme token value like "258 70% 55%" */
function hsl(value: string): string {
+7 -3
View File
@@ -1,6 +1,7 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { useState } from 'react';
import { NoteCard } from '@/components/NoteCard';
import { cn } from '@/lib/utils';
/** Maximum nesting depth before collapsing the rest of the thread. */
const MAX_RENDER_DEPTH = 3;
@@ -34,7 +35,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
return (
<div>
<NoteCard event={node.event} threaded />
<ExpandThreadButton count={countDescendants(node)} onClick={() => setExpanded(true)} />
<ExpandThreadButton count={countDescendants(node)} onClick={() => setExpanded(true)} isLast />
</div>
);
}
@@ -72,11 +73,14 @@ function countDescendants(node: ReplyNode): number {
return count;
}
function ExpandThreadButton({ count, onClick }: { count: number; onClick: () => void }) {
function ExpandThreadButton({ count, onClick, isLast }: { count: number; onClick: () => void; isLast?: boolean }) {
return (
<button
onClick={onClick}
className="flex items-center gap-3 px-4 pt-0 pb-2.5 w-full hover:bg-secondary/30 transition-colors group"
className={cn(
"flex items-center gap-3 px-4 pt-0 pb-2.5 w-full hover:bg-secondary/30 transition-colors group",
isLast && "border-b border-border",
)}
>
<div className="flex flex-col items-center w-10">
<div className="w-0.5 flex-1 mb-1 bg-foreground/20" />
+1 -139
View File
@@ -5,6 +5,7 @@ import { Blurhash } from 'react-blurhash';
import { cn } from '@/lib/utils';
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
import { usePlayerControls } from '@/hooks/usePlayerControls';
import { useVideoThumbnail } from '@/hooks/useVideoThumbnail';
import { formatTime } from '@/lib/formatTime';
interface VideoPlayerProps {
@@ -30,145 +31,6 @@ function parseDim(dim: string | undefined): { width: number; height: number } |
}
/**
* Extracts a thumbnail frame from a video URL by loading it off-screen,
* drawing the first frame to a canvas, and returning a data URL.
* Works reliably on Android WebView where preload="metadata" doesn't render a visible frame.
*/
export function useVideoThumbnail(src: string, poster: string | undefined): string | undefined {
const [thumbnail, setThumbnail] = useState<string | undefined>(poster);
useEffect(() => {
// Skip if we already have a poster image
if (poster) return;
if (!src) return;
let cancelled = false;
function grabFrameFromUrl(videoSrc: string) {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.muted = true;
video.playsInline = true;
video.preload = 'metadata';
video.src = videoSrc;
function captureFrame() {
if (cancelled) return;
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 320;
canvas.height = video.videoHeight || 180;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
if (dataUrl.length > 1000) setThumbnail(dataUrl);
}
} catch { /* CORS or tainted canvas */ }
video.src = '';
video.load();
}
// After metadata loads, seek to 0.1s — then capture on seeked
const handleMetadata = () => { video.currentTime = 0.1; };
const handleSeeked = () => captureFrame();
video.addEventListener('loadedmetadata', handleMetadata, { once: true });
video.addEventListener('seeked', handleSeeked, { once: true });
return () => {
video.removeEventListener('loadedmetadata', handleMetadata);
video.removeEventListener('seeked', handleSeeked);
video.src = '';
video.load();
};
}
// For HLS: use hls.js to load the stream into an off-screen video, then grab a frame
if (/\.m3u8(\?|$)/i.test(src)) {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.muted = true;
video.playsInline = true;
// Safari — native HLS support, no need for hls.js
if (video.canPlayType('application/vnd.apple.mpegurl')) {
const grabFrame = () => {
if (cancelled) return;
video.play().then(() => {
video.pause();
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 320;
canvas.height = video.videoHeight || 180;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
if (dataUrl.length > 1000) setThumbnail(dataUrl);
}
} catch { /* tainted canvas */ }
video.src = '';
}).catch(() => { /* ignore */ });
};
video.src = src;
video.addEventListener('loadeddata', grabFrame, { once: true });
return () => {
cancelled = true;
video.removeEventListener('loadeddata', grabFrame);
video.src = '';
};
}
// Non-Safari: dynamically import hls.js
let hlsInstance: Hls | null = null;
import('hls.js').then(({ default: HlsLib }) => {
if (cancelled || !HlsLib.isSupported()) return;
const grabFrame = () => {
if (cancelled) return;
video.play().then(() => {
video.pause();
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 320;
canvas.height = video.videoHeight || 180;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
if (dataUrl.length > 1000) setThumbnail(dataUrl);
}
} catch { /* tainted canvas */ }
hlsInstance?.destroy();
hlsInstance = null;
video.src = '';
}).catch(() => { hlsInstance?.destroy(); hlsInstance = null; });
};
const hls = new HlsLib({ startLevel: -1, maxBufferLength: 5 });
hlsInstance = hls;
hls.loadSource(src);
hls.attachMedia(video);
hls.on(HlsLib.Events.MANIFEST_PARSED, () => {
if (cancelled) { hls.destroy(); return; }
grabFrame();
});
});
return () => { cancelled = true; hlsInstance?.destroy(); hlsInstance = null; video.src = ''; };
}
// Regular video file
const cleanupDirect = grabFrameFromUrl(src);
return () => { cancelled = true; cleanupDirect(); };
}, [src, poster]);
return thumbnail;
}
/** Attaches hls.js to a video element for HLS streams on non-Safari browsers. */
function useHls(videoRef: React.RefObject<HTMLVideoElement | null>, src: string) {
const hlsRef = useRef<Hls | null>(null);
+368 -222
View File
@@ -2,19 +2,25 @@ import {
useRef,
useEffect,
useCallback,
useImperativeHandle,
forwardRef,
useImperativeHandle,
type IframeHTMLAttributes,
} from "react";
import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from "@webxdc/types";
} from 'react';
import { unzipSync } from 'fflate';
import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from '@webxdc/types/webxdc';
import { SandboxFrame, type SandboxFrameHandle } from '@/components/SandboxFrame';
import { getMimeType, bytesToBase64, injectScriptTags } from '@/lib/sandbox';
import type { FileResponse } from '@/lib/sandbox';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface WebxdcProps
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, "src" | "id"> {
/** Unique session identifier — used as the subdomain: `<id>.webxdc.app`. */
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, 'src' | 'id'> {
/** Unique session identifier — used as the sandbox subdomain. */
id: string;
/** The `.xdc` archive: raw bytes or a URL to fetch them from. */
xdc: Uint8Array | string;
@@ -30,21 +36,181 @@ export interface WebxdcHandle {
focus: () => void;
}
// ---------------------------------------------------------------------------
// CSP applied to every response served from the archive.
//
// The webxdc spec requires that all internet access is denied. We enforce
// this with a strict Content-Security-Policy on every response. Permits
// same-origin, inline, eval, wasm, data: and blob: — all commonly needed
// by webxdc apps — but blocks any external network access.
// ---------------------------------------------------------------------------
const WEBXDC_CSP = [
"default-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' data: blob:",
"base-uri 'self'",
"form-action 'self'",
].join('; ');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Resolve `xdc` prop to an ArrayBuffer. */
async function resolveXdc(xdc: Uint8Array | string): Promise<ArrayBuffer> {
if (typeof xdc === "string") {
/** Resolve `xdc` prop to a Uint8Array. */
async function resolveXdc(xdc: Uint8Array | string): Promise<Uint8Array> {
if (typeof xdc === 'string') {
const res = await fetch(xdc);
if (!res.ok) throw new Error(`Failed to fetch xdc: ${res.status}`);
return res.arrayBuffer();
return new Uint8Array(await res.arrayBuffer());
}
// Uint8Array → ArrayBuffer (copy so we can transfer)
const copy = new ArrayBuffer(xdc.byteLength);
new Uint8Array(copy).set(xdc);
return copy;
return xdc;
}
/** Unzip a `.xdc` archive into a normalised file map. */
function unzipXdc(bytes: Uint8Array): Map<string, Uint8Array> {
const unzipped = unzipSync(bytes);
const fileMap = new Map<string, Uint8Array>();
for (const [path, content] of Object.entries(unzipped)) {
const normalised = path.replace(/^\/+/, '').replace(/\\/g, '/');
if (normalised.endsWith('/')) continue; // skip directories
fileMap.set(normalised, content);
}
return fileMap;
}
/**
* Generate the webxdc bridge script that will be injected into HTML responses.
* This script implements window.webxdc by sending JSON-RPC requests to the
* parent through the sandbox frame's relay.
*/
function generateWebxdcBridge(api: WebxdcAPI<unknown>): string {
return `(function(){
var nextId = 1;
var pending = {};
var updateListener = null;
var updateListenerReady = null;
var realtimeDataListener = null;
var realtimeChannelId = null;
function send(msg) {
window.parent.postMessage(msg, "*");
}
function sendRequest(method, params) {
var id = nextId++;
return new Promise(function(resolve, reject) {
pending[id] = { resolve: resolve, reject: reject };
send({ jsonrpc: "2.0", id: id, method: method, params: params });
});
}
function sendNotification(method, params) {
send({ jsonrpc: "2.0", method: method, params: params });
}
window.addEventListener("message", function(event) {
var data = event.data;
if (!data || typeof data !== "object" || data.jsonrpc !== "2.0") return;
// JSON-RPC response
if (data.id !== undefined && !data.method) {
var p = pending[data.id];
if (p) {
delete pending[data.id];
if (data.error) {
p.reject(new Error(data.error.message));
} else {
p.resolve(data.result);
}
}
return;
}
// Notifications from parent
if (data.method && data.id === undefined) {
switch (data.method) {
case "webxdc.update":
if (updateListener) updateListener(data.params.update);
break;
case "webxdc.realtimeChannel.data":
if (realtimeDataListener) realtimeDataListener(new Uint8Array(data.params.data));
break;
case "webxdc.keyboard":
var p2 = data.params;
var evt = new KeyboardEvent(p2.type, {
key: p2.key, code: p2.code, keyCode: p2.keyCode,
bubbles: true, cancelable: true, composed: true
});
window.dispatchEvent(evt);
document.dispatchEvent(new KeyboardEvent(p2.type, {
key: p2.key, code: p2.code, keyCode: p2.keyCode,
bubbles: true, cancelable: true
}));
break;
}
}
});
window.webxdc = {
selfAddr: ${JSON.stringify(api.selfAddr)},
selfName: ${JSON.stringify(api.selfName)},
sendUpdateInterval: ${api.sendUpdateInterval},
sendUpdateMaxSize: ${api.sendUpdateMaxSize},
sendUpdate: function(update, descr) {
sendRequest("webxdc.sendUpdate", { update: update, descr: descr });
},
setUpdateListener: function(cb, serial) {
updateListener = cb;
return new Promise(function(resolve) {
updateListenerReady = resolve;
sendRequest("webxdc.setUpdateListener", { serial: serial || 0 }).then(function() {
if (updateListenerReady) { updateListenerReady(); updateListenerReady = null; }
});
});
},
getAllUpdates: function() {
return sendRequest("webxdc.getAllUpdates");
},
sendToChat: function(message) {
return sendRequest("webxdc.sendToChat", { message: message });
},
importFiles: function(filter) {
return sendRequest("webxdc.importFiles", { filter: filter || {} });
},
joinRealtimeChannel: function() {
if (realtimeChannelId) throw new Error("Already joined a realtime channel. Leave first.");
var channelIdPromise = sendRequest("webxdc.joinRealtimeChannel");
var joined = true;
channelIdPromise.then(function(r) { realtimeChannelId = r.channelId; });
return {
setListener: function(cb) {
if (!joined) throw new Error("Channel has been left.");
realtimeDataListener = cb;
},
send: function(data) {
if (!joined) throw new Error("Channel has been left.");
channelIdPromise.then(function(r) {
sendRequest("webxdc.realtimeChannel.send", { channelId: r.channelId, data: Array.from(data) });
});
},
leave: function() {
if (!joined) return;
joined = false;
realtimeDataListener = null;
channelIdPromise.then(function(r) {
sendRequest("webxdc.realtimeChannel.leave", { channelId: r.channelId });
realtimeChannelId = null;
});
}
};
}
};
})();`;
}
// ---------------------------------------------------------------------------
@@ -52,217 +218,46 @@ async function resolveXdc(xdc: Uint8Array | string): Promise<ArrayBuffer> {
// ---------------------------------------------------------------------------
/**
* Renders a webxdc app inside an iframe hosted on `<id>.webxdc.app`.
* Renders a webxdc app inside a sandboxed iframe.
*
* The component handles the full JSON-RPC lifecycle:
* 1. Waits for `webxdc.ready` from the frame.
* 2. Sends `webxdc.init` with the `.xdc` bytes.
* 3. Proxies every JSON-RPC request to the provided `Webxdc` instance.
* 4. Forwards `webxdc.update` notifications into the frame.
* The component handles the full lifecycle:
* 1. Fetches and unzips the `.xdc` archive on the parent side.
* 2. Serves files from the archive via the sandbox frame's fetch proxy.
* 3. Injects the webxdc bridge script into HTML responses.
* 4. Handles `webxdc.*` RPC requests from the bridge script and proxies
* them to the provided `WebxdcAPI` instance.
*/
export const Webxdc = forwardRef<WebxdcHandle, WebxdcProps>(function Webxdc(
{ id, xdc, webxdc, ...iframeProps },
ref,
) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const sandboxRef = useRef<SandboxFrameHandle>(null);
// Keep latest props in refs so the message handler always sees current values
// without needing to re-register the listener.
// Keep latest props in refs so callbacks always see current values.
const webxdcRef = useRef(webxdc);
const xdcRef = useRef(xdc);
useEffect(() => {
webxdcRef.current = webxdc;
}, [webxdc]);
useEffect(() => {
xdcRef.current = xdc;
}, [xdc]);
useEffect(() => { webxdcRef.current = webxdc; }, [webxdc]);
useEffect(() => { xdcRef.current = xdc; }, [xdc]);
const origin = `https://${id}.webxdc.app`;
// The unzipped file map, populated on first `onReady`.
const fileMapRef = useRef<Map<string, Uint8Array> | null>(null);
// The generated bridge script, cached per webxdc instance.
const bridgeScriptRef = useRef<string>('');
// ------------------------------------------------------------------
// Post a JSON-RPC message to the iframe
// ------------------------------------------------------------------
const post = useCallback(
(msg: Record<string, unknown>, transfer?: Transferable[]) => {
iframeRef.current?.contentWindow?.postMessage(
msg,
origin,
transfer ?? [],
);
},
[origin],
);
// Realtime channel handles, keyed by channelId.
const realtimeChannels = useRef<
Map<string, ReturnType<WebxdcAPI<unknown>['joinRealtimeChannel']>>
>(new Map());
// Expose imperative handle so parent components can post messages and focus.
useImperativeHandle(ref, () => ({
postMessage: (msg: Record<string, unknown>, transfer?: Transferable[]) => {
iframeRef.current?.contentWindow?.postMessage(
msg,
origin,
transfer ?? [],
);
sandboxRef.current?.postMessage(msg, transfer);
},
focus: () => {
iframeRef.current?.focus();
sandboxRef.current?.focus();
},
}), [origin]);
// ------------------------------------------------------------------
// Handle messages coming from the iframe
// ------------------------------------------------------------------
useEffect(() => {
function onMessage(event: MessageEvent) {
// Only accept messages from our iframe's origin.
if (event.origin !== origin) return;
if (event.source !== iframeRef.current?.contentWindow) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msg = event.data as any;
if (!msg || msg.jsonrpc !== "2.0") return;
const api = webxdcRef.current;
// --- Notification: webxdc.ready → send webxdc.init ---------------
if (msg.method === "webxdc.ready" && msg.id === undefined) {
resolveXdc(xdcRef.current).then((buf) => {
const initMsg = {
jsonrpc: "2.0" as const,
method: "webxdc.init",
params: {
xdc: buf,
selfAddr: api.selfAddr,
selfName: api.selfName,
sendUpdateInterval: api.sendUpdateInterval,
sendUpdateMaxSize: api.sendUpdateMaxSize,
},
};
iframeRef.current?.contentWindow?.postMessage(
initMsg,
origin,
[buf], // transfer
);
});
return;
}
// --- Requests (have an `id`) ------------------------------------
if (msg.id !== undefined && msg.method) {
handleRequest(msg.id, msg.method, msg.params ?? {});
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function handleRequest(id: string | number, method: string, params: any) {
const api = webxdcRef.current;
const respond = (result: unknown) =>
post({ jsonrpc: "2.0", id, result });
const respondError = (code: number, message: string) =>
post({ jsonrpc: "2.0", id, error: { code, message } });
try {
switch (method) {
case "webxdc.sendUpdate": {
api.sendUpdate(params.update, "");
respond(null);
break;
}
case "webxdc.setUpdateListener": {
const serial: number = params.serial ?? 0;
// Forward every update into the frame as a notification.
await api.setUpdateListener(
(update: ReceivedStatusUpdate<unknown>) => {
post({
jsonrpc: "2.0",
method: "webxdc.update",
params: { update },
});
},
serial,
);
respond(null);
break;
}
case "webxdc.getAllUpdates": {
const updates = await api.getAllUpdates();
respond(updates);
break;
}
case "webxdc.sendToChat": {
await api.sendToChat(params.message);
respond(null);
break;
}
case "webxdc.importFiles": {
const files = await api.importFiles(params.filter ?? {});
// File objects can't be serialised — convert to transferable form.
const result = await Promise.all(
files.map(async (f) => ({
name: f.name,
type: f.type,
data: bufToBase64(await f.arrayBuffer()),
})),
);
respond(result);
break;
}
case "webxdc.joinRealtimeChannel": {
const rt = api.joinRealtimeChannel();
// Generate a channel id to track this listener.
const channelId = crypto.randomUUID();
rt.setListener((data: Uint8Array) => {
post({
jsonrpc: "2.0",
method: "webxdc.realtimeChannel.data",
params: { channelId, data: Array.from(data) },
});
});
// Store on ref so subsequent calls can find it.
realtimeChannels.current.set(channelId, rt);
respond({ channelId });
break;
}
case "webxdc.realtimeChannel.send": {
const ch = realtimeChannels.current.get(params.channelId);
if (ch) ch.send(new Uint8Array(params.data));
respond(null);
break;
}
case "webxdc.realtimeChannel.leave": {
const ch = realtimeChannels.current.get(params.channelId);
if (ch) {
ch.leave();
realtimeChannels.current.delete(params.channelId);
}
respond(null);
break;
}
default:
respondError(-32601, `Method not found: ${method}`);
}
} catch (err) {
respondError(-1, String(err));
}
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [origin, post]);
// Realtime channel handles, keyed by channelId.
const realtimeChannels = useRef<
Map<string, ReturnType<WebxdcAPI<unknown>["joinRealtimeChannel"]>>
>(new Map());
}), []);
// Clean up realtime channels on unmount.
useEffect(() => {
@@ -273,26 +268,177 @@ export const Webxdc = forwardRef<WebxdcHandle, WebxdcProps>(function Webxdc(
};
}, []);
// -----------------------------------------------------------------
// onReady: fetch and unzip the archive when the sandbox is ready
// -----------------------------------------------------------------
const onReady = useCallback(async () => {
try {
const bytes = await resolveXdc(xdcRef.current);
fileMapRef.current = unzipXdc(bytes);
bridgeScriptRef.current = generateWebxdcBridge(webxdcRef.current);
} catch (err) {
console.error('[Webxdc] Failed to initialise:', err);
}
}, []);
// -----------------------------------------------------------------
// File resolver: serve files from the unzipped archive
// -----------------------------------------------------------------
const resolveFile = useCallback(async (pathname: string): Promise<FileResponse | null> => {
const fileMap = fileMapRef.current;
if (!fileMap) {
// Archive not loaded yet — return a 503.
return {
status: 503,
contentType: 'text/plain',
body: new TextEncoder().encode('Archive not loaded'),
};
}
// Normalise: "/" and "/index.html" both resolve to "index.html".
const filePath =
pathname === '/' ? 'index.html' : decodeURIComponent(pathname.slice(1));
const fileBytes = fileMap.get(filePath);
if (!fileBytes) return null;
const contentType = getMimeType(filePath);
return { status: 200, contentType, body: fileBytes };
}, []);
// -----------------------------------------------------------------
// File resolver with bridge script injection
//
// The webxdc bridge is generated dynamically in onReady (it embeds
// runtime values like selfAddr), so we can't use SandboxFrame's
// static injectedScripts prop. Instead we:
// 1. Serve /webxdc.js ourselves from bridgeScriptRef
// 2. Inject <script src="/webxdc.js"> into HTML responses here
// -----------------------------------------------------------------
const resolveFileWithBridge = useCallback(async (pathname: string): Promise<FileResponse | null> => {
// Serve the virtual webxdc bridge script.
if (pathname === '/webxdc.js') {
return {
status: 200,
contentType: 'application/javascript',
body: new TextEncoder().encode(bridgeScriptRef.current),
};
}
const file = await resolveFile(pathname);
if (!file) return null;
// Inject <script src="/webxdc.js"> into HTML responses.
if (file.contentType.includes('text/html')) {
const html = new TextDecoder().decode(file.body);
const injected = injectScriptTags(html, ['/webxdc.js']);
return { ...file, body: new TextEncoder().encode(injected) };
}
return file;
}, [resolveFile]);
// -----------------------------------------------------------------
// Custom RPC handler: webxdc.* methods
// -----------------------------------------------------------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onRpc = useCallback(async (method: string, params: any, post: (msg: Record<string, unknown>) => void): Promise<unknown> => {
const api = webxdcRef.current;
switch (method) {
case 'webxdc.sendUpdate': {
api.sendUpdate(params.update, '');
return null;
}
case 'webxdc.setUpdateListener': {
const serial: number = params.serial ?? 0;
// Forward every update into the frame as a notification.
await api.setUpdateListener(
(update: ReceivedStatusUpdate<unknown>) => {
post({
jsonrpc: '2.0',
method: 'webxdc.update',
params: { update },
});
},
serial,
);
return null;
}
case 'webxdc.getAllUpdates': {
return await api.getAllUpdates();
}
case 'webxdc.sendToChat': {
await api.sendToChat(params.message);
return null;
}
case 'webxdc.importFiles': {
const files = await api.importFiles(params.filter ?? {});
// File objects can't be serialised — convert to transferable form.
return await Promise.all(
files.map(async (f) => ({
name: f.name,
type: f.type,
data: bytesToBase64(new Uint8Array(await f.arrayBuffer())),
})),
);
}
case 'webxdc.joinRealtimeChannel': {
const rt = api.joinRealtimeChannel();
const channelId = crypto.randomUUID();
rt.setListener((data: Uint8Array) => {
post({
jsonrpc: '2.0',
method: 'webxdc.realtimeChannel.data',
params: { channelId, data: Array.from(data) },
});
});
realtimeChannels.current.set(channelId, rt);
return { channelId };
}
case 'webxdc.realtimeChannel.send': {
const ch = realtimeChannels.current.get(params.channelId);
if (ch) ch.send(new Uint8Array(params.data));
return null;
}
case 'webxdc.realtimeChannel.leave': {
const ch = realtimeChannels.current.get(params.channelId);
if (ch) {
ch.leave();
realtimeChannels.current.delete(params.channelId);
}
return null;
}
default:
throw new Error(`Method not found: ${method}`);
}
}, []);
return (
<iframe
ref={iframeRef}
src={`${origin}/`}
<SandboxFrame
ref={sandboxRef}
id={id}
resolveFile={resolveFileWithBridge}
onRpc={onRpc}
csp={WEBXDC_CSP}
onReady={onReady}
{...iframeProps}
/>
);
});
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function bufToBase64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
export default Webxdc;

Some files were not shown because too many files have changed in this diff Show More