Compare commits

..

4 Commits

Author SHA1 Message Date
filemon 834038dba5 Merge branch 'main' into fix/blobbi-item-use-cooldown 2026-04-05 23:08:25 -03:00
filemon d6f89d206e Remove quantity leftovers and unify cooldown UX across all item surfaces
Quantity cleanup:
- Remove quantity field from ResolvedInventoryItem, CompanionItem,
  and BlobbiShopModal's local interface
- Remove all quantity: Infinity assignments from item resolution
- Rename filterInventoryByAction to getItemsForAction with a
  cleaner signature (drop unused _storage parameter)
- Update barrel export and call sites
- Fix stale 'inventory' comments in blobbi-action-utils.ts

Cooldown UX consistency:
- Add Clock icon to dashboard ItemsTabContent (previously only
  showed dimmed opacity with no icon)
- Normalize shop modal button variant (outline, matching action
  modal style)
- All three item surfaces now share the same visual language:
  loading = spinner, cooldown = clock icon + disabled + muted
2026-04-05 22:56:11 -03:00
filemon 0249760b74 Add reactive cooldown UI with auto-expiring visual feedback
The item-cooldown module now has a subscriber system and scheduled
timers that automatically notify React when cooldowns start and end.

New useItemCooldown hook (via useSyncExternalStore) gives components
a reactive isOnCooldown(itemId) that triggers re-renders on cooldown
state changes — no polling or manual refresh needed.

Visual behavior:
- Use button shows a clock icon during cooldown (distinct from the
  spinner shown during the loading/mutation phase)
- Button switches to outline/ghost variant while cooling down
- Items tab dims items on cooldown
- All visuals clear automatically when the timer expires
- Success cooldown: 400ms, failure cooldown: 2000ms

Applied consistently across:
- BlobbiActionInventoryModal (feed/play/clean/medicine dialogs)
- ItemsTabContent (dashboard items grid)
- BlobbiShopModal ItemsGrid (shop items tab)

The isItemOnCooldown prop was removed from BlobbiActionInventoryModal
since each component now uses the hook directly.
2026-04-05 22:37:18 -03:00
filemon 377a6cbb97 Unify item-use cooldown with shared module and simplify item flows
Introduce a centralized item-cooldown module (item-cooldown.ts) that
provides a single per-item cooldown map shared across every item-use
path: BlobbiPage dashboard, companion floating UI, shop modal, and
the falling-items system.

Previously, cooldown was only enforced in the companion layer's
fallback hook (useBlobbiItemUse) and HangingItems had its own local
fallback map. The primary dashboard mutation (useBlobbiUseInventoryItem)
had zero cooldown — only the isPending mutex prevented spam. Now all
paths check and set cooldown via the same shared singleton.

Also included in this commit:
- Remove quantity selectors and confirmation dialogs from modals
- Replace multi-quantity stat loops with single-use application
- Drop quantity parameter from UseItemFunction and all call sites
- Remove dead clearItemCooldown code from context chain
- Add isItemOnCooldown prop to BlobbiActionInventoryModal for
  per-item disabled state on Use buttons
- Remove HangingItems local cooldown fallback (delegates to parent)
- Fix HangingItems cooldown key mismatch (now uses item type ID)
2026-04-05 21:54:59 -03:00
74 changed files with 1055 additions and 4327 deletions
-112
View File
@@ -1,112 +0,0 @@
---
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
@@ -1,229 +0,0 @@
============================================================
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
============================================================
+1 -3
View File
@@ -2,6 +2,4 @@ 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=""
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
# ALLOWED_HOSTS="*"
VITE_NOSTR_PUSH_PUBKEY=""
+1 -18
View File
@@ -57,22 +57,6 @@ 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
@@ -145,9 +129,8 @@ build-apk:
- npx vite build -l error
- cp dist/index.html dist/404.html
# Sync web assets to Capacitor Android project and register local plugins
# Sync web assets to Capacitor Android project
- npx cap sync android
- node scripts/patch-cap-config.mjs
# Build signed release APK
- cd android && chmod +x gradlew && ./gradlew assembleRelease bundleRelease && cd ..
-21
View File
@@ -1,26 +1,5 @@
# Changelog
## [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.1"
versionName "2.6.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -1,10 +1,7 @@
package pub.ditto.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.content.Context;
import android.util.Log;
import com.getcapacitor.Plugin;
@@ -17,10 +14,6 @@ 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 {
@@ -31,7 +24,6 @@ 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;
@@ -68,8 +60,7 @@ public class DittoNotificationPlugin extends Plugin {
if (userPubkey != null && relayUrlsRaw != null) {
SharedPreferences.Editor editor = prefs.edit()
.putString("userPubkey", userPubkey)
.putString("relayUrls", relayUrlsRaw)
.putString("notificationStyle", notificationStyle);
.putString("relayUrls", relayUrlsRaw);
if (enabledKindsRaw != null) {
editor.putString("enabledKinds", enabledKindsRaw);
}
@@ -79,46 +70,13 @@ public class DittoNotificationPlugin extends Plugin {
editor.remove("authors");
}
editor.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., style=" + notificationStyle + ", relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., 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,9 +1,7 @@
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;
@@ -13,36 +11,32 @@ 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 native plugins before super.onCreate.
// Register the native notification config plugin before super.onCreate
registerPlugin(DittoNotificationPlugin.class);
registerPlugin(SandboxPlugin.class);
super.onCreate(savedInstanceState);
// 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;
}
// 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;
}
}
@@ -1,467 +0,0 @@
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 android.widget.FrameLayout;
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.
View capWebView = getBridge().getWebView();
ViewGroup parent = (ViewGroup) capWebView.getParent();
FrameLayout.LayoutParams params = new FrameLayout.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;
}
FrameLayout.LayoutParams params = new FrameLayout.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://" + 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;
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ const config: CapacitorConfig = {
},
ios: {
backgroundColor: '#14161f',
contentInset: 'never',
contentInset: 'automatic',
scheme: 'Ditto'
}
};
+2 -10
View File
@@ -15,8 +15,6 @@
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 */
@@ -30,8 +28,6 @@
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 */
@@ -68,8 +64,6 @@
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
@@ -162,8 +156,6 @@
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -311,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.1;
MARKETING_VERSION = 2.6.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -333,7 +325,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.1;
MARKETING_VERSION = 2.6.0;
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="DittoBridgeViewController" customModule="App" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
@@ -1,9 +0,0 @@
import UIKit
import Capacitor
class DittoBridgeViewController: CAPBridgeViewController {
override func capacitorDidLoad() {
super.capacitorDidLoad()
webView?.allowsBackForwardNavigationGestures = true
}
}
-475
View File
@@ -1,475 +0,0 @@
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"]
))
}
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.6.1",
"version": "2.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.6.1",
"version": "2.6.0",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
+1 -2
View File
@@ -1,13 +1,12 @@
{
"name": "ditto",
"private": true,
"version": "2.6.1",
"version": "2.6.0",
"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"
},
-21
View File
@@ -1,26 +1,5 @@
# Changelog
## [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
@@ -1,49 +0,0 @@
#!/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(', ')}`);
}
}
-2
View File
@@ -149,8 +149,6 @@ const hardcodedConfig: AppConfig = {
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
savedFeeds: [],
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
};
/**
@@ -1,7 +1,7 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo } from 'react';
import { Loader2, X } from 'lucide-react';
import { Loader2, X, Clock } from 'lucide-react';
import {
Dialog,
@@ -16,7 +16,7 @@ import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobb
import { cn } from '@/lib/utils';
import {
filterInventoryByAction,
getItemsForAction,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
@@ -27,6 +27,7 @@ import {
type ResolvedInventoryItem,
type EggStatPreview,
} from '../lib/blobbi-action-utils';
import { useItemCooldown } from '../hooks/useItemCooldown';
interface BlobbiActionInventoryModalProps {
open: boolean;
@@ -53,11 +54,11 @@ export function BlobbiActionInventoryModal({
usingItemId,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
const { isOnCooldown } = useItemCooldown();
// Get all available items for this action from the catalog (not inventory).
// Items are abilities/tools — no ownership required.
// Get all available items for this action from the catalog.
const availableItems = useMemo(() => {
return filterInventoryByAction([], action, { stage: companion.stage });
return getItemsForAction(action, { stage: companion.stage });
}, [action, companion.stage]);
// Check stage restrictions for this specific action
@@ -68,6 +69,7 @@ export function BlobbiActionInventoryModal({
const handleUseItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
if (isOnCooldown(item.itemId)) return;
onUseItem(item.itemId);
};
@@ -126,17 +128,22 @@ export function BlobbiActionInventoryModal({
{/* Item List */}
{canUse && !isEmpty && (
<div className="grid gap-3">
{availableItems.map((item) => (
<BlobbiInventoryUseRow
key={item.itemId}
item={item}
companion={companion}
action={action}
onUse={() => handleUseItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
))}
{availableItems.map((item) => {
const isCoolingDown = isOnCooldown(item.itemId);
const isThisUsing = isUsingItem && usingItemId === item.itemId;
return (
<BlobbiItemUseRow
key={item.itemId}
item={item}
companion={companion}
action={action}
onUse={() => handleUseItem(item)}
isUsing={isThisUsing}
disabled={isUsingItem || isCoolingDown}
isCoolingDown={isCoolingDown}
/>
);
})}
</div>
)}
</div>
@@ -145,30 +152,32 @@ export function BlobbiActionInventoryModal({
);
}
// ─── Inventory Use Row ────────────────────────────────────────────────────────
// ─── Item Use Row ─────────────────────────────────────────────────────────────
interface BlobbiInventoryUseRowProps {
interface BlobbiItemUseRowProps {
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
onUse: () => void;
isUsing: boolean;
disabled: boolean;
isCoolingDown: boolean;
}
function BlobbiInventoryUseRow({
function BlobbiItemUseRow({
item,
companion,
action,
onUse,
isUsing,
disabled,
}: BlobbiInventoryUseRowProps) {
isCoolingDown,
}: BlobbiItemUseRowProps) {
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes - handle egg-specific preview for medicine and clean
// Preview stat changes — single-use effect preview
const { normalStatChanges, eggStatChanges } = useMemo(() => {
if (isEgg && isMedicine) {
return {
@@ -217,38 +226,18 @@ function BlobbiInventoryUseRow({
<div className="flex flex-wrap gap-x-3 gap-y-1">
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
{delta > 0 ? '+' : ''}{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
</span>
))}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
{delta > 0 ? '+' : ''}{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
</span>
))}
</div>
@@ -261,10 +250,13 @@ function BlobbiInventoryUseRow({
size="sm"
onClick={onUse}
disabled={disabled}
className="shrink-0"
variant={isCoolingDown ? 'outline' : 'default'}
className="shrink-0 min-w-14"
>
{isUsing ? (
<Loader2 className="size-4 animate-spin" />
) : isCoolingDown ? (
<Clock className="size-3.5 text-muted-foreground" />
) : (
'Use'
)}
@@ -276,38 +268,18 @@ function BlobbiInventoryUseRow({
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
{delta > 0 ? '+' : ''}{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
</span>
))}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
{delta > 0 ? '+' : ''}{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
</span>
))}
</div>
@@ -29,11 +29,12 @@ import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { isItemOnCooldown, setItemCooldown } from '../lib/item-cooldown';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
/**
* Request payload for using an item on a Blobbi companion
* Request payload for using an item on a Blobbi companion.
*/
export interface UseItemRequest {
itemId: string;
@@ -41,7 +42,7 @@ export interface UseItemRequest {
}
/**
* Result of using an item on a Blobbi companion
* Result of using an item on a Blobbi companion.
*/
export interface UseItemResult {
itemName: string;
@@ -79,15 +80,19 @@ import type { NostrEvent } from '@nostrify/nostrify';
/**
* 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.
*
* ownership or quantity is required. Each use applies effects once.
*
* Cooldown is enforced via the shared item-cooldown module so that
* rapid repeated clicks are blocked consistently across all UIs.
*
* This hook:
* 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)
* 2. Checks the shared per-item cooldown
* 3. Ensures canonical format before action
* 4. Applies accumulated decay, then item effects to Blobbi stats
* 5. Updates Blobbi state (kind 31124)
*/
export function useBlobbiUseInventoryItem({
companion,
@@ -101,6 +106,11 @@ export function useBlobbiUseInventoryItem({
return useMutation({
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
// ─── Cooldown guard (shared across all UIs) ───
if (isItemOnCooldown(itemId)) {
throw new Error('Please wait before using this item again');
}
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
@@ -147,9 +157,6 @@ export function useBlobbiUseInventoryItem({
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any user interaction updates stats.
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
@@ -163,7 +170,6 @@ export function useBlobbiUseInventoryItem({
const statsAfterDecay = decayResult.stats;
// ─── Validate Play Energy Requirements ───
// For play actions, validate the Blobbi has enough energy AFTER decay
if (action === 'play') {
const energyCost = Math.abs(shopItem.effect.energy ?? 0);
const currentEnergy = statsAfterDecay.energy;
@@ -174,8 +180,6 @@ export function useBlobbiUseInventoryItem({
);
}
// Also check if playing would have any effect at all
// If happiness is maxed AND we can't spend energy, playing is pointless
const happinessGain = shopItem.effect.happiness ?? 0;
const currentHappiness = statsAfterDecay.happiness;
const wouldGainHappiness = happinessGain > 0 && currentHappiness < 100;
@@ -194,8 +198,7 @@ export function useBlobbiUseInventoryItem({
const statsChanged: Record<string, number> = {};
if (isEggCompanion && action === 'medicine') {
const healthDelta = shopItem.effect.health ?? 0;
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
statsUpdate.health = currentHealth.toString();
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
@@ -243,7 +246,6 @@ export function useBlobbiUseInventoryItem({
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
@@ -252,7 +254,6 @@ export function useBlobbiUseInventoryItem({
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain ───
@@ -276,11 +277,6 @@ export function useBlobbiUseInventoryItem({
updateCompanionEvent(blobbiEvent);
// 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,
@@ -289,7 +285,7 @@ export function useBlobbiUseInventoryItem({
newXP,
};
},
onSuccess: ({ itemName, action, xpGained }) => {
onSuccess: ({ itemName, action, xpGained }, { itemId }) => {
const actionMeta = ACTION_METADATA[action];
const xpText = formatXPGain(xpGained);
toast({
@@ -297,19 +293,24 @@ export function useBlobbiUseInventoryItem({
description: `Used ${itemName} on your Blobbi. ${xpText}`,
});
// Set shared cooldown (success — short)
setItemCooldown(itemId, true);
// Track daily mission progress
// 'interact' is always tracked, plus the specific action if it maps to a daily mission
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'feed') dailyActions.push('feed');
if (action === 'clean') dailyActions.push('clean');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
onError: (error: Error, { itemId }) => {
toast({
title: 'Failed to use item',
description: error.message,
variant: 'destructive',
});
// Set shared cooldown (failure — longer)
setItemCooldown(itemId, false);
},
});
}
@@ -0,0 +1,46 @@
/**
* useItemCooldown React hook for per-item cooldown state.
*
* Subscribes to the shared item-cooldown module so that components
* automatically re-render when any item's cooldown starts or expires.
*
* Usage:
* ```tsx
* const { isOnCooldown } = useItemCooldown();
* <Button disabled={isOnCooldown(item.id)}>Use</Button>
* ```
*/
import { useCallback, useSyncExternalStore } from 'react';
import { isItemOnCooldown, subscribeCooldowns } from '../lib/item-cooldown';
/** Monotonically increasing snapshot counter bumped on every cooldown change. */
let snapshotVersion = 0;
/** Called by subscribeCooldowns — bumps the version so useSyncExternalStore re-renders. */
function bumpVersion(): void {
snapshotVersion++;
}
// Wire the bump into the cooldown module (idempotent — Set prevents duplicates)
subscribeCooldowns(bumpVersion);
function getSnapshot(): number {
return snapshotVersion;
}
/**
* Hook that re-renders the calling component whenever any item's cooldown
* starts or ends. Returns a stable `isOnCooldown` checker.
*/
export function useItemCooldown() {
// Subscribe to cooldown changes — triggers re-render via snapshot bump
useSyncExternalStore(subscribeCooldowns, getSnapshot);
const isOnCooldown = useCallback((itemId: string): boolean => {
return isItemOnCooldown(itemId);
}, []);
return { isOnCooldown };
}
+1 -1
View File
@@ -136,7 +136,7 @@ export {
clampStat,
applyStat,
applyItemEffects,
filterInventoryByAction,
getItemsForAction,
decrementStorageItem,
canUseAction,
canUseDirectAction,
+8 -11
View File
@@ -12,8 +12,8 @@ import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Direct actions that don't use items.
* These actions affect stats directly without selecting a shop item.
* Direct actions that don't use items
* These actions affect stats directly without using shop items.
*/
export type DirectAction = 'play_music' | 'sing';
@@ -273,11 +273,10 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
// ─── Item Helpers ─────────────────────────────────────────────────────────────
/**
* Resolved catalog item with shop metadata
* Resolved catalog item with shop metadata.
*/
export interface ResolvedInventoryItem {
itemId: string;
quantity: number;
name: string;
icon: string;
type: ShopItemCategory;
@@ -285,7 +284,7 @@ export interface ResolvedInventoryItem {
}
/**
* Options for filtering catalog items by action
* Options for filtering catalog items by action.
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
@@ -294,7 +293,7 @@ export interface FilterInventoryOptions {
/**
* Get all available items for an action type from the shop catalog.
* Items are abilities/tools no inventory ownership is required.
* Items are reusable abilities no ownership is required.
*
* Filtering rules:
* - Only items matching the action's item type are included
@@ -303,8 +302,7 @@ export interface FilterInventoryOptions {
* - medicine action: only items with health effect
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
_storage: StorageItem[],
export function getItemsForAction(
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
@@ -324,16 +322,15 @@ export function filterInventoryByAction(
// For eggs, filter items by egg-compatible effects
if (isEgg) {
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
continue; // Skip medicine without health effect
continue;
}
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
continue; // Skip hygiene items without hygiene or happiness effect
continue;
}
}
result.push({
itemId: shopItem.id,
quantity: Infinity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
+93
View File
@@ -0,0 +1,93 @@
/**
* Centralized item-use cooldown tracking.
*
* Provides a single, shared per-item cooldown map used by every item-use
* path (BlobbiPage dashboard, companion layer, shop modal, falling items).
*
* Design:
* - Module-level singleton all hooks share the same map.
* - Keyed by item type ID (e.g. "food_apple"), NOT instance IDs.
* - Separate durations for success (short) and failure (longer).
* - Built-in subscriber system so React components can re-render when
* cooldowns start or expire.
*/
// ─── Configuration ────────────────────────────────────────────────────────────
/** Cooldown after a successful item use (ms). */
export const ITEM_COOLDOWN_SUCCESS_MS = 400;
/** Cooldown after a failed item use (ms). */
export const ITEM_COOLDOWN_FAILURE_MS = 2000;
// ─── Singleton State ──────────────────────────────────────────────────────────
interface CooldownEntry {
/** Timestamp (Date.now()) when the cooldown expires */
expiresAt: number;
/** Timeout handle that fires the expiry notification */
timerId: ReturnType<typeof setTimeout>;
}
/** Module-level cooldown map shared across all hooks. */
const cooldowns = new Map<string, CooldownEntry>();
/** Subscribers notified on every cooldown start/end. */
const subscribers = new Set<() => void>();
// ─── Internal Helpers ─────────────────────────────────────────────────────────
function notify(): void {
subscribers.forEach((cb) => cb());
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Check whether an item is currently on cooldown.
*/
export function isItemOnCooldown(itemId: string): boolean {
const entry = cooldowns.get(itemId);
if (!entry) return false;
if (Date.now() >= entry.expiresAt) {
clearTimeout(entry.timerId);
cooldowns.delete(itemId);
return false;
}
return true;
}
/**
* Put an item on cooldown after a use attempt.
* Subscribers are notified immediately (cooldown started) and again when
* the cooldown expires (so the UI can re-enable the button).
*/
export function setItemCooldown(itemId: string, success: boolean): void {
// Clear any existing cooldown for this item
const prev = cooldowns.get(itemId);
if (prev) clearTimeout(prev.timerId);
const ms = success ? ITEM_COOLDOWN_SUCCESS_MS : ITEM_COOLDOWN_FAILURE_MS;
const timerId = setTimeout(() => {
cooldowns.delete(itemId);
notify(); // re-render: cooldown ended
}, ms);
cooldowns.set(itemId, { expiresAt: Date.now() + ms, timerId });
notify(); // re-render: cooldown started
}
/**
* Subscribe to cooldown state changes.
* Returns an unsubscribe function.
*/
export function subscribeCooldowns(callback: () => void): () => void {
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
}
@@ -4,9 +4,11 @@
* Fetches the current companion data from the user's Blobbonaut profile.
* This is the data layer - it handles fetching and provides companion data.
*
* 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.
* 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
*/
import { useMemo } from 'react';
@@ -30,14 +32,16 @@ interface UseBlobbiCompanionDataResult {
*
* Flow:
* 1. Use useBlobbonautProfile to get the profile (shared query, reactive)
* 2. Build a dList containing just the currentCompanion (targeted fetch)
* 3. Use useBlobbisCollection with the dList to get the companion
* 2. Build a dList containing just the currentCompanion
* 3. Use useBlobbisCollection (shared with BlobbiPage) to get the companion
* 4. Apply projected decay for accurate UI reactions
* 5. Return the companion data with projected stats
*
* Reactivity:
* - Optimistic updates propagate across all blobbi-collection queries
* - Projected decay recalculates every 60 seconds while mounted
* - 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
*/
export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
// Use the shared profile hook - this ensures reactivity when profile changes
@@ -40,7 +40,8 @@ export {
* 1. Registered function from BlobbiPage (if available) - better cache access
* 2. Built-in useBlobbiItemUse hook as fallback - works anywhere
*
* Uses subscription pattern to only re-render when necessary.
* Cooldown is enforced via the shared item-cooldown module, which is
* consistent across both the registered and fallback paths.
*/
export function useBlobbiActions(): BlobbiActionsContextValue {
const context = useContext(BlobbiActionsContext);
@@ -103,8 +104,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
isUsingItem,
canUseItems,
isItemOnCooldown: fallbackItemUse.isItemOnCooldown,
clearItemCooldown: fallbackItemUse.clearItemCooldown,
}), [useItem, isUsingItem, canUseItems, fallbackItemUse.isItemOnCooldown, fallbackItemUse.clearItemCooldown]);
}), [useItem, isUsingItem, canUseItems, fallbackItemUse.isItemOnCooldown]);
}
// ─── Registration Hook ────────────────────────────────────────────────────────
@@ -187,5 +187,3 @@ export function useBlobbiActionsRegistration(
};
}, [context]);
}
@@ -47,11 +47,8 @@ export interface BlobbiActionsContextValue {
/** Whether items can be used (companion exists and profile loaded) */
canUseItems: boolean;
/** Check if an item is on cooldown (recently attempted) */
/** Check if a specific item is on cooldown */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item */
clearItemCooldown: (itemId: string) => void;
}
/**
@@ -437,32 +437,15 @@ export function HangingItems({
// Contact auto-use only triggers when item ENTERS the zone (transitions from outside to inside)
const itemsInZoneRef = useRef<Set<string>>(new Set());
// Local item cooldown tracking (fallback if isItemOnCooldown not provided)
const localCooldownsRef = useRef<Map<string, number>>(new Map());
// Check if an item is on cooldown (uses prop if available, else local)
const checkItemCooldown = useCallback((itemId: string): boolean => {
// Check if an item is on cooldown.
// Uses the parent-provided isItemOnCooldown (shared module) with the item TYPE id.
const checkItemCooldown = useCallback((item: CompanionItem): boolean => {
if (isItemOnCooldown) {
return isItemOnCooldown(itemId);
return isItemOnCooldown(item.id);
}
// Local fallback cooldown check
const expiresAt = localCooldownsRef.current.get(itemId);
if (!expiresAt) return false;
if (Date.now() >= expiresAt) {
localCooldownsRef.current.delete(itemId);
return false;
}
return true;
return false;
}, [isItemOnCooldown]);
// Set local cooldown for an item
const setLocalCooldown = useCallback((itemId: string, success: boolean) => {
const cooldownMs = success
? HANGING_CONFIG.successUseCooldown
: HANGING_CONFIG.failedUseCooldown;
localCooldownsRef.current.set(itemId, Date.now() + cooldownMs);
}, []);
// Ref for onItemLanded callback
const onItemLandedRef = useRef(onItemLanded);
onItemLandedRef.current = onItemLanded;
@@ -624,8 +607,8 @@ export function HangingItems({
* @param source - How the item was used
*/
const attemptUseItem = useCallback(async (instanceId: string, item: CompanionItem, source: 'contact' | 'click' | 'drag-drop') => {
// Check cooldown first (prevents retry spam) - use instanceId for cooldown tracking
if (checkItemCooldown(instanceId)) {
// Check shared cooldown by item type ID (prevents retry spam)
if (checkItemCooldown(item)) {
if (import.meta.env.DEV) {
console.log(`[HangingItems] Item on cooldown, skipping:`, item.name, instanceId);
}
@@ -644,8 +627,6 @@ export function HangingItems({
itemsBeingUsedRef.current = new Set(itemsBeingUsedRef.current).add(instanceId);
forceUpdate(c => c + 1); // Trigger re-render for visual feedback
let success = false;
try {
// If onItemUse is provided, use the async flow
const onItemUseFn = onItemUseRef.current;
@@ -656,7 +637,6 @@ export function HangingItems({
const result = await onItemUseFn(item);
if (result.success) {
success = true;
if (import.meta.env.DEV) {
console.log(`[HangingItems] Item used successfully:`, item.name, instanceId);
}
@@ -685,7 +665,6 @@ export function HangingItems({
}
} else {
// Legacy behavior: call onItemCollected and remove immediately
success = true;
if (import.meta.env.DEV) {
console.log(`[HangingItems] Item collected (legacy):`, item.name, instanceId);
}
@@ -712,11 +691,9 @@ export function HangingItems({
newSet.delete(instanceId);
itemsBeingUsedRef.current = newSet;
forceUpdate(c => c + 1);
// Set cooldown (longer on failure to prevent retry spam)
setLocalCooldown(instanceId, success);
// Cooldown is set by the mutation hooks (onSuccess/onError) via the shared module
}
}, [checkItemCooldown, setLocalCooldown]); // Minimal dependencies - rest uses refs
}, [checkItemCooldown]); // Minimal dependencies - rest uses refs
// Contact detection with Blobbi (for auto-use)
//
+1 -3
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 shop catalog items optimized for rendering.
* A simplified view of shop catalog items optimized for rendering.
*/
export interface CompanionItem {
/** Unique item ID (matches shop item ID) */
@@ -74,8 +74,6 @@ export interface CompanionItem {
emoji: string;
/** Item category */
category: ShopItemCategory;
/** Availability (always Infinity — items are reusable abilities) */
quantity: number;
/** Item effects when used */
effect?: ItemEffect;
}
@@ -8,16 +8,16 @@
* - Fetches companion and profile data if not provided
* - Uses the same item-use logic as BlobbiPage (useBlobbiUseInventoryItem)
* - Works as a standalone hook or can be passed cached data
* - Provides retry protection and cooldown
* - Uses the shared item-cooldown module for per-item cooldown
*
* Architecture:
* - BlobbiCompanionLayer uses this hook directly as a fallback when
* BlobbiPage is not mounted
* - BlobbiPage registers its own item-use function (which has better cache access)
* - Both use the same underlying mutation logic
* - Both use the same underlying mutation logic and shared cooldown
*/
import { useCallback, useRef, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
@@ -54,17 +54,13 @@ import type { DailyMissionAction } from '@/blobbi/actions/lib/daily-missions';
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 {
isItemOnCooldown,
setItemCooldown,
} from '@/blobbi/actions/lib/item-cooldown';
import type { UseItemFunction } from './BlobbiActionsContextDef';
// ─── Configuration ────────────────────────────────────────────────────────────
/** Cooldown time after a failed item use attempt (ms) */
const ITEM_USE_COOLDOWN_MS = 3000;
/** Cooldown time after a successful item use (ms) - shorter to allow quick successive uses */
const ITEM_USE_SUCCESS_COOLDOWN_MS = 500;
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UseBlobbiItemUseOptions {
@@ -80,23 +76,14 @@ export interface UseBlobbiItemUseOptions {
}
export interface UseBlobbiItemUseResult {
/** The item use function - same signature as UseItemFunction */
/** The item use function same signature as UseItemFunction */
useItem: UseItemFunction;
/** Whether item use is available (companion and profile loaded) */
canUseItems: boolean;
/** Whether an item use is currently in progress */
isUsingItem: boolean;
/** Check if an item is on cooldown (recently attempted) */
/** Check if an item is on cooldown (delegates to shared module) */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item (e.g., after it's removed) */
clearItemCooldown: (itemId: string) => void;
}
interface ItemCooldownEntry {
/** Timestamp when the cooldown expires */
expiresAt: number;
/** Whether the last attempt succeeded */
wasSuccess: boolean;
}
// ─── Hook Implementation ──────────────────────────────────────────────────────
@@ -104,16 +91,8 @@ interface ItemCooldownEntry {
/**
* Shared Blobbi item-use hook that works anywhere.
*
* This is the "real" item-use logic extracted to be usable from:
* - BlobbiCompanionLayer (floating companion)
* - BlobbiPage (main dashboard)
* - Any other location
*
* Features:
* - Fetches companion/profile data if not provided
* - Identical item-use logic to useBlobbiUseInventoryItem
* - Built-in per-item cooldown/retry protection
* - Works as a direct hook or registered in context
* Uses the centralized item-cooldown module so that cooldown is
* consistent regardless of which UI path triggers the use.
*/
export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlobbiItemUseResult {
const { nostr } = useNostr();
@@ -125,42 +104,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
const { profile: fetchedProfile } = useBlobbonautProfile();
const profile = options.profile ?? fetchedProfile;
// Per-item cooldown tracking (ref to avoid re-renders)
const itemCooldowns = useRef<Map<string, ItemCooldownEntry>>(new Map());
// Check if an item is on cooldown
const isItemOnCooldown = useCallback((itemId: string): boolean => {
const entry = itemCooldowns.current.get(itemId);
if (!entry) return false;
const now = Date.now();
if (now >= entry.expiresAt) {
// Cooldown expired, remove it
itemCooldowns.current.delete(itemId);
return false;
}
return true;
}, []);
// Clear cooldown for an item
const clearItemCooldown = useCallback((itemId: string): void => {
itemCooldowns.current.delete(itemId);
}, []);
// Set cooldown for an item
const setItemCooldown = useCallback((itemId: string, success: boolean): void => {
const cooldownMs = success ? ITEM_USE_SUCCESS_COOLDOWN_MS : ITEM_USE_COOLDOWN_MS;
itemCooldowns.current.set(itemId, {
expiresAt: Date.now() + cooldownMs,
wasSuccess: success,
});
}, []);
// Fetch current companion based on profile's currentCompanion
// This is fetched on-demand when needed, not kept in state
const fetchCurrentCompanion = useCallback(async (): Promise<BlobbiCompanion | null> => {
// If companion was provided via options, use that
if (options.companion !== undefined) {
return options.companion ?? null;
}
@@ -184,51 +129,29 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
return parseBlobbiEvent(validEvents[0]) ?? null;
}, [nostr, user?.pubkey, profile?.currentCompanion, options.companion]);
// Update companion in query cache - optimistic update for immediate UI refresh
// Update companion in query cache
const updateCompanionInCache = useCallback((event: NostrEvent) => {
if (!user?.pubkey || !profile?.currentCompanion) return;
// Parse the new event to get the updated companion
const parsed = parseBlobbiEvent(event);
if (!parsed) {
// Fallback to invalidation if parsing fails
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey]
});
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
return;
}
// Optimistically update the blobbi-collection cache
// This ensures the companion layer sees the update immediately
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] } | undefined>(
// Use partial key match - React Query will find any matching query
['blobbi-collection', user.pubkey],
(prev) => {
if (!prev) return prev;
// Update the specific companion in the record
const newCompanionsByD = {
...prev.companionsByD,
[parsed.d]: parsed,
};
// Rebuild companions array from the record
const newCompanions = Object.values(newCompanionsByD);
return {
companionsByD: newCompanionsByD,
companions: newCompanions,
};
const newCompanionsByD = { ...prev.companionsByD, [parsed.d]: parsed };
return { companionsByD: newCompanionsByD, companions: Object.values(newCompanionsByD) };
},
);
// Also invalidate to trigger background refetch (ensures consistency)
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey]
});
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
}, [queryClient, user?.pubkey, profile?.currentCompanion]);
// Core mutation for using items (always uses once)
// Core mutation for using items (always single-use)
const mutation = useMutation({
mutationFn: async ({
itemId,
@@ -237,6 +160,11 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
itemId: string;
action: InventoryAction;
}): Promise<{ statsChanged: Record<string, number> }> => {
// ─── Cooldown guard (shared across all UIs) ───
if (isItemOnCooldown(itemId)) {
throw new Error('Please wait before using this item again');
}
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
@@ -246,38 +174,30 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
throw new Error('Profile not found');
}
// Fetch fresh companion data
const companion = await fetchCurrentCompanion();
if (!companion) {
throw new Error('No companion selected');
}
// Check stage restrictions
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
throw new Error(message ?? 'This companion cannot use this item');
}
// Validate item exists in shop catalog
const shopItem = getShopItemById(itemId);
if (!shopItem) {
throw new Error('Item not found in catalog');
}
// Validate item can be used by this companion's stage
// This catches egg-only items (like Shell Repair Kit) being used by baby/adult companions
const itemUsability = canUseItemForStage(itemId, companion.stage);
if (!itemUsability.canUse) {
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
}
// For eggs, validate that items have applicable effects
const isEgg = companion.stage === 'egg';
if (isEgg && action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
throw new Error('This medicine has no effect on eggs');
@@ -295,8 +215,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
lastDecayAt: companion.lastDecayAt,
now,
});
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Apply Item Effects (single use) ───
@@ -306,10 +224,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
if (isEggCompanion && action === 'medicine') {
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
statsUpdate.health = currentHealth.toString();
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
statsUpdate.hunger = '100';
@@ -317,43 +233,30 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
} else if (isEggCompanion && action === 'clean') {
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);
statsUpdate.happiness = currentHappiness.toString();
const totalHappinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
if (totalHappinessChange !== 0) {
statsChanged.happiness = totalHappinessChange;
}
const happinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
if (happinessChange !== 0) statsChanged.happiness = happinessChange;
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// 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);
statsUpdate.happiness = clampStat(currentStats.happiness).toString();
statsChanged.happiness = (currentStats.happiness ?? 0) - (statsAfterDecay.happiness ?? 0);
statsUpdate.energy = clampStat(currentStats.energy).toString();
statsChanged.energy = (currentStats.energy ?? 0) - (statsAfterDecay.energy ?? 0);
statsUpdate.hygiene = clampStat(currentStats.hygiene).toString();
statsChanged.hygiene = (currentStats.hygiene ?? 0) - (statsAfterDecay.hygiene ?? 0);
statsUpdate.health = clampStat(currentStats.health).toString();
statsChanged.health = (currentStats.health ?? 0) - (statsAfterDecay.health ?? 0);
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// Handle interaction counter for tasks
const companionState = companion.state;
let updatedTags = companion.allTags;
if (companionState === 'incubating') {
@@ -362,7 +265,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
updatedTags = incrementInteractionTaskTags(companion.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(companion) ?? {};
const blobbiTags = updateBlobbiTags(updatedTags, {
@@ -379,9 +281,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
updateCompanionInCache(blobbiEvent);
// ─── Invalidate Queries ───
// Items are free to use — no storage decrement needed.
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
return { statsChanged };
@@ -395,14 +294,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
description: `Used ${shopItem?.name ?? 'item'} on your Blobbi.`,
});
// Set shared cooldown (success — short)
setItemCooldown(itemId, true);
// Track daily mission progress
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'feed') dailyActions.push('feed');
if (action === 'clean') dailyActions.push('clean');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
// Set success cooldown (short)
setItemCooldown(itemId, true);
},
onError: (error: Error, { itemId }) => {
toast({
@@ -411,14 +310,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
variant: 'destructive',
});
// Set failure cooldown (longer)
// Set shared cooldown (failure — longer)
setItemCooldown(itemId, false);
},
});
// Wrapper function that matches UseItemFunction signature and includes cooldown check
// Wrapper function that matches UseItemFunction signature
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Check cooldown first
// Check shared cooldown first
if (isItemOnCooldown(itemId)) {
if (import.meta.env.DEV) {
console.log('[useBlobbiItemUse] Item on cooldown, skipping:', itemId);
@@ -441,7 +340,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}, [mutation, isItemOnCooldown]);
}, [mutation]);
// Determine if items can be used
const canUseItems = useMemo(() => {
@@ -453,6 +352,5 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
canUseItems,
isUsingItem: mutation.isPending,
isItemOnCooldown,
clearItemCooldown,
};
}
@@ -112,7 +112,6 @@ function resolveItemsForAction(
name: shopItem.name,
emoji: shopItem.icon,
category: shopItem.type,
quantity: Infinity,
effect: shopItem.effect,
});
}
+36 -65
View File
@@ -6,7 +6,6 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
KIND_BLOBBI_STATE,
BLOBBI_ECOSYSTEM_NAMESPACE,
isValidBlobbiEvent,
parseBlobbiEvent,
type BlobbiCompanion,
@@ -27,90 +26,62 @@ function chunkArray<T>(array: T[], size: number): T[][] {
}
/**
* 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).
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
*
* 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();
// 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)
// Create a stable query key based on sorted d-tags
const sortedDList = useMemo(() => {
if (mode === 'all' || !dList || dList.length === 0) return null;
if (!dList || dList.length === 0) return null;
return [...dList].sort();
}, [mode, dList]);
}, [dList]);
// Query key segment: 'all' for fetch-all mode, comma-joined d-tags for dlist mode
const queryKeySegment = mode === 'all' ? 'all' : (sortedDList?.join(',') ?? '');
const queryKeyDTags = sortedDList?.join(',') ?? '';
// Main query to fetch companions from relays
// Main query to fetch all companions from relays
const query = useQuery({
queryKey: ['blobbi-collection', user?.pubkey, queryKeySegment],
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
queryFn: async ({ signal }) => {
if (!user?.pubkey) {
console.log('[useBlobbisCollection] No pubkey, returning empty');
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
return { companionsByD: {}, companions: [] };
}
let allEvents: NostrEvent[];
// Log the dList we're about to query
console.log('[Blobbi] dList:', sortedDList);
if (mode === 'all') {
// Fetch ALL the user's blobbi events — author is the source of truth
// 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) {
const filter = {
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#b': [BLOBBI_ECOSYSTEM_NAMESPACE],
'#d': chunk,
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
};
console.log('[Blobbi] 31124 query filter (all):', JSON.stringify(filter, null, 2));
// Log the filter immediately before query
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
allEvents = await nostr.query([filter], { signal });
const events = await nostr.query([filter], { signal });
allEvents.push(...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] Chunk returned', events.length, 'events');
}
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
@@ -152,7 +123,7 @@ export function useBlobbisCollection(dList?: string[] | undefined) {
return { companionsByD, companions };
},
enabled: !!user?.pubkey && (mode === 'all' || (!!sortedDList && sortedDList.length > 0)),
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
staleTime: 30_000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
@@ -166,17 +137,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) {
if (user?.pubkey && queryKeyDTags) {
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey, queryKeySegment],
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
});
}
}, [queryClient, user?.pubkey, queryKeySegment]);
}, [queryClient, user?.pubkey, queryKeyDTags]);
// 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 queryKeySegment. This ensures the BlobbiPage cache
// and companion layer cache stay in sync (they use different query modes).
// one matching the current queryKeyDTags. This ensures the BlobbiPage cache
// and companion layer cache stay in sync (they use different d-tag lists).
const updateCompanionEvent = useCallback((event: NostrEvent) => {
const parsed = parseBlobbiEvent(event);
if (!parsed || !user?.pubkey) return;
@@ -198,14 +169,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, queryKeySegment],
['blobbi-collection', user.pubkey, queryKeyDTags],
{
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
},
);
}
}, [queryClient, user?.pubkey, queryKeySegment]);
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Memoize return values for stability
const companionsByD = query.data?.companionsByD ?? {};
@@ -215,7 +215,7 @@ export function BlobbiInventoryModal({
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
<Package className="size-4 sm:size-5 text-primary" />
</div>
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
<DialogTitle className="text-xl sm:text-2xl">Items</DialogTitle>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
import { ShoppingBag, Package, Loader2, X } from 'lucide-react';
import { ShoppingBag, Package, Loader2, X, Clock } from 'lucide-react';
import {
Dialog,
@@ -19,6 +19,7 @@ import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobb
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { useItemCooldown } from '@/blobbi/actions/hooks/useItemCooldown';
import { cn, formatCompactNumber } from '@/lib/utils';
type TopTab = 'items' | 'shop';
@@ -271,6 +272,8 @@ interface ItemsGridProps {
}
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _onGoToShop }: ItemsGridProps) {
const { isOnCooldown } = useItemCooldown();
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
@@ -289,6 +292,8 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _on
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{items.map(item => {
const isThisUsing = isUsingItem && usingItemId === item.itemId;
const isCoolingDown = isOnCooldown(item.itemId);
const isDisabled = isUsingItem || isCoolingDown;
return (
<div
@@ -312,10 +317,12 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _on
variant="outline"
className="w-full h-7 text-xs"
onClick={() => onUseItem(item)}
disabled={isUsingItem}
disabled={isDisabled}
>
{isThisUsing ? (
<Loader2 className="size-3 animate-spin" />
) : isCoolingDown ? (
<Clock className="size-3 text-muted-foreground" />
) : (
'Use'
)}
+38 -31
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 { DITTO_RELAYS } from '@/lib/appRelays';
import { useInfiniteHotFeed } from '@/hooks/useTrending';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useInterests } from '@/hooks/useInterests';
@@ -22,15 +22,13 @@ 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';
@@ -38,6 +36,23 @@ 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[];
@@ -59,7 +74,6 @@ 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 = (() => {
@@ -136,17 +150,21 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
(kinds || tagFilters) ? { kinds, tagFilters } : undefined,
);
// Curated Ditto feed: latest content from the curator's follow list.
const topQuery = useCuratedDittoFeed(
curatorFollowList,
// "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,
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 ? ['ditto-curated-feed'] : ['feed', activeTab],
() => useDittoQuery ? ['infinite-hot-feed', LANDING_KINDS.join(',')] : ['feed', activeTab],
[useDittoQuery, activeTab],
);
@@ -186,25 +204,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const seen = new Set<string>();
if (useDittoQuery) {
// 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 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 }));
}
return (rawData.pages as unknown as { items: FeedItem[] }[])
@@ -219,9 +228,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
});
}, [rawData?.pages, muteItems, useDittoQuery]);
// 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);
const showSkeleton = isPending || (isLoading && !rawData);
// 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.
+34 -23
View File
@@ -21,17 +21,26 @@ 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 ─────────────────────────────────────────────────────────────────
export function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
const { muteItems } = useMuteList();
const { posts, isLoading } = useStreamPosts('', {
@@ -92,7 +101,7 @@ export function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
// ─── Members Tab ──────────────────────────────────────────────────────────────
export function PackMembersTab({
function PackMembersTab({
pubkeys,
membersMap,
membersLoading,
@@ -177,32 +186,34 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
setIsFollowingAll(true);
try {
// 1. Fetch freshest kind 3 from relays (not cache)
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
const signal = AbortSignal.timeout(10_000);
// 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 followEvents = await nostr.query(
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
{ signal },
);
// 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;
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));
// 4. Publish with prev for published_at preservation
await publishEvent({
kind: 3,
content: prev?.content ?? '',
tags: [...nonPTags, ...existingPTags, ...newPTags],
prev: prev ?? undefined,
content: latestEvent?.content ?? '',
tags: allFollows.map((pk) => ['p', pk]),
});
toast({
title: 'Following all!',
description: added > 0
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
description: added.length > 0
? `Added ${added.length} new account${added.length !== 1 ? 's' : ''} to your follow list.`
: 'You were already following everyone in this pack.',
});
} catch (error) {
@@ -346,7 +357,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
}
/** Individual member card in the follow pack. */
export function MemberCard({
function MemberCard({
pubkey,
metadata,
isFollowed,
@@ -426,7 +437,7 @@ export function MemberCard({
);
}
export function MemberCardSkeleton() {
function MemberCardSkeleton() {
return (
<div className="flex items-center gap-3 px-4 py-3">
<Skeleton className="size-11 rounded-full shrink-0" />
+20 -16
View File
@@ -15,7 +15,6 @@ 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,
@@ -943,27 +942,32 @@ function FollowsStep({
.filter(([n]) => n === "p")
.map(([, pk]) => pk);
// 1. Fetch freshest kind 3 from relays (not cache)
const prev = await fetchFreshEvent(nostr, {
kinds: [3],
authors: [user.pubkey],
});
// Fetch current follow list
const followEvents: NostrEvent[] = await nostr
.query([{ kinds: [3], authors: [user.pubkey], limit: 1 }], {
signal: AbortSignal.timeout(10_000),
})
.catch((): NostrEvent[] => []);
// 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 prev =
followEvents.length > 0
? followEvents.reduce((latest, current) =>
current.created_at > latest.created_at ? current : latest,
)
: null;
// 3. Merge: add new pubkeys that aren't already followed
const newPTags = packPubkeys
.filter((pk) => !existingPubkeys.has(pk))
.map((pk) => ["p", pk]);
const existingFollows = prev
? prev.tags
.filter(([name]) => name === "p")
.map(([, pk]) => pk)
: [];
const allFollows = [...new Set([...existingFollows, ...packPubkeys])];
// 4. Publish with prev for published_at preservation
await publishEvent({
kind: 3,
content: prev?.content ?? "",
tags: [...nonPTags, ...existingPTags, ...newPTags],
tags: allFollows.map((pk) => ["p", pk]),
prev: prev ?? undefined,
});
+2 -2
View File
@@ -137,8 +137,8 @@ export function MobileBottomNav() {
</div>
</div>
{/* Safe area fill — matches the arc's semi-transparent background */}
<div className="safe-area-bottom bg-background/85" />
{/* Safe area spacer — fully opaque so any subpixel gap is invisible */}
<div className="safe-area-bottom bg-background" />
</nav>
</>
);
+207 -50
View File
@@ -1,18 +1,13 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, 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 }
@@ -38,6 +33,38 @@ 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 pathsha256 manifest from a nsite event's `path` tags.
* Each path tag has the format: ["path", "/file/path", "<sha256>"]
@@ -87,6 +114,43 @@ 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;
@@ -100,25 +164,30 @@ interface NsitePreviewDialogProps {
/**
* An in-app preview panel that covers the center column and loads an nsite in
* a sandboxed iframe.
* a sandboxed iframe, using the Shakespeare iframe-fetch-client protocol over
* local-shakespeare.dev.
*
* 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.
* 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.
*
* 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();
// 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]);
// 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}/`;
// Build the manifest and server list from the event (memoised per event identity)
const manifest = useRef<Map<string, string>>(new Map());
@@ -133,40 +202,128 @@ 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]);
/** Injected scripts: just the path normalisation snippet for SPA support. */
const injectedScripts = useMemo<InjectedScript[]>(() => [{
path: '__injected__/preview.js',
content: getPreviewInjectedScript(),
}], []);
/** Send a JSON-RPC response back to the iframe. */
const sendResponse = useCallback((message: JSONRPCResponse) => {
iframeRef.current?.contentWindow?.postMessage(message, iframeOrigin);
}, [iframeOrigin]);
/** 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;
/** 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;
if (!sha256) {
sha256 = manifest.current.get('/index.html');
servingPath = '/index.html';
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,
});
}
}, [iframeOrigin, sendResponse]);
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 };
/** Handle navigation state updates from the iframe (no-op). */
const handleNavigationState = useCallback((_params: {
currentUrl: string;
canGoBack: boolean;
canGoForward: boolean;
}) => {
// intentionally empty
}, []);
// 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
@@ -186,7 +343,7 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
}}
>
{/* Nav bar */}
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
<div className="h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{appPicture ? (
@@ -215,15 +372,15 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
</Button>
</div>
{/* Sandboxed iframe */}
{/* iframe */}
<div className="flex-1 min-h-0 bg-background">
<SandboxFrame
key={`${previewSubdomain}-${open}`}
id={previewSubdomain}
resolveFile={resolveFile}
injectedScripts={injectedScripts}
<iframe
key={`${subdomain}-${open}`}
ref={iframeRef}
src={iframeSrc}
className="w-full h-full border-0"
title={`${appName} preview`}
sandbox="allow-scripts allow-same-origin allow-forms"
/>
</div>
</div>,
-637
View File
@@ -1,637 +0,0 @@
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();
// Create the native WebView.
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;
// Listen for fetch requests from the native URL scheme handler.
const fetchListener = await SandboxPlugin.addListener(
'fetch',
(event: SandboxFetchEvent) => {
if (event.id !== id) return;
handleNativeFetch(event);
},
);
listeners.push(fetchListener);
// Listen for script messages (custom JSON-RPC from injected scripts).
const scriptListener = await SandboxPlugin.addListener(
'scriptMessage',
(event: SandboxScriptMessageEvent) => {
if (event.id !== id) return;
handleNativeScriptMessage(event);
},
);
listeners.push(scriptListener);
}
// ---------------------------------------------------------------
// 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
// -----------------------------------------------------------------
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);
window.addEventListener('scroll', updateFrame, { passive: true });
return () => {
ro.disconnect();
window.removeEventListener('scroll', updateFrame);
};
}, [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 -4
View File
@@ -167,9 +167,9 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
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"
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/90 to-transparent cursor-pointer"
>
<ChevronLeft className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
<ChevronLeft className="size-4 text-muted-foreground" />
</button>
)}
<div
@@ -184,9 +184,9 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
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"
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/90 to-transparent cursor-pointer"
>
<ChevronRight className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
<ChevronRight className="size-4 text-muted-foreground" />
</button>
)}
</div>
+7 -27
View File
@@ -30,52 +30,32 @@ export function useActiveTabIndicator(active: boolean, elRef: React.RefObject<HT
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 };
const scrollOffset = scrollContainerRef.current?.scrollLeft ?? 0;
return { left: el.offsetLeft - scrollOffset, 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);
};
return () => onActive(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
// Re-report position when the scroll container scrolls or resizes,
// Re-report position when the scroll container scrolls,
// 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 handleScroll = () => {
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();
};
container.addEventListener('scroll', handleScroll, { passive: true });
return () => container.removeEventListener('scroll', handleScroll);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
+19 -18
View File
@@ -9,7 +9,6 @@ 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';
@@ -88,32 +87,34 @@ export function TeamSoapboxCard({ className }: { className?: string }) {
setIsFollowingAll(true);
try {
// 1. Fetch freshest kind 3 from relays (not cache)
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
const signal = AbortSignal.timeout(10_000);
// 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 followEvents = await nostr.query(
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
{ signal },
);
// 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;
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));
// 4. Publish with prev for published_at preservation
await publishEvent({
kind: 3,
content: prev?.content ?? '',
tags: [...nonPTags, ...existingPTags, ...newPTags],
prev: prev ?? undefined,
content: latestEvent?.content ?? '',
tags: allFollows.map((pk) => ['p', pk]),
});
toast({
title: 'Following Team Soapbox!',
description: added > 0
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
description: added.length > 0
? `Added ${added.length} new account${added.length !== 1 ? 's' : ''} to your follow list.`
: 'You were already following everyone on the team.',
});
} catch (error) {
+222 -368
View File
@@ -2,25 +2,19 @@ import {
useRef,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
forwardRef,
type IframeHTMLAttributes,
} 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';
} from "react";
import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from "@webxdc/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface WebxdcProps
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, 'src' | 'id'> {
/** Unique session identifier — used as the sandbox subdomain. */
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, "src" | "id"> {
/** Unique session identifier — used as the subdomain: `<id>.webxdc.app`. */
id: string;
/** The `.xdc` archive: raw bytes or a URL to fetch them from. */
xdc: Uint8Array | string;
@@ -36,181 +30,21 @@ 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 a Uint8Array. */
async function resolveXdc(xdc: Uint8Array | string): Promise<Uint8Array> {
if (typeof xdc === 'string') {
/** Resolve `xdc` prop to an ArrayBuffer. */
async function resolveXdc(xdc: Uint8Array | string): Promise<ArrayBuffer> {
if (typeof xdc === "string") {
const res = await fetch(xdc);
if (!res.ok) throw new Error(`Failed to fetch xdc: ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
return res.arrayBuffer();
}
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;
});
}
};
}
};
})();`;
// Uint8Array → ArrayBuffer (copy so we can transfer)
const copy = new ArrayBuffer(xdc.byteLength);
new Uint8Array(copy).set(xdc);
return copy;
}
// ---------------------------------------------------------------------------
@@ -218,46 +52,217 @@ function generateWebxdcBridge(api: WebxdcAPI<unknown>): string {
// ---------------------------------------------------------------------------
/**
* Renders a webxdc app inside a sandboxed iframe.
* Renders a webxdc app inside an iframe hosted on `<id>.webxdc.app`.
*
* 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.
* 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.
*/
export const Webxdc = forwardRef<WebxdcHandle, WebxdcProps>(function Webxdc(
{ id, xdc, webxdc, ...iframeProps },
ref,
) {
const sandboxRef = useRef<SandboxFrameHandle>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Keep latest props in refs so callbacks always see current values.
// Keep latest props in refs so the message handler always sees current values
// without needing to re-register the listener.
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]);
// 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>('');
const origin = `https://${id}.webxdc.app`;
// Realtime channel handles, keyed by channelId.
const realtimeChannels = useRef<
Map<string, ReturnType<WebxdcAPI<unknown>['joinRealtimeChannel']>>
>(new Map());
// ------------------------------------------------------------------
// 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],
);
// Expose imperative handle so parent components can post messages and focus.
useImperativeHandle(ref, () => ({
postMessage: (msg: Record<string, unknown>, transfer?: Transferable[]) => {
sandboxRef.current?.postMessage(msg, transfer);
iframeRef.current?.contentWindow?.postMessage(
msg,
origin,
transfer ?? [],
);
},
focus: () => {
sandboxRef.current?.focus();
iframeRef.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(() => {
@@ -268,177 +273,26 @@ 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 (
<SandboxFrame
ref={sandboxRef}
id={id}
resolveFile={resolveFileWithBridge}
onRpc={onRpc}
csp={WEBXDC_CSP}
onReady={onReady}
<iframe
ref={iframeRef}
src={`${origin}/`}
{...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;
+4 -13
View File
@@ -1,12 +1,10 @@
import { useState, useRef, useCallback, forwardRef } from 'react';
import { createPortal } from 'react-dom';
import { Blocks, Play, Maximize2, Minimize2, RotateCcw, X, Gamepad2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip';
import { Webxdc, type WebxdcHandle } from '@/components/Webxdc';
import { GameControls } from '@/components/GameControls';
import { useWebxdc } from '@/hooks/useWebxdc';
import { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
import { cn } from '@/lib/utils';
export interface WebxdcEmbedProps {
@@ -33,11 +31,8 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
const containerRef = useRef<HTMLDivElement>(null);
const webxdcHandleRef = useRef<WebxdcHandle>(null);
// Derive a private, stable subdomain from a device-local seed + the identifier.
// This prevents event authors from choosing a subdomain that collides with
// another app's origin on iframe.diy.
const identifier = uuid ?? url;
const iframeId = deriveIframeSubdomain('webxdc', identifier);
// Derive a stable iframe ID from the UUID or URL
const iframeId = uuid ?? url.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
const handleReload = useCallback(() => {
setIframeKey((k) => k + 1);
@@ -95,7 +90,7 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
);
}
const content = (
return (
<div
ref={containerRef}
className={cn(
@@ -109,7 +104,7 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
{/* Controls bar */}
<div className={cn(
'flex items-center justify-between px-3 py-1.5 bg-muted/60 border-b border-border',
isFullscreen ? 'safe-area-top' : 'rounded-t-2xl',
isFullscreen ? '' : 'rounded-t-2xl',
)}>
<div className="flex items-center gap-2 min-w-0">
{icon ? (
@@ -226,10 +221,6 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
)}
</div>
);
// Portal fullscreen out of any parent stacking context (e.g. z-0 center column)
// so it reliably sits above the mobile top bar and other fixed UI.
return isFullscreen ? createPortal(content, document.body) : content;
}
/**
-4
View File
@@ -241,10 +241,6 @@ export interface AppConfig {
savedFeeds: SavedFeed[];
/** Image upload quality: "compressed" resizes/optimizes, "original" uploads as-is. Default: "compressed". */
imageQuality: 'compressed' | 'original';
/** Hex pubkey of the curator whose follow list defines the Ditto feed. */
curatorPubkey?: string;
/** Wildcard domain used for iframe sandboxing (e.g. "iframe.diy"). Default: "iframe.diy". */
sandboxDomain: string;
}
export interface AppContextType {
-94
View File
@@ -1,94 +0,0 @@
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { DITTO_RELAYS } from '@/lib/appRelays';
/** Curated kinds for the Ditto feed: unique Ditto content types. */
const CURATED_KINDS = [
20, // Photos (NIP-68)
21, // Videos (NIP-71)
22, // Short Videos (NIP-71)
34236, // Divines (addressable short videos)
36787, // Music Tracks
34139, // Music Playlists
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 WEBXDC_FILTER = { kinds: [1063], '#m': ['application/x-webxdc'] };
/**
* Compute a short fingerprint of a string array for use in query keys.
* Produces a stable, content-dependent value so the query busts when
* the actual pubkey set changes (not just its length).
*/
function fingerprint(items: string[]): string {
// Simple djb2-style hash — fast and collision-resistant enough for a cache key.
let hash = 5381;
for (const item of items) {
for (let i = 0; i < item.length; i++) {
hash = ((hash << 5) + hash + item.charCodeAt(i)) | 0;
}
}
return (hash >>> 0).toString(36);
}
/**
* Curated Ditto feed: latest content from the curator's follow list.
* Standard NIP-01 reverse-chronological pagination (no sort:hot).
*
* @param authors - Pubkeys whose content to include (from useCuratorFollowList).
* @param enabled - Whether the query should run.
*/
export function useCuratedDittoFeed(authors: string[] | undefined, enabled: boolean) {
const { nostr } = useNostr();
const authorsKey = authors ? fingerprint(authors) : '';
return useInfiniteQuery<NostrEvent[], Error>({
queryKey: ['ditto-curated-feed', authorsKey],
queryFn: async ({ pageParam, signal }) => {
const base: Record<string, unknown> = {
kinds: CURATED_KINDS,
authors,
limit: 20,
};
if (pageParam) base.until = pageParam;
// Webxdc needs a separate filter with MIME-type tag constraint
const webxdcFilter: Record<string, unknown> = {
...WEBXDC_FILTER,
authors,
limit: 20,
};
if (pageParam) webxdcFilter.until = pageParam;
const ditto = nostr.group(DITTO_RELAYS);
return ditto.query(
[base, webxdcFilter] as Parameters<typeof ditto.query>[0],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
);
},
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) return undefined;
return lastPage[lastPage.length - 1].created_at - 1;
},
initialPageParam: undefined as number | undefined,
enabled: enabled && !!authors && authors.length > 0,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
placeholderData: (prev) => prev,
});
}
/** Re-export for use in Feed.tsx landing hero / kind lists. */
export { CURATED_KINDS, WEBXDC_FILTER };
-68
View File
@@ -1,68 +0,0 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
/** localStorage key for cached curator follow list. */
const CACHE_KEY = 'ditto:curatorFollowList';
/** Read cached curator follow list from localStorage. */
function getCached(): string[] | undefined {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return undefined;
const cached = JSON.parse(raw);
if (!Array.isArray(cached)) return undefined;
return cached;
} catch {
return undefined;
}
}
/** Persist curator follow list to localStorage. */
function setCached(pubkeys: string[]): void {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(pubkeys));
} catch {
// Storage full or unavailable — non-critical
}
}
/**
* Fetches the follow list (kind 3 `p` tags) for the curator pubkey.
* Returns the curator's pubkey + all pubkeys they follow.
* Cached in localStorage for instant display on return visits.
*
* The curator pubkey is read from `config.curatorPubkey`. When unset the
* hook is disabled and returns `undefined`.
*/
export function useCuratorFollowList() {
const { nostr } = useNostr();
const { config } = useAppContext();
const curatorPubkey = config.curatorPubkey;
return useQuery<string[]>({
queryKey: ['curator-follow-list', curatorPubkey],
queryFn: async ({ signal }) => {
if (!curatorPubkey) return [];
const [event] = await nostr.query(
[{ kinds: [3], authors: [curatorPubkey], limit: 1 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
if (!event) return [curatorPubkey];
const pubkeys = event.tags
.filter(([name]) => name === 'p')
.map(([, pk]) => pk);
// Include the curator themselves
const allPubkeys = [...new Set([curatorPubkey, ...pubkeys])];
setCached(allPubkeys);
return allPubkeys;
},
enabled: !!curatorPubkey,
staleTime: 10 * 60 * 1000, // 10 minutes
gcTime: 60 * 60 * 1000, // 1 hour
placeholderData: getCached(),
});
}
-2
View File
@@ -59,8 +59,6 @@ export interface EncryptedSettings {
contentWarningPolicy?: ContentWarningPolicy;
/** Whether the user has enabled push notifications */
notificationsEnabled?: boolean;
/** Notification delivery style on native: 'push' (default) or 'persistent' (foreground service) */
notificationStyle?: 'push' | 'persistent';
/** Timestamp of last viewed notification (Unix timestamp in seconds) */
notificationsCursor?: number;
/** Per-type notification preferences (all default to true/enabled) */
+1 -4
View File
@@ -36,7 +36,6 @@ export function useHasUnreadNotifications(): boolean {
const { data: followData } = useFollowList();
const prefs = settings?.notificationPreferences;
const notificationStyle = settings?.notificationStyle ?? 'push';
// Derive enabled kinds from preferences so disabled types don't trigger the dot
const enabledKinds = useMemo(
@@ -78,9 +77,7 @@ export function useHasUnreadNotifications(): boolean {
return events.some((e) => e.pubkey !== user.pubkey);
},
enabled: !!user && notificationsCursor !== null,
// Disable polling on native only when using persistent mode (foreground service
// handles it). In push mode on native, poll like web since there's no service.
refetchInterval: Capacitor.isNativePlatform() && notificationStyle === 'persistent' ? false : 60_000,
refetchInterval: Capacitor.isNativePlatform() ? false : 60_000,
placeholderData: (prev) => prev,
});
+2 -4
View File
@@ -11,7 +11,7 @@ import { getEnabledNotificationKinds } from '@/lib/notificationKinds';
/** Interface for the native DittoNotification Capacitor plugin. */
interface DittoNotificationPlugin {
configure(options: { userPubkey?: string; relayUrls?: string[]; enabledKinds?: number[]; authors?: string[]; notificationStyle?: string }): Promise<void>;
configure(options: { userPubkey?: string; relayUrls?: string[]; enabledKinds?: number[]; authors?: string[] }): Promise<void>;
}
const DittoNotification = registerPlugin<DittoNotificationPlugin>('DittoNotification');
@@ -36,7 +36,6 @@ export function useNativeNotifications(): void {
const prefs = settings?.notificationPreferences;
const notificationsEnabled = settings?.notificationsEnabled ?? true;
const notificationStyle = settings?.notificationStyle ?? 'push';
const enabledKinds = useMemo(
() => getEnabledNotificationKinds(prefs),
[prefs],
@@ -88,8 +87,7 @@ export function useNativeNotifications(): void {
userPubkey: user.pubkey,
relayUrls,
enabledKinds,
notificationStyle,
...(authorsFilter ? { authors: authorsFilter } : {}),
});
}, [user, config.relayMetadata, config.useAppRelays, notificationsEnabled, notificationStyle, enabledKinds, authorsFilter]);
}, [user, config.relayMetadata, config.useAppRelays, notificationsEnabled, enabledKinds, authorsFilter]);
}
+1 -1
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { Webxdc as WebxdcAPI, SendingStatusUpdate, ReceivedStatusUpdate, RealtimeListener } from '@webxdc/types';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { NSecSigner } from '@nostrify/nostrify';
import type { Webxdc as WebxdcAPI, SendingStatusUpdate, ReceivedStatusUpdate, RealtimeListener } from '@webxdc/types/webxdc';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
+17 -29
View File
@@ -6,10 +6,6 @@ import { openDB, type IDBPDatabase } from 'idb';
// All persistent client-side data lives in a single "ditto" database with
// one object store per data domain. Callers should import `openDatabase()`
// rather than managing their own `openDB` calls.
//
// When IndexedDB is unavailable (e.g. iOS Lockdown Mode, certain private-
// browsing modes) every function in this module still works — callers get
// `null` instead of a database handle and should skip persistence silently.
// ============================================================================
const DB_NAME = 'ditto';
@@ -22,37 +18,29 @@ export const STORE = {
MESSAGES: 'messages',
} as const;
let dbPromise: Promise<IDBPDatabase | null> | null = null;
let dbPromise: Promise<IDBPDatabase> | null = null;
/**
* Open (or reuse) the shared Ditto database.
*
* Returns `null` when IndexedDB is not available (e.g. iOS Lockdown Mode,
* some private-browsing contexts). The result is cached for the page
* lifetime so the availability check runs only once.
* The returned promise is cached so only one connection is created per
* page lifetime, regardless of how many callers import this function.
*/
export function openDatabase(): Promise<IDBPDatabase | null> {
export function openDatabase(): Promise<IDBPDatabase> {
if (!dbPromise) {
dbPromise = (async () => {
try {
return await openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE.NIP05)) {
db.createObjectStore(STORE.NIP05);
}
if (!db.objectStoreNames.contains(STORE.PROFILES)) {
db.createObjectStore(STORE.PROFILES);
}
if (!db.objectStoreNames.contains(STORE.MESSAGES)) {
db.createObjectStore(STORE.MESSAGES);
}
},
});
} catch {
// IndexedDB is unavailable — degrade to in-memory only.
return null;
}
})();
dbPromise = openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE.NIP05)) {
db.createObjectStore(STORE.NIP05);
}
if (!db.objectStoreNames.contains(STORE.PROFILES)) {
db.createObjectStore(STORE.PROFILES);
}
if (!db.objectStoreNames.contains(STORE.MESSAGES)) {
db.createObjectStore(STORE.MESSAGES);
}
},
});
}
return dbPromise;
}
+27 -27
View File
@@ -26,9 +26,8 @@ export interface MessageStore {
// ============================================================================
/**
* Write messages to IndexedDB for a specific user.
* Messages are stored in their original encrypted form (kind 4 or kind 13).
* Silently skipped when IndexedDB is unavailable.
* Write messages to IndexedDB for a specific user
* Messages are stored in their original encrypted form (kind 4 or kind 13)
*/
export async function writeMessagesToDB(
userPubkey: string,
@@ -36,59 +35,60 @@ export async function writeMessagesToDB(
): Promise<void> {
try {
const db = await openDatabase();
if (!db) return; // IndexedDB unavailable — skip persistence.
// Store messages in their original encrypted form (no NIP-44 wrapper needed)
// Each message content is already encrypted by the sender
await db.put(STORE.MESSAGES, messageStore, userPubkey);
} catch {
// Write failure is non-critical — DMs still work in-memory.
// Store messages in their original encrypted form (no NIP-44 wrapper needed)
// Each message content is already encrypted by the sender
await db.put(STORE.MESSAGES, messageStore, userPubkey);
} catch (error) {
console.error('[MessageStore] Error writing to IndexedDB:', error);
throw error;
}
}
/**
* Read messages from IndexedDB for a specific user.
* Messages are stored in their original encrypted form (kind 4 or kind 13).
* Returns `undefined` when IndexedDB is unavailable.
* Read messages from IndexedDB for a specific user
* Messages are stored in their original encrypted form (kind 4 or kind 13)
*/
export async function readMessagesFromDB(
userPubkey: string
): Promise<MessageStore | undefined> {
try {
const db = await openDatabase();
if (!db) return undefined; // IndexedDB unavailable.
const data = await db.get(STORE.MESSAGES, userPubkey);
if (!data) return undefined;
if (!data) {
return undefined;
}
return data as MessageStore;
} catch {
// Read failure — return undefined so the caller proceeds without cache.
return undefined;
} catch (error) {
console.error('[MessageStore] Error reading from IndexedDB:', error);
throw error;
}
}
/**
* Delete messages from IndexedDB for a specific user.
* Silently skipped when IndexedDB is unavailable.
* Delete messages from IndexedDB for a specific user
*/
export async function deleteMessagesFromDB(userPubkey: string): Promise<void> {
try {
const db = await openDatabase();
if (!db) return;
await db.delete(STORE.MESSAGES, userPubkey);
} catch {
// Non-critical.
} catch (error) {
console.error('[MessageStore] Error deleting from IndexedDB:', error);
throw error;
}
}
/**
* Clear all messages from IndexedDB.
* Silently skipped when IndexedDB is unavailable.
* Clear all messages from IndexedDB
*/
export async function clearAllMessages(): Promise<void> {
try {
const db = await openDatabase();
if (!db) return;
await db.clear(STORE.MESSAGES);
} catch {
// Non-critical.
} catch (error) {
console.error('[MessageStore] Error clearing IndexedDB:', error);
throw error;
}
}
-216
View File
@@ -1,216 +0,0 @@
import { getKindId } from '@/lib/extraKinds';
import type { FeedItem } from '@/lib/feedUtils';
/** Options for feed diversity reordering. */
export interface DiversifyOptions {
/** Minimum index gap between same content type (default: 4). */
minGap?: number;
/** Maximum proportion of a page any single type can occupy (default: 0.2 = 20%). */
maxProportion?: number;
/** Per-type cap overrides. Key is the content-type ID (from getKindId), value is the proportion. */
typeCaps?: Record<string, number>;
}
/** Default per-type cap overrides. */
const DEFAULT_TYPE_CAPS: Record<string, number> = {
blobbi: 0.1, // 10% cap for Blobbi
};
/** Resolve a kind number to a content-type bucket string. */
function getContentType(kind: number): string {
return getKindId(kind) ?? `kind-${kind}`;
}
/**
* Diversify a single page of feed items for content-type variety.
*
* Two-phase algorithm:
* 1. **Proportional cap** no single content type may exceed `maxProportion`
* of the page. Excess items (the least-hot ones for that type) are trimmed.
* 2. **Gap-enforced interleave** items are placed so the same content type
* doesn't appear within `minGap` positions of itself.
*
* @param priorTail - The last `minGap` content types from the previous page,
* so the gap constraint holds across page boundaries. Pass an empty array
* for the first page.
*/
export function diversifyPage(
items: FeedItem[],
priorTail: string[],
options?: DiversifyOptions,
): FeedItem[] {
if (items.length === 0) return items;
const minGap = options?.minGap ?? 4;
const maxProportion = options?.maxProportion ?? 0.2;
const typeCaps = { ...DEFAULT_TYPE_CAPS, ...options?.typeCaps };
// ── Phase 1: Proportional cap ────────────────────────────────────────
const capped = applyCap(items, maxProportion, typeCaps);
// ── Phase 2: Gap-enforced interleave ─────────────────────────────────
return applyGapInterleave(capped, minGap, priorTail);
}
/**
* Diversify multiple pages of feed items incrementally.
*
* Each page is diversified independently but the gap state carries forward
* from the previous page's tail. This ensures:
* - Earlier pages never change when new pages arrive (no visual jumps)
* - The gap constraint holds across page boundaries
* - The proportional cap applies per-page
*/
export function diversifyFeedPages(
pages: FeedItem[][],
options?: DiversifyOptions,
): FeedItem[] {
const minGap = options?.minGap ?? 4;
const result: FeedItem[] = [];
let priorTail: string[] = [];
for (const page of pages) {
const diversified = diversifyPage(page, priorTail, options);
result.push(...diversified);
// Extract the tail content types for the next page's gap tracking.
// We need the last `minGap` types from the combined result so far.
const tailSlice = result.slice(-minGap);
priorTail = tailSlice.map((item) => getContentType(item.event.kind));
}
return result;
}
/**
* Cap each content type to at most `maxProportion` of the page item count.
* Per-type overrides in `typeCaps` take precedence over the default proportion.
* Keeps the hottest items for each type (items are already hot-sorted).
*/
function applyCap(
items: FeedItem[],
maxProportion: number,
typeCaps: Record<string, number>,
): FeedItem[] {
const defaultMax = Math.max(1, Math.ceil(items.length * maxProportion));
/** Resolve the cap for a given content type. */
function maxForType(type: string): number {
const override = typeCaps[type];
if (override !== undefined) {
return Math.max(1, Math.ceil(items.length * override));
}
return defaultMax;
}
const typeCounts = new Map<string, number>();
const result: FeedItem[] = [];
for (const item of items) {
const type = getContentType(item.event.kind);
const count = typeCounts.get(type) ?? 0;
if (count < maxForType(type)) {
result.push(item);
typeCounts.set(type, count + 1);
}
}
return result;
}
/**
* Reorder items so that no two items of the same content type appear
* within `minGap` positions of each other.
*
* @param priorTail - Content type strings from the tail of the previous page,
* used to seed the `lastPlaced` map so the gap holds across boundaries.
*/
function applyGapInterleave(
items: FeedItem[],
minGap: number,
priorTail: string[],
): FeedItem[] {
const result: FeedItem[] = [];
const deferred: FeedItem[] = [];
/** Map from content type → index of last placement in `result`. */
const lastPlaced = new Map<string, number>();
// Seed lastPlaced from the prior page's tail so the gap constraint
// holds across page boundaries. Use negative indices representing
// positions "before" this page's result array.
for (let i = 0; i < priorTail.length; i++) {
const type = priorTail[i];
// The tail items are at virtual indices -(priorTail.length - i)
// relative to the start of this page's result.
const virtualIndex = -(priorTail.length - i);
const existing = lastPlaced.get(type);
// Keep the highest (most recent) index for each type
if (existing === undefined || virtualIndex > existing) {
lastPlaced.set(type, virtualIndex);
}
}
function canPlace(type: string): boolean {
const lastIdx = lastPlaced.get(type);
if (lastIdx === undefined) return true;
return result.length - lastIdx >= minGap;
}
function place(item: FeedItem): void {
const type = getContentType(item.event.kind);
lastPlaced.set(type, result.length);
result.push(item);
}
// Main pass
for (const item of items) {
drainDeferred(deferred, result, lastPlaced, minGap);
const type = getContentType(item.event.kind);
if (canPlace(type)) {
place(item);
} else {
deferred.push(item);
}
}
// Final drain: keep looping until no deferred item can be placed.
// Each iteration tries every item in the queue (not just the front).
for (;;) {
const sizeBefore = deferred.length;
drainDeferred(deferred, result, lastPlaced, minGap);
if (deferred.length === sizeBefore) break; // no progress
}
// Drop anything still deferred rather than clustering same-type items
// at the tail. The cap already limits per-type count; these leftovers
// are items that can't be placed without violating the gap, so it's
// better to omit them than to show three Blobbis in a row.
return result;
}
/**
* Drain one item from the deferred queue whose gap constraint is now satisfied.
*/
function drainDeferred(
deferred: FeedItem[],
result: FeedItem[],
lastPlaced: Map<string, number>,
minGap: number,
): void {
for (let i = 0; i < deferred.length; i++) {
const item = deferred[i];
const type = getContentType(item.event.kind);
const lastIdx = lastPlaced.get(type);
const ok = lastIdx === undefined || result.length - lastIdx >= minGap;
if (ok) {
lastPlaced.set(type, result.length);
result.push(item);
deferred.splice(i, 1);
break;
}
}
}
-44
View File
@@ -1,44 +0,0 @@
import { hmac } from '@noble/hashes/hmac';
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
import { hexToBase36 } from '@/lib/nsiteSubdomain';
const SEED_STORAGE_KEY = 'ditto:seed';
/**
* Get or create a device-local random seed persisted in localStorage.
* This is a general-purpose secret used to derive private identifiers
* (e.g. sandbox frame subdomains) that must not be predictable by third parties.
*/
function getSeed(): string {
const stored = localStorage.getItem(SEED_STORAGE_KEY);
if (stored) return stored;
const seed = crypto.randomUUID();
localStorage.setItem(SEED_STORAGE_KEY, seed);
return seed;
}
/**
* Derive a stable, private subdomain label for a sandbox frame.
*
* Uses HMAC-SHA256 with the device-local seed as the key and
* `prefix|identifier` as the message. Because the seed is secret to
* this device, a third party cannot predict or collide with another
* app's subdomain, preventing cross-app localStorage/IndexedDB access
* on the sandbox domain.
*
* The `prefix` acts as a domain separator so that different use-cases
* (e.g. "webxdc", "nsite") produce distinct subdomains even for the
* same identifier.
*
* The result is a 50-character base36 string (256 bits of entropy) that
* fits within the 63-character subdomain label limit.
*/
export function deriveIframeSubdomain(prefix: string, identifier: string): string {
const seed = getSeed();
const enc = new TextEncoder();
const mac = hmac(sha256, enc.encode(seed), enc.encode(`${prefix}|${identifier}`));
return hexToBase36(bytesToHex(mac));
}
+4 -5
View File
@@ -35,13 +35,12 @@ export function hydrateNip05Cache(): Promise<void> {
hydratePromise = (async () => {
try {
const db = await openDatabase();
if (!db) return; // IndexedDB unavailable — skip hydration.
const entries: Nip05CacheEntry[] = await db.getAll(STORE.NIP05);
for (const entry of entries) {
memoryCache.set(entry.identifier, entry);
}
} catch {
// IndexedDB read failure — silently degrade.
// IndexedDB unavailable (e.g. private browsing) — silently degrade.
} finally {
hydrated = true;
}
@@ -70,7 +69,7 @@ export async function setNip05Cached(identifier: string, pubkey: string): Promis
try {
const db = await openDatabase();
if (db) await db.put(STORE.NIP05, entry, identifier);
await db.put(STORE.NIP05, entry, identifier);
} catch {
// Write failure is non-critical — the in-memory cache still works.
}
@@ -85,7 +84,7 @@ export async function deleteNip05Cached(identifier: string): Promise<void> {
try {
const db = await openDatabase();
if (db) await db.delete(STORE.NIP05, identifier);
await db.delete(STORE.NIP05, identifier);
} catch {
// Non-critical.
}
@@ -97,7 +96,7 @@ export async function clearNip05Cache(): Promise<void> {
try {
const db = await openDatabase();
if (db) await db.clear(STORE.NIP05);
await db.clear(STORE.NIP05);
} catch {
// Non-critical.
}
-12
View File
@@ -1,12 +0,0 @@
import type { NostrEvent } from '@nostrify/nostrify';
/** Parse a follow pack / starter pack event into structured data. */
export 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 };
}
-15
View File
@@ -1,15 +0,0 @@
/**
* Script injected into preview iframe HTML responses.
*
* The sandbox frame loads the inner iframe at `/index.html`. This script
* normalises the path to `/` before any SPA router initialises, so
* React Router etc. see the correct path.
*/
export function getPreviewInjectedScript(): string {
return `(function() {
'use strict';
if (window.location.pathname === '/index.html') {
history.replaceState(null, '', '/');
}
})();`;
}
+4 -5
View File
@@ -42,13 +42,12 @@ export function hydrateProfileCache(): Promise<void> {
hydratePromise = (async () => {
try {
const db = await openDatabase();
if (!db) return; // IndexedDB unavailable — skip hydration.
const entries: ProfileCacheEntry[] = await db.getAll(STORE.PROFILES);
for (const entry of entries) {
memoryCache.set(entry.pubkey, entry);
}
} catch {
// IndexedDB read failure — silently degrade.
// IndexedDB unavailable (e.g. private browsing) — silently degrade.
} finally {
hydrated = true;
}
@@ -88,7 +87,7 @@ export async function setProfileCached(event: NostrEvent, metadata?: NostrMetada
try {
const db = await openDatabase();
if (db) await db.put(STORE.PROFILES, entry, event.pubkey);
await db.put(STORE.PROFILES, entry, event.pubkey);
} catch {
// Write failure is non-critical — the in-memory cache still works.
}
@@ -100,7 +99,7 @@ export async function deleteProfileCached(pubkey: string): Promise<void> {
try {
const db = await openDatabase();
if (db) await db.delete(STORE.PROFILES, pubkey);
await db.delete(STORE.PROFILES, pubkey);
} catch {
// Non-critical.
}
@@ -112,7 +111,7 @@ export async function clearProfileCache(): Promise<void> {
try {
const db = await openDatabase();
if (db) await db.clear(STORE.PROFILES);
await db.clear(STORE.PROFILES);
} catch {
// Non-critical.
}
-14
View File
@@ -1,14 +0,0 @@
/** Encode a Uint8Array to base64. */
export function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/** Encode a UTF-8 string to base64. */
export function utf8ToBase64(str: string): string {
const bytes = new TextEncoder().encode(str);
return bytesToBase64(bytes);
}
-24
View File
@@ -1,24 +0,0 @@
/**
* Inject `<script>` tags into an HTML document string.
*
* Uses DOMParser to safely manipulate the DOM, then serialises back to a
* string. Each script path is prepended inside `<head>` so the injected
* scripts run before the app's own scripts.
*/
export function injectScriptTags(html: string, scriptPaths: string[]): string {
if (scriptPaths.length === 0) return html;
const doc = new DOMParser().parseFromString(html, 'text/html');
// Insert in reverse order so the first path ends up first in <head>.
for (let i = scriptPaths.length - 1; i >= 0; i--) {
const script = doc.createElement('script');
script.src = scriptPaths[i];
doc.head.prepend(script);
}
// DOMParser strips the doctype; re-add it when the original had one.
const hasDoctype = /^<!doctype\s/i.test(html.trimStart());
const serialised = doc.documentElement.outerHTML;
return hasDoctype ? '<!DOCTYPE html>\n' + serialised : serialised;
}
-14
View File
@@ -1,14 +0,0 @@
export { getMimeType } from './mimeTypes';
export { bytesToBase64, utf8ToBase64 } from './base64';
export { injectScriptTags } from './htmlInjector';
export type {
JsonRpcRequest,
JsonRpcNotification,
JsonRpcSuccessResponse,
JsonRpcErrorResponse,
JsonRpcResponse,
SerialisedRequest,
SerialisedResponse,
FileResponse,
InjectedScript,
} from './types';
-50
View File
@@ -1,50 +0,0 @@
/**
* Unified MIME type lookup for sandbox file serving.
* Covers common web-relevant file types served through the sandbox frame.
*/
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.bmp': 'image/bmp',
'.avif': 'image/avif',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.mp3': 'audio/mpeg',
'.ogg': 'audio/ogg',
'.wav': 'audio/wav',
'.opus': 'audio/opus',
'.weba': 'audio/webm',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.xml': 'application/xml',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.wasm': 'application/wasm',
'.pdf': 'application/pdf',
'.toml': 'application/toml',
};
/**
* Guess a MIME type from a file path or extension.
* Falls back to `application/octet-stream` for unknown extensions.
*/
export function getMimeType(path: string): string {
const dot = path.lastIndexOf('.');
if (dot === -1) return 'application/octet-stream';
const ext = path.slice(dot).toLowerCase();
return MIME_TYPES[ext] ?? 'application/octet-stream';
}
-73
View File
@@ -1,73 +0,0 @@
// ---------------------------------------------------------------------------
// JSON-RPC 2.0 message types used by the sandbox frame protocol.
// ---------------------------------------------------------------------------
export interface JsonRpcRequest {
jsonrpc: '2.0';
id: string | number;
method: string;
params?: unknown;
}
export interface JsonRpcNotification {
jsonrpc: '2.0';
method: string;
params?: unknown;
}
export interface JsonRpcSuccessResponse {
jsonrpc: '2.0';
id: string | number;
result: unknown;
}
export interface JsonRpcErrorResponse {
jsonrpc: '2.0';
id: string | number;
error: { code: number; message: string };
}
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
// ---------------------------------------------------------------------------
// Serialised HTTP request/response shapes exchanged via the fetch RPC.
// ---------------------------------------------------------------------------
export interface SerialisedRequest {
url: string;
method: string;
headers: Record<string, string>;
body: string | null;
}
export interface SerialisedResponse {
status: number;
statusText: string;
headers: Record<string, string>;
body: string | null;
}
// ---------------------------------------------------------------------------
// File resolution types used by SandboxFrame consumers.
// ---------------------------------------------------------------------------
/** The result of resolving a file request inside the sandbox. */
export interface FileResponse {
/** HTTP status code. */
status: number;
/** MIME content type (e.g. "text/html"). */
contentType: string;
/** Raw file bytes. */
body: Uint8Array;
}
/**
* A virtual script that the sandbox frame should serve at a given path
* and inject into HTML responses via a `<script>` tag.
*/
export interface InjectedScript {
/** The virtual path to serve this script at (e.g. "/__injected__/preview.js"). */
path: string;
/** The script source code as a string. */
content: string;
}
-126
View File
@@ -1,126 +0,0 @@
/**
* SandboxPlugin Capacitor plugin for native sandboxed WebViews.
*
* On iOS, each sandbox gets a WKWebView with a custom URL scheme handler
* (`sbx-<id>://`) that intercepts all resource requests and forwards them
* to the JS layer. On Android, the same is achieved via
* `shouldInterceptRequest`. This replaces iframe.diy on native platforms.
*
* The plugin is registered as "SandboxPlugin" and is only usable on native
* platforms. On web, SandboxFrame uses iframe.diy directly.
*/
import { registerPlugin } from '@capacitor/core';
import type { PluginListenerHandle } from '@capacitor/core';
// ---------------------------------------------------------------------------
// Plugin method options
// ---------------------------------------------------------------------------
/** Options for creating a new sandbox WebView. */
export interface SandboxCreateOptions {
/** Unique identifier for this sandbox (the HMAC-derived subdomain ID). */
id: string;
/** Absolute position and size of the WebView within the app window. */
frame: { x: number; y: number; width: number; height: number };
}
/** Options for updating the WebView frame (position/size). */
export interface SandboxUpdateFrameOptions {
id: string;
frame: { x: number; y: number; width: number; height: number };
}
/** A serialised fetch response sent back to the native WebView. */
export interface SandboxRespondToFetchOptions {
id: string;
requestId: string;
response: {
status: number;
statusText: string;
headers: Record<string, string>;
body: string | null;
};
}
/** Options for posting a message into the sandbox (to injected scripts). */
export interface SandboxPostMessageOptions {
id: string;
message: Record<string, unknown>;
}
/** Options for destroying a sandbox. */
export interface SandboxDestroyOptions {
id: string;
}
// ---------------------------------------------------------------------------
// Plugin event payloads
// ---------------------------------------------------------------------------
/** A fetch request forwarded from the native WebView's URL scheme handler. */
export interface SandboxFetchEvent {
/** The sandbox ID this request belongs to. */
id: string;
/** Unique request ID — pass back to `respondToFetch`. */
requestId: string;
/** The serialised HTTP request. */
request: {
url: string;
method: string;
headers: Record<string, string>;
body: string | null;
};
}
/** A JSON-RPC message from an injected script inside the sandbox. */
export interface SandboxScriptMessageEvent {
/** The sandbox ID this message came from. */
id: string;
/** The JSON-RPC message body. */
message: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Plugin interface
// ---------------------------------------------------------------------------
export interface SandboxPluginInterface {
/** Create a new sandbox WebView with a unique custom URL scheme. */
create(options: SandboxCreateOptions): Promise<void>;
/** Update the position/size of an existing sandbox WebView. */
updateFrame(options: SandboxUpdateFrameOptions): Promise<void>;
/** Send a fetch response back to the native WebView for a pending request. */
respondToFetch(options: SandboxRespondToFetchOptions): Promise<void>;
/** Post a JSON-RPC message to injected scripts inside the sandbox. */
postMessage(options: SandboxPostMessageOptions): Promise<void>;
/** Destroy a sandbox WebView and clean up all resources. */
destroy(options: SandboxDestroyOptions): Promise<void>;
/** Listen for fetch requests from the native WebView. */
addListener(
eventName: 'fetch',
handler: (event: SandboxFetchEvent) => void,
): Promise<PluginListenerHandle>;
/** Listen for JSON-RPC messages from injected scripts inside the sandbox. */
addListener(
eventName: 'scriptMessage',
handler: (event: SandboxScriptMessageEvent) => void,
): Promise<PluginListenerHandle>;
}
// ---------------------------------------------------------------------------
// Plugin registration
// ---------------------------------------------------------------------------
/**
* The SandboxPlugin Capacitor plugin.
* Only usable on native platforms (iOS/Android). On web, SandboxFrame
* falls back to the iframe.diy service worker sandbox.
*/
export const SandboxPlugin = registerPlugin<SandboxPluginInterface>('SandboxPlugin');
-3
View File
@@ -245,8 +245,6 @@ export const AppConfigSchema = z.object({
})
).optional().default([]),
imageQuality: z.enum(['compressed', 'original']),
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
sandboxDomain: z.string().optional(),
});
// ─── DittoConfigSchema (build-time ditto.json) ───────────────────────
@@ -302,7 +300,6 @@ export const EncryptedSettingsSchema = z.looseObject({
contentFilters: z.array(ContentFilterSchema).optional(),
contentWarningPolicy: ContentWarningPolicySchema.optional(),
notificationsEnabled: z.boolean().optional(),
notificationStyle: z.enum(['push', 'persistent']).optional(),
notificationsCursor: z.number().optional(),
notificationPreferences: z.object({
reactions: z.boolean().optional(),
+71 -55
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, HeartHandshake, Package, Target, Droplets, Heart, Zap } from 'lucide-react';
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, HeartHandshake, Package, Target, Droplets, Heart, Zap, Clock } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
@@ -87,6 +87,7 @@ import {
// DailyMissionsPanel no longer used — daily missions rendered inline in MissionsTabContent
import { BlobbiOnboardingFlow } from '@/blobbi/onboarding';
import { useBlobbiActionsRegistration, type UseItemFunction } from '@/blobbi/companion/interaction';
import { useItemCooldown } from '@/blobbi/actions/hooks/useItemCooldown';
import { BlobbiDevEditor, useBlobbiDevUpdate, type BlobbiDevUpdates, BlobbiEmotionPanel, useEffectiveEmotion, isLocalhostDev } from '@/blobbi/dev';
import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
@@ -192,9 +193,26 @@ function BlobbiContent() {
invalidateProfile,
});
// STEP 1: Fetch ALL the user's Blobbi events from relays (author is source of truth).
// No dList needed — useBlobbisCollection() without args queries by author + ecosystem tag.
// This ensures blobbis are never invisible due to a stale profile.has[] list.
// STEP 1: Build dList from profile.has[] + currentCompanion
const dList = useMemo(() => {
if (!profile) return undefined;
// Build unique list: profile.has[] + currentCompanion (if not already in list)
const allDs = new Set<string>(profile.has);
if (profile.currentCompanion && !allDs.has(profile.currentCompanion)) {
allDs.add(profile.currentCompanion);
}
const result = Array.from(allDs);
if (DEBUG_BLOBBI) {
console.log('[Blobbi] dList:', result);
}
return result.length > 0 ? result : undefined;
}, [profile]);
// STEP 2 & 3: Fetch ALL Blobbi pets (with chunking in the hook)
const {
companionsByD,
companions,
@@ -202,7 +220,7 @@ function BlobbiContent() {
isFetching: collectionFetching,
invalidate: invalidateCollection,
updateCompanionEvent,
} = useBlobbisCollection();
} = useBlobbisCollection(dList);
// STEP 5: localStorage for UI selection (user-scoped key)
const localStorageKey = user?.pubkey ? getSelectedBlobbiKey(user.pubkey) : 'blobbi:selected:d:none';
@@ -213,13 +231,14 @@ function BlobbiContent() {
// STEP 6: Selection Priority
// 1) localStorage selection (if valid and exists in collection) - USER SELECTION ALWAYS WINS
// 2) first item from profile.has that exists in companionsByD - preferred ordering
// 3) first companion in the collection (covers blobbis missing from profile.has)
// 4) undefined (show selector)
// 2) first item from profile.has that exists in companionsByD - DEFAULT ONLY, never persisted
// 3) undefined (show selector)
//
// CRITICAL: Default selection must NEVER overwrite localStorage.
// User selection persists only via handleSelectBlobbi, not via this computed value.
const selectedD = useMemo(() => {
if (!profile) return undefined;
// Priority 1: localStorage selection (if it exists in loaded collection)
// USER SELECTION ALWAYS WINS - this is the authoritative source
if (storedSelectedD && companionsByD[storedSelectedD]) {
@@ -230,35 +249,24 @@ function BlobbiContent() {
}
// Priority 2: First item from profile.has that exists in companionsByD
// This preserves the user's ordering preference from their profile
if (profile) {
for (const d of profile.has) {
if (companionsByD[d]) {
if (DEBUG_BLOBBI) {
console.log('[BlobbiPage] selectedD: using default from profile.has:', d,
'(storedSelectedD was:', storedSelectedD,
storedSelectedD ? (companionsByD[storedSelectedD] ? 'exists' : 'NOT in companionsByD') : 'null', ')');
}
return d;
// This is a DEFAULT - it should NOT be persisted to localStorage
for (const d of profile.has) {
if (companionsByD[d]) {
if (DEBUG_BLOBBI) {
console.log('[BlobbiPage] selectedD: using default from profile.has:', d,
'(storedSelectedD was:', storedSelectedD,
storedSelectedD ? (companionsByD[storedSelectedD] ? 'exists' : 'NOT in companionsByD') : 'null', ')');
}
return d;
}
}
// Priority 3: First companion in the collection (covers blobbis not in profile.has)
if (companions.length > 0) {
const firstD = companions[0].d;
if (DEBUG_BLOBBI) {
console.log('[BlobbiPage] selectedD: using first companion from collection:', firstD);
}
return firstD;
}
// Priority 4: No valid selection
// Priority 3: No valid selection
if (DEBUG_BLOBBI) {
console.log('[BlobbiPage] selectedD: no valid selection available');
}
return undefined;
}, [profile, storedSelectedD, companionsByD, companions]);
}, [profile, storedSelectedD, companionsByD]);
// NOTE: We intentionally do NOT auto-save the computed selectedD to localStorage.
// This prevents the default selection from overwriting user selections during:
@@ -285,6 +293,7 @@ function BlobbiContent() {
}, [selectedD, companion]);
// Combine loading/fetching states
const companionLoading = collectionLoading;
const companionFetching = collectionFetching;
const invalidateCompanion = invalidateCollection;
@@ -482,13 +491,14 @@ function BlobbiContent() {
const pageState = useMemo(() => {
if (profileLoading) return 'loading-profile';
if (!profile) return 'no-profile';
if (collectionLoading) return 'loading-companions';
if (collectionFetching && companions.length === 0) return 'fetching-companions';
if (companions.length === 0) return 'no-pets';
if (!dList || dList.length === 0) return 'profile-no-pets';
if (companionLoading) return 'loading-companions';
if (companionFetching && companions.length === 0) return 'fetching-companions';
if (companions.length === 0) return 'pets-not-found';
if (!selectedD) return 'no-selection';
if (!companion) return 'companion-not-resolved';
return 'dashboard';
}, [profileLoading, profile, collectionLoading, collectionFetching, companions.length, selectedD, companion]);
}, [profileLoading, profile, dList, companionLoading, companionFetching, companions.length, selectedD, companion]);
// Debug log page state decisions
if (DEBUG_BLOBBI) {
@@ -498,8 +508,9 @@ function BlobbiContent() {
hasProfile: !!profile,
profileName: profile?.name,
profileHas: profile?.has?.length ?? 0,
collectionLoading,
collectionFetching,
dListLength: dList?.length ?? 0,
companionLoading,
companionFetching,
companionsLoaded: companions.length,
selectedD,
hasCompanion: !!companion,
@@ -518,11 +529,11 @@ function BlobbiContent() {
//
// Ceremony decision tree:
// 1. No profile → ceremony (brand new user, creates profile + egg)
// 2. Profile exists but no blobbis found on relays → ceremony (creates egg)
// 3. Profile with blobbis → inspect companion stages, then:
// 2. Profile with no pets (empty has[]) → ceremony (creates egg)
// 3. Profile with pets → wait for companions to load, then:
// a. Any baby/adult exists → skip ceremony (dashboard)
// b. Only eggs exist → ceremony with existingCompanion (reuses egg)
// c. No companions resolved → ceremony (creates egg)
// c. No companions resolved (stale refs) → ceremony (creates egg)
const [ceremonyInProgress, setCeremonyInProgress] = useState(false);
// Set to true once the companion-stage check has resolved so it doesn't
// re-run on every render as companion data updates.
@@ -532,14 +543,14 @@ function BlobbiContent() {
const ceremonyEggRef = useRef<BlobbiCompanion | null>(null);
// Cases that definitely need ceremony (no need to wait for companions)
const definitelyNeedsCeremony = !profile;
// Whether we've finished loading enough data to make the decision
const companionDataReady = !collectionLoading && (!collectionFetching || companions.length > 0);
const definitelyNeedsCeremony = !profile || !dList || dList.length === 0;
// Cases where we must inspect actual companion stages before deciding.
// This fires for ALL users with a profile — regardless of onboardingDone —
// This fires for ALL users with pets — regardless of onboardingDone —
// so that accounts with onboardingDone=true but only eggs still get
// the ceremony.
const pendingCeremonyCheck = !definitelyNeedsCeremony && !!profile && !ceremonyCheckDone;
// Whether we've finished loading enough data to make the decision
const companionDataReady = !companionLoading && (!companionFetching || companions.length > 0);
// Auto-start ceremony for definite cases (no profile / no pets)
useEffect(() => {
@@ -583,8 +594,8 @@ function BlobbiContent() {
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Starting ceremony with existing egg:', egg.d);
setCeremonyInProgress(true);
} else {
// No blobbi events found on relays — treat as new user
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Starting ceremony: no companions found');
// Profile has pet d-tags but none resolved (stale references) — treat as new user
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Starting ceremony: no companions resolved');
setCeremonyInProgress(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -626,19 +637,19 @@ function BlobbiContent() {
);
}
// After ceremony check, profile must exist
if (!profile) {
// After ceremony check, profile and dList must exist
if (!profile || !dList || dList.length === 0) {
return <DashboardLoadingState />;
}
// ─── CASE D: Companions still loading ───
if (collectionLoading) {
// ─── CASE D: Profile has pet references, but companions still loading ───
if (companionLoading) {
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: loading companions');
return <DashboardLoadingState />;
}
// ─── CASE E: Companions not yet resolved (fetching) ───
if (collectionFetching && companions.length === 0) {
// ─── CASE E: Profile has pet references, but companions not yet resolved (fetching) ───
if (companionFetching && companions.length === 0) {
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: syncing pets from relays');
return (
<DashboardShell>
@@ -657,8 +668,8 @@ function BlobbiContent() {
);
}
// ─── CASE F: No blobbi events found on relays ───
// This shouldn't normally happen after the ceremony check, but handle gracefully
// ─── CASE F: Profile has pet references, but pets not found on relays ───
// This is a data sync issue - show error state, NOT onboarding
if (companions.length === 0) {
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: pets not found error');
return (
@@ -670,7 +681,7 @@ function BlobbiContent() {
</div>
<h1 className="text-2xl font-bold">Pet Data Not Found</h1>
<p className="text-muted-foreground">
No Blobbi data could be loaded from relays.
Your profile references {dList.length} pet(s), but the data couldn't be loaded from relays.
This may be a sync issue - try refreshing the page.
</p>
<Button
@@ -1962,20 +1973,24 @@ function ItemsTabContent({
isUsingItem,
usingItemId,
}: ItemsTabContentProps) {
const { isOnCooldown } = useItemCooldown();
return (
<div className="grid grid-cols-4 sm:grid-cols-5 gap-0.5">
{allShopItems.filter(i => i.status !== 'disabled').map((item) => {
const isThisUsing = isUsingItem && usingItemId === item.id;
const isCoolingDown = isOnCooldown(item.id);
const isDisabled = isUsingItem || isCoolingDown;
return (
<button
key={item.id}
onClick={() => onUseItem(item.id)}
disabled={isUsingItem}
disabled={isDisabled}
className={cn(
'group relative flex flex-col items-center justify-center gap-0.5 py-3 rounded-2xl transition-all duration-200',
'hover:bg-accent/50 hover:-translate-y-0.5 active:scale-[0.93] active:translate-y-0',
isThisUsing && 'bg-accent/40 -translate-y-0.5',
isUsingItem && !isThisUsing && 'opacity-40 pointer-events-none',
isDisabled && !isThisUsing && 'opacity-40 pointer-events-none',
)}
>
{/* Stat category indicator — top-right */}
@@ -1985,6 +2000,7 @@ function ItemsTabContent({
<span className="text-4xl leading-none transition-transform duration-200 group-hover:scale-110">{item.icon}</span>
<span className="text-[10px] text-muted-foreground font-medium truncate w-full text-center px-1">{item.name}</span>
{isThisUsing && <Loader2 className="size-3 animate-spin text-primary absolute bottom-1" />}
{isCoolingDown && !isThisUsing && <Clock className="size-3 text-muted-foreground absolute bottom-1" />}
</button>
);
})}
+11 -316
View File
@@ -1,9 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useInView } from 'react-intersection-observer';
import { nip19 } from 'nostr-tools';
import { UserPlus, Loader2, CheckCircle2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@@ -12,30 +11,21 @@ import { NoteCard } from '@/components/NoteCard';
import { getAvatarShape, isEmoji, emojiAvatarBorderStyle } from '@/lib/avatarShape';
import { cn } from '@/lib/utils';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
import { useToast } from '@/hooks/useToast';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useProfileFeed, filterByTab } from '@/hooks/useProfileFeed';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { parsePackEvent } from '@/lib/packUtils';
import { PackFeedTab, MemberCard, MemberCardSkeleton } from '@/components/FollowPackDetailContent';
import { genUserName } from '@/lib/genUserName';
import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { DittoLogo } from '@/components/DittoLogo';
import { Nip05Badge } from '@/components/Nip05Badge';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme';
import { useOnboarding } from '@/hooks/useOnboarding';
import { buildThemeCssFromCore } from '@/themes';
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
import LoginDialog from '@/components/auth/LoginDialog';
import type { FeedItem } from '@/lib/feedUtils';
import type { AddressPointer } from 'nostr-tools/nip19';
import NotFound from './NotFound';
// ---------------------------------------------------------------------------
@@ -347,323 +337,28 @@ function FollowView({ pubkey }: { pubkey: string }) {
);
}
// ---------------------------------------------------------------------------
// Immersive follow pack/set view
// ---------------------------------------------------------------------------
type PackTab = 'feed' | 'members';
function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[] }) {
const { data: event, isLoading: eventLoading } = useAddrEvent(addr, relays);
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { data: followList } = useFollowList();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const navigate = useNavigate();
const { startSignup } = useOnboarding();
const [loginOpen, setLoginOpen] = useState(false);
const [activeTab, setActiveTab] = useState<PackTab>('feed');
const [isFollowingAll, setIsFollowingAll] = useState(false);
const author = useAuthor(addr.pubkey);
const authorMeta = author.data?.metadata;
const authorName = authorMeta?.name || genUserName(addr.pubkey);
const { title, description, image, pubkeys } = useMemo(
() => (event ? parsePackEvent(event) : { title: 'Loading...', description: '', image: undefined, pubkeys: [] }),
[event],
);
const { data: membersMap, isLoading: membersLoading } = useAuthors(pubkeys);
const followedPubkeys = useMemo(() => new Set(followList?.pubkeys ?? []), [followList]);
const followingCount = useMemo(
() => pubkeys.filter((pk) => followedPubkeys.has(pk)).length,
[pubkeys, followedPubkeys],
);
const allFollowed = pubkeys.length > 0 && followingCount === pubkeys.length;
const newCount = pubkeys.length - followingCount;
const bannerUrl = image || authorMeta?.banner;
/** Follow All using fetch-fresh -> modify -> publish pattern. */
const handleFollowAll = useCallback(async () => {
if (!user) return;
setIsFollowingAll(true);
try {
// 1. Fetch freshest kind 3 from relays (not cache)
const prev = await fetchFreshEvent(nostr, {
kinds: [3],
authors: [user.pubkey],
});
// 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));
// 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: prev?.content ?? '',
tags: [...nonPTags, ...existingPTags, ...newPTags],
prev: prev ?? undefined,
});
toast({
title: allFollowed ? 'Already following all!' : 'Following all!',
description: added > 0
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
: 'You were already following everyone in this pack.',
});
} catch (error) {
console.error('Failed to follow all:', error);
toast({
title: 'Failed to follow',
description: 'There was an error updating your follow list.',
variant: 'destructive',
});
} finally {
setIsFollowingAll(false);
}
}, [user, pubkeys, nostr, publishEvent, toast, allFollowed]);
if (eventLoading) {
return (
<div className="h-dvh flex flex-col bg-background/85">
<div className="shrink-0">
<div className="h-36 md:h-48 bg-secondary relative">
<Skeleton className="w-full h-full rounded-none" />
<Link to="/" className="absolute top-3 left-3">
<div className="bg-background/85 rounded-full">
<DittoLogo size={48} />
</div>
</Link>
</div>
<div className="bg-background/85">
<div className="flex flex-col items-center px-4 pt-6 pb-6 max-w-2xl mx-auto w-full space-y-3">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-64" />
<Skeleton className="h-10 w-56 rounded-full" />
</div>
</div>
</div>
</div>
);
}
if (!event) return <NotFound />;
return (
<div className="h-dvh flex flex-col bg-background/85">
{/* Header (not scrollable) */}
<div className="shrink-0">
{/* Banner */}
<div className="h-36 md:h-48 bg-secondary relative">
{bannerUrl ? (
<img src={bannerUrl} alt="" className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />
)}
<Link to="/" className="absolute top-3 left-3">
<div className="bg-background/85 rounded-full">
<DittoLogo size={48} />
</div>
</Link>
</div>
{/* Pack info */}
<div className="bg-background/85">
<div className="flex flex-col items-center px-4 -mt-6 pb-4 relative z-10 max-w-2xl mx-auto w-full">
{/* Avatar stack (first 5 members) */}
<div className="flex -space-x-3 mb-3">
{pubkeys.slice(0, 5).map((pk) => {
const member = membersMap?.get(pk);
const name = member?.metadata?.name || genUserName(pk);
const shape = getAvatarShape(member?.metadata);
return (
<Avatar key={pk} shape={shape} className="size-12 border-2 border-background shadow-md">
<AvatarImage src={member?.metadata?.picture} alt={name} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
);
})}
{pubkeys.length > 5 && (
<div className="size-12 rounded-full border-2 border-background bg-secondary flex items-center justify-center shadow-md">
<span className="text-xs font-medium text-muted-foreground">+{pubkeys.length - 5}</span>
</div>
)}
</div>
{/* Title */}
<h1 className="text-xl font-bold text-foreground text-center">{title}</h1>
{/* Author attribution */}
<Link to={`/${nip19.npubEncode(addr.pubkey)}`} className="flex items-center gap-1.5 mt-1.5 hover:underline">
<Avatar shape={getAvatarShape(authorMeta)} className="size-5">
<AvatarImage src={authorMeta?.picture} alt={authorName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{authorName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">by {authorName}</span>
</Link>
{/* Description */}
{description && (
<p className="text-sm text-muted-foreground text-center mt-2 max-w-sm whitespace-pre-wrap">
{description}
</p>
)}
{/* Big CTA button */}
<div className="mt-4 w-full max-w-xs">
{!user ? (
<Button
onClick={() => setLoginOpen(true)}
className="w-full rounded-full py-3 text-base font-semibold gap-2"
size="lg"
>
<UserPlus className="size-5" />
Follow {pubkeys.length} people on Ditto
</Button>
) : isFollowingAll ? (
<Button disabled className="w-full rounded-full py-3 text-base font-semibold gap-2" size="lg">
<Loader2 className="size-5 animate-spin" />
Following...
</Button>
) : allFollowed ? (
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-2 text-primary">
<CheckCircle2 className="size-5" />
<p className="font-semibold">Following all {pubkeys.length} people</p>
</div>
<Button variant="secondary" className="rounded-full w-full" onClick={() => navigate('/feed')}>
Go to feed
</Button>
</div>
) : (
<div className="space-y-2">
<Button
onClick={handleFollowAll}
className="w-full rounded-full py-3 text-base font-semibold gap-2"
size="lg"
>
<UserPlus className="size-5" />
Follow All ({pubkeys.length})
</Button>
{followingCount > 0 && (
<p className="text-center text-sm text-muted-foreground">
Already following {followingCount} of {pubkeys.length}
{' '}&middot;{' '}
<span className="text-green-600 dark:text-green-400">{newCount} new</span>
</p>
)}
</div>
)}
</div>
</div>
</div>
</div>
{/* Tab bar */}
<SubHeaderBar className="shrink-0" innerClassName="max-w-2xl mx-auto">
<TabButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
<TabButton label={`Members (${pubkeys.length})`} active={activeTab === 'members'} onClick={() => setActiveTab('members')} />
</SubHeaderBar>
{/* Tab content (scrollable) */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="max-w-2xl mx-auto w-full bg-background/85" style={{ paddingTop: ARC_OVERHANG_PX }}>
{activeTab === 'feed' ? (
<PackFeedTab pubkeys={pubkeys} />
) : membersLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => (
<MemberCardSkeleton key={i} />
))}
</div>
) : (
<div className="divide-y divide-border">
{pubkeys.map((pk) => {
const member = membersMap?.get(pk);
const isFollowed = followedPubkeys.has(pk);
return (
<MemberCard
key={pk}
pubkey={pk}
metadata={member?.metadata}
isFollowed={isFollowed}
isSelf={pk === user?.pubkey}
/>
);
})}
</div>
)}
</div>
</div>
<LoginDialog
isOpen={loginOpen}
onClose={() => setLoginOpen(false)}
onLogin={() => setLoginOpen(false)}
onSignupClick={startSignup}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Route component
// ---------------------------------------------------------------------------
/** Kinds accepted as follow packs/sets at /follow URLs. */
const FOLLOW_PACK_SET_KINDS = new Set([30000, 39089]);
export function FollowPage() {
const { npub } = useParams<{ npub: string }>();
if (!npub) return <NotFound />;
// Try decoding as a NIP-19 identifier
let decoded;
let pubkey: string;
try {
decoded = nip19.decode(npub);
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
pubkey = decoded.data;
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey;
} else {
return <NotFound />;
}
} catch {
return <NotFound />;
}
// Handle npub / nprofile -> individual user follow view
if (decoded.type === 'npub') {
return <FollowView pubkey={decoded.data} />;
}
if (decoded.type === 'nprofile') {
return <FollowView pubkey={decoded.data.pubkey} />;
}
// Handle naddr -> follow pack/set view
if (decoded.type === 'naddr') {
const addr = decoded.data as AddressPointer;
if (!FOLLOW_PACK_SET_KINDS.has(addr.kind)) {
return <NotFound />;
}
return (
<FollowPackView
addr={{ kind: addr.kind, pubkey: addr.pubkey, identifier: addr.identifier }}
relays={addr.relays}
/>
);
}
return <NotFound />;
return <FollowView pubkey={pubkey} />;
}
+1 -55
View File
@@ -1,11 +1,9 @@
import { useState, useEffect, useRef } from 'react';
import { Capacitor } from '@capacitor/core';
import { useSeoMeta } from '@unhead/react';
import { Bell, BellOff, AlertTriangle, Heart, Repeat2, Zap, AtSign, MessageSquare, Users, Award, Mail, Radio, MonitorSmartphone } from 'lucide-react';
import { Bell, BellOff, AlertTriangle, Heart, Repeat2, Zap, AtSign, MessageSquare, Users, Award, Mail } from 'lucide-react';
import { Navigate } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -152,7 +150,6 @@ export function NotificationSettings() {
// Web: toggle reflects actual browser push subscription (from the hook).
// Native: toggle reflects NIP-78 persisted preference.
const [nativePushEnabled, setNativePushEnabled] = useState<boolean>(() => isNative);
const [notificationStyle, setNotificationStyle] = useState<'push' | 'persistent'>('push');
const [prefs, setPrefs] = useState<NonNullable<NonNullable<typeof settings>['notificationPreferences']>>({});
const initializedRef = useRef(false);
@@ -161,7 +158,6 @@ export function NotificationSettings() {
initializedRef.current = true;
if (isNative) {
setNativePushEnabled(settings.notificationsEnabled ?? true);
setNotificationStyle(settings.notificationStyle ?? 'push');
}
setPrefs(settings.notificationPreferences ?? {});
}, [settings, isNative]);
@@ -230,14 +226,6 @@ export function NotificationSettings() {
}
};
const handleStyleChange = (style: 'push' | 'persistent') => {
const prev = notificationStyle;
setNotificationStyle(style);
updateSettings.mutateAsync({ notificationStyle: style }).catch(() => {
setNotificationStyle(prev); // roll back on failure
});
};
const handleToggleOnlyFollowing = (enabled: boolean) => {
const next = { ...prefs, onlyFollowing: enabled };
setPrefs(next);
@@ -300,48 +288,6 @@ export function NotificationSettings() {
)}
</div>
{/* Notification Style — native only, visible when push is enabled */}
{isNative && pushEnabled && (
<>
<SectionHeader title="Delivery Method" />
<div className="pb-4">
<p className="text-xs text-muted-foreground px-3 pt-3 pb-4">
Choose how notifications are delivered to your device.
</p>
<RadioGroup
value={notificationStyle}
onValueChange={(v) => handleStyleChange(v as 'push' | 'persistent')}
className="px-3 space-y-3"
>
<div className="flex items-start gap-3">
<RadioGroupItem value="push" id="style-push" className="mt-0.5" />
<Label htmlFor="style-push" className="flex-1 cursor-pointer">
<div className="flex items-center gap-2">
<Radio className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">Push</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Standard notifications. No persistent status bar icon.
</p>
</Label>
</div>
<div className="flex items-start gap-3">
<RadioGroupItem value="persistent" id="style-persistent" className="mt-0.5" />
<Label htmlFor="style-persistent" className="flex-1 cursor-pointer">
<div className="flex items-center gap-2">
<MonitorSmartphone className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">Persistent</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Background service polls relays directly. Shows a persistent notification. Use this on devices that don't support push notifications (e.g. GrapheneOS).
</p>
</Label>
</div>
</RadioGroup>
</div>
</>
)}
{/* Filter + Notify Me About — one continuous block */}
<SectionHeader title="Notify Me About" />
<div className="pb-4">
+1 -13
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import { useInView } from 'react-intersection-observer';
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -167,7 +167,6 @@ interface ProfileMoreMenuProps {
function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile, authorEvent }: ProfileMoreMenuProps) {
const { toast } = useToast();
const { user } = useCurrentUser();
const navigate = useNavigate();
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
const { addMute, removeMute, isMuted } = useMuteList();
const userMuted = isMuted('pubkey', pubkey);
@@ -233,10 +232,6 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
const handleRecovery = () => openAfterClose(setRecoveryOpen);
const handleGiveBadge = () => openAfterClose(setGiveBadgeOpen);
const handleWriteLetter = () => {
close();
navigate(`/letters/compose?to=${npubEncoded}`);
};
const handleZap = () => {
close();
setTimeout(() => zapTriggerRef.current?.click(), 150);
@@ -309,13 +304,6 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
onClick={handleGiveBadge}
/>
)}
{user && (
<MenuRow
icon={<Mail className="size-5" />}
label="Write a letter"
onClick={handleWriteLetter}
/>
)}
<MenuRow
icon={<VolumeX className="size-5" />}
label={userMuted ? `Unmute @${displayName}` : `Mute @${displayName}`}
-1
View File
@@ -111,7 +111,6 @@ export function TestApp({ children }: TestAppProps) {
plausibleEndpoint: "",
savedFeeds: [],
imageQuality: 'compressed',
sandboxDomain: 'iframe.diy',
};
return (
+2 -5
View File
@@ -6,7 +6,7 @@ import path from "node:path";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig, loadEnv, type Plugin } from "vite";
import { defineConfig, type Plugin } from "vite";
import { DittoConfigSchema } from "./src/lib/schemas";
@@ -120,14 +120,11 @@ function getCommitTag(): string {
}
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
export default defineConfig(() => {
return {
server: {
host: "::",
port: 8080,
allowedHosts: env.ALLOWED_HOSTS === "*" ? true : undefined,
},
plugins: [
react(),