Compare commits

..

1 Commits

Author SHA1 Message Date
Derek Ross 7d474a0817 Fix Android WebView stuck at half-screen height
The @capacitor/keyboard plugin's possiblyResizeChildOfContent() sets the
CoordinatorLayout's height to a fixed pixel value when the keyboard
appears. On dismiss, an animation callback resets it to MATCH_PARENT.
However, on Android 15+ (API 35+) with edge-to-edge enforced, insets
can change without a keyboard animation (permission dialogs, config
changes, edge-to-edge recalculations), causing the height to be set
but never reset — leaving the WebView stuck at roughly half height.

Add an OnApplyWindowInsetsListener on the ContentFrameLayout that
resets the CoordinatorLayout's height to MATCH_PARENT whenever the
IME is not visible. This fires in the same insets dispatch pass as
the Keyboard plugin's rootView listener, preventing stale heights
from persisting. The listener is placed on the ContentFrameLayout
(not the CoordinatorLayout) to avoid overwriting the SystemBars
plugin's listener.

Closes #220
2026-04-12 13:23:18 -04:00
39 changed files with 1477 additions and 2477 deletions
+6 -10
View File
@@ -283,20 +283,16 @@ When adding support for a new Nostr event kind to the application, the kind must
3. **Detail page** (`src/pages/PostDetailPage.tsx`):
- Add the same `isMyKind` detection flag and include it in the group/exclusion flags (mirrors NoteCard)
- Add the content dispatch for the detail view
- `shellTitleForKind()` falls through to the central `KIND_LABELS` registry, so adding a label there is sufficient for the loading state title. Only add a manual override in `shellTitleForKind()` if the kind belongs to a group (e.g. music kinds → "Track Details") or needs a composite label (e.g. "Badge Collection")
- Add an entry in `shellTitleForKind()` for the loading state title
- Import the new component
4. **Feed registration** (`src/lib/extraKinds.ts`):
- Add the kind number to an existing feed definition's `extraFeedKinds` array, or create a new `ExtraKindDef` entry
5. **Central kind label registry** (`src/lib/kindLabels.ts`):
- Add an entry to the `KIND_LABELS` map with a short, user-facing label (capitalized noun phrase, no articles)
- This registry is the single source of truth for kind→label mappings and is consumed by the nsite permission prompt, signer nudge toasts, detail page loading titles, and addressable event preview headers
- Some UI contexts maintain **context-specific** label maps that cannot use the central registry directly (they need different grammar):
- `KIND_LABELS` and `KIND_ICONS` in `src/components/CommentContext.tsx` -- uses articles ("a post", "an article") for "Commenting on {label}" text
- `NOTIFICATION_KIND_NOUNS` in `src/pages/NotificationsPage.tsx` -- uses bare lowercase nouns for notification action text
- `KIND_HEADER_MAP` in `src/components/NoteCard.tsx` -- uses action verbs + nouns for feed headers
- These context-specific maps must also be updated when adding a new kind
5. **Kind label registries** -- these are separate maps that resolve kind numbers to human-readable strings. All must be updated:
- `KIND_LABELS` and `KIND_ICONS` in `src/components/CommentContext.tsx` -- used for "Commenting on an nsite" text and inline icons
- `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` -- used in addressable event preview headers
- The icon fallback in `AddressableEventPreview` in the same file
6. **Embedded note cards** (`src/components/EmbeddedNote.tsx`, `src/components/EmbeddedNaddr.tsx`) -- these are the small preview cards shown inside quote posts, reply context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal card (author + title/content preview + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags rather than in the `content` field (e.g. kind 20 photos via `imeta` tags) may need attachment indicator logic added to `EmbeddedNoteCard`.
@@ -307,7 +303,7 @@ When adding support for a new Nostr event kind to the application, the kind must
#### Why so many places?
These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. The central `KIND_LABELS` in `src/lib/kindLabels.ts` handles the common case, but several contexts need grammar-specific maps (articles, verbs, lowercase nouns) that can't be derived mechanically. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
### NIP.md
-8
View File
@@ -1,13 +1,5 @@
# Changelog
## [2.6.6] - 2026-04-12
### Fixed
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
- Emoji shortcodes now render as color emoji instead of plain text glyphs
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
- Signing requests on Android are more reliable and no longer silently fail after switching apps
## [2.6.5] - 2026-04-11
### Changed
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.6.6"
versionName "2.6.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -8,6 +8,13 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.getcapacitor.BridgeActivity;
@@ -23,6 +30,45 @@ public class MainActivity extends BridgeActivity {
super.onCreate(savedInstanceState);
// Workaround for @capacitor/keyboard plugin intermittently leaving
// the CoordinatorLayout at a fixed pixel height on Android 15+
// (API 35+) with edge-to-edge enforced.
//
// The Keyboard plugin's possiblyResizeChildOfContent() sets the
// CoordinatorLayout's LayoutParams.height to a computed pixel value
// when the keyboard appears. On keyboard dismiss, the animation
// callback resets it to MATCH_PARENT. However, when insets change
// without a keyboard animation (permission dialogs, config changes,
// edge-to-edge recalculations), the plugin's rootView insets
// listener fires with showingKeyboard=true and sets the height,
// but no animation runs to reset it — leaving the WebView stuck
// at roughly half height.
//
// Fix: set an OnApplyWindowInsetsListener on the CoordinatorLayout
// itself. This fires AFTER the Keyboard plugin's listener on the
// rootView (parent dispatches to children). When the IME is not
// visible, we force the height back to MATCH_PARENT, overriding
// any stale value the plugin may have set in the same dispatch.
FrameLayout content = getWindow().getDecorView().findViewById(android.R.id.content);
if (content != null && content.getChildCount() > 0) {
View child = content.getChildAt(0);
// Set the listener on the ContentFrameLayout (parent of the
// CoordinatorLayout) so it fires after the Keyboard plugin's
// rootView listener but before the SystemBars plugin's listener
// on the CoordinatorLayout — avoiding overwriting either one.
ViewCompat.setOnApplyWindowInsetsListener(content, (@NonNull View v, @NonNull WindowInsetsCompat insets) -> {
boolean imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime());
if (!imeVisible) {
ViewGroup.LayoutParams lp = child.getLayoutParams();
if (lp.height >= 0) {
lp.height = ViewGroup.LayoutParams.MATCH_PARENT;
child.requestLayout();
}
}
return insets;
});
}
// 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);
@@ -1,19 +1,31 @@
package pub.ditto.app;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import android.view.Gravity;
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 android.widget.ProgressBar;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient;
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;
@@ -26,37 +38,140 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Capacitor plugin that intercepts requests from sandbox iframes in the
* main Capacitor WebView.
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
*
* On Android, each sandbox iframe loads from
* {@code https://<sandbox-id>.sandbox.native/path}. A custom
* {@link BridgeWebViewClient} subclass intercepts these requests via
* {@code shouldInterceptRequest}, forwards them to the JS layer as "fetch"
* events, and blocks the WebView IO thread until JS responds with
* {@code respondToFetch()}.
*
* Each unique hostname is a different web origin, so localStorage / IndexedDB
* are fully isolated per sandbox — no separate WebView instances needed.
* 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());
/** Pending requests waiting for JS to respond. */
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
@PluginMethod
public void create(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
@Override
public void load() {
// Replace the main WebView's client with our subclass that intercepts
// sandbox iframe requests.
Bridge bridge = getBridge();
bridge.setWebViewClient(new SandboxBridgeWebViewClient(bridge, this));
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 container (WebView + spinner overlay) on top of the
// Capacitor WebView. The parent is a CoordinatorLayout — using
// the wrong LayoutParams type causes a ClassCastException when
// it intercepts touch events.
View capWebView = getBridge().getWebView();
ViewGroup parent = (ViewGroup) capWebView.getParent();
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
parent.addView(sandbox.container, params);
// The spinner is now visible. Navigation is deferred until the
// JS layer calls navigate() — this allows the caller to
// pre-fetch blobs while the spinner animates.
call.resolve();
});
}
@PluginMethod
public void navigate(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
call.resolve();
});
}
@PluginMethod
public void updateFrame(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject frame = call.getObject("frame");
if (frame == null) {
call.reject("Missing required parameter: frame");
return;
}
int x = frame.optInt("x", 0);
int y = frame.optInt("y", 0);
int width = frame.optInt("width", 0);
int height = frame.optInt("height", 0);
float density = getActivity().getResources().getDisplayMetrics().density;
int pxX = Math.round(x * density);
int pxY = Math.round(y * density);
int pxWidth = Math.round(width * density);
int pxHeight = Math.round(height * density);
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
sandbox.container.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");
@@ -68,6 +183,12 @@ public class SandboxPlugin extends Plugin {
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);
@@ -81,36 +202,56 @@ public class SandboxPlugin extends Plugin {
}
}
PendingRequest pending = pendingRequests.remove(requestId);
if (pending == null) {
call.resolve();
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;
}
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);
}
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
String encoding = contentType.contains("text/") ? "UTF-8" : null;
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
InputStream body = bodyBytes != null
? new ByteArrayInputStream(bodyBytes)
: new ByteArrayInputStream(new byte[0]);
WebResourceResponse webResponse = new WebResourceResponse(
contentType, encoding, status, statusText, headers, body
);
pending.resolve(webResponse);
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.container.getParent();
if (parent != null) {
parent.removeView(sandbox.container);
}
sandbox.webView.destroy();
}
call.resolve();
});
}
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
JSObject data = new JSObject();
data.put("id", sandboxId);
@@ -119,48 +260,159 @@ public class SandboxPlugin extends Plugin {
notifyListeners("fetch", data);
}
// -------------------------------------------------------------------------
// Custom BridgeWebViewClient that intercepts sandbox iframe requests
// -------------------------------------------------------------------------
void emitScriptMessage(String sandboxId, JSObject message) {
JSObject data = new JSObject();
data.put("id", sandboxId);
data.put("message", message);
notifyListeners("scriptMessage", data);
}
/**
* Extends Capacitor's BridgeWebViewClient to additionally intercept
* requests from sandbox iframes (URLs matching *.sandbox.native).
* A single sandboxed WebView instance.
*/
private static class SandboxBridgeWebViewClient extends BridgeWebViewClient {
private final SandboxPlugin plugin;
private static class SandboxInstance {
final String id;
/** Wrapper layout that holds the WebView and the loading overlay. */
final FrameLayout container;
final WebView webView;
final SandboxPlugin plugin;
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
/** Native spinner overlay, shown while the sandbox content loads. */
private ProgressBar spinner;
SandboxBridgeWebViewClient(Bridge bridge, SandboxPlugin plugin) {
super(bridge);
SandboxInstance(String id, SandboxPlugin plugin) {
this.id = id;
this.plugin = plugin;
this.container = new FrameLayout(plugin.getActivity());
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.parseColor("#14161f"));
// 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));
// Build the container: WebView fills it, spinner overlays on top.
container.addView(webView, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
// Native spinner overlay — uses the Android indeterminate
// ProgressBar which animates on the render thread, so it keeps
// spinning even when the main/IO threads are busy.
spinner = new ProgressBar(plugin.getActivity());
spinner.setIndeterminate(true);
spinner.getIndeterminateDrawable().setColorFilter(
Color.parseColor("#7c5cdc"), PorterDuff.Mode.SRC_IN);
FrameLayout.LayoutParams spinnerParams = new FrameLayout.LayoutParams(
dpToPx(plugin, 32), dpToPx(plugin, 32), Gravity.CENTER);
container.addView(spinner, spinnerParams);
// Dark background behind the spinner.
View overlay = new View(plugin.getActivity());
overlay.setBackgroundColor(Color.parseColor("#14161f"));
// Insert the overlay between the WebView (index 0) and spinner (index 1)
// so it covers the WebView but sits behind the spinner.
container.addView(overlay, 1, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
}
/** Remove the native loading overlay. Safe to call multiple times. */
void hideSpinner() {
if (spinner != null) {
// Remove spinner and overlay (indices 2 and 1 after WebView at 0).
if (container.getChildCount() > 2) container.removeViewAt(2); // spinner
if (container.getChildCount() > 1) container.removeViewAt(1); // overlay
spinner = null;
}
}
private static int dpToPx(SandboxPlugin plugin, int dp) {
float density = plugin.getActivity().getResources().getDisplayMetrics().density;
return Math.round(dp * density);
}
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 host = request.getUrl().getHost();
String url = request.getUrl().toString();
// Intercept requests to *.sandbox.native (from sandbox iframes).
if (host != null && host.endsWith(".sandbox.native")) {
return handleSandboxRequest(request, host);
// Only intercept requests to the sandbox domain.
if (!url.contains(".sandbox.native")) {
return null;
}
// Everything else: delegate to Capacitor's default handling.
return super.shouldInterceptRequest(view, request);
}
private WebResourceResponse handleSandboxRequest(WebResourceRequest request, String host) {
// Extract sandbox ID from the hostname (e.g. "abc123.sandbox.native" -> "abc123").
String sandboxId = host.replace(".sandbox.native", "");
String requestId = UUID.randomUUID().toString();
PendingRequest pending = new PendingRequest();
plugin.pendingRequests.put(requestId, pending);
// Serialise the request for the JS layer.
// 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://" + sandboxId + ".sandbox.native" + 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());
@@ -173,11 +425,12 @@ public class SandboxPlugin extends Plugin {
serialisedRequest.put("body", JSONObject.NULL);
// Emit to JS.
plugin.emitFetchRequest(sandboxId, requestId, serialisedRequest);
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
// Block until JS responds. The WebView IO thread pool has ~6
// threads; pre-fetching blobs in JS before setting the iframe src
// ensures this blocking time is minimal (cache hits).
// Block until JS responds. Each asset is fetched from a Blossom
// server over the network, so we need a generous timeout. The
// WebView IO thread pool has ~6 threads; if all are blocked,
// subsequent requests queue until a thread frees up.
WebResourceResponse response = pending.awaitResponse(60000);
if (response != null) {
@@ -185,21 +438,98 @@ public class SandboxPlugin extends Plugin {
}
// Timeout — return error response.
plugin.pendingRequests.remove(requestId);
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);
}
// Remove the native spinner once the first page has finished
// loading (all initial resources resolved). This runs on the
// main thread, so the removal is safe.
sandbox.hideSpinner();
}
private String getBridgeScript() {
return "(function() {" +
"'use strict';" +
"var messageListeners = [];" +
"window.__sandboxBridge = {" +
" onMessage: function(data) {" +
" var event = {" +
" data: data," +
" origin: 'https://" + sandbox.id + ".sandbox.native'," +
" source: window.parent," +
" type: 'message'" +
" };" +
" for (var i = 0; i < messageListeners.length; i++) {" +
" try { messageListeners[i](event); } catch(e) {}" +
" }" +
" }" +
"};" +
"var origAdd = window.addEventListener;" +
"window.addEventListener = function(type, fn, opts) {" +
" if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);" +
" return origAdd.call(window, type, fn, opts);" +
"};" +
"var origRemove = window.removeEventListener;" +
"window.removeEventListener = function(type, fn, opts) {" +
" if (type === 'message') {" +
" var idx = messageListeners.indexOf(fn);" +
" if (idx !== -1) messageListeners.splice(idx, 1);" +
" }" +
" return origRemove.call(window, type, fn, opts);" +
"};" +
"if (!window.parent || window.parent === window) window.parent = {};" +
"window.parent.postMessage = function(data) {" +
" if (data && typeof data === 'object' && data.jsonrpc === '2.0') {" +
" try { window.__sandboxNative.postMessage(JSON.stringify(data)); } catch(e) {}" +
" }" +
"};" +
"})();";
}
}
// -------------------------------------------------------------------------
// Pending request helper
// -------------------------------------------------------------------------
/**
* 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 WebView IO thread until JS responds.
* A pending request that blocks the WebViewClient IO thread until JS
* responds with the complete resource.
*/
private static class PendingRequest {
private volatile WebResourceResponse response;
+3
View File
@@ -19,6 +19,9 @@ const config: CapacitorConfig = {
scheme: 'Ditto'
},
plugins: {
Keyboard: {
resizeOnFullScreen: true,
},
SystemBars: {
// Inject --safe-area-inset-* CSS variables on Android to work around
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<title>Ditto — Your content. Your vibe. Your rules.</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
<!-- Open Graph -->
+2 -2
View File
@@ -319,7 +319,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.6;
MARKETING_VERSION = 2.6.5;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -343,7 +343,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.6;
MARKETING_VERSION = 2.6.5;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -1,22 +1,7 @@
import UIKit
import WebKit
import Capacitor
class DittoBridgeViewController: CAPBridgeViewController {
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
let config = super.webViewConfiguration(for: instanceConfiguration)
// Register the sbx:// custom scheme handler BEFORE the WKWebView is
// created. Each sandbox iframe loads from sbx://<sandbox-id>/path,
// giving every sandbox a unique web origin with full storage isolation.
let handler = SandboxRequestHandler()
_sandboxHandler = handler
config.setURLSchemeHandler(handler, forURLScheme: "sbx")
return config
}
override func capacitorDidLoad() {
super.capacitorDidLoad()
webView?.allowsBackForwardNavigationGestures = true
+429 -118
View File
@@ -2,48 +2,432 @@ import Foundation
import Capacitor
import WebKit
// MARK: - Shared Handler Singleton
// MARK: - Plugin
/// The sandbox request handler singleton.
/// Created by `DittoBridgeViewController` at WKWebView configuration time,
/// then connected to the `SandboxPlugin` when the plugin loads.
var _sandboxHandler: SandboxRequestHandler?
// MARK: - Sandbox Scheme Handler
/// `WKURLSchemeHandler` for the `sbx://` custom scheme.
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
///
/// Each sandbox iframe loads from `sbx://<sandbox-id>/path`, giving every
/// sandbox a unique web origin with full localStorage / IndexedDB / cookie
/// isolation.
///
/// Intercepted requests are forwarded to the JS layer via the Capacitor
/// plugin bridge. JS resolves the file and responds with `respondToFetch()`.
class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
/// 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: "navigate", 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 container (WebView + spinner overlay) on top of
// the Capacitor WebView.
if let bridge = self.bridge,
let webView = bridge.webView {
webView.superview?.addSubview(sandbox.containerView)
}
call.resolve()
}
}
@objc func navigate(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
DispatchQueue.main.async { [weak self] in
guard let sandbox = self?.sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.navigateToApp()
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.containerView.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.containerView.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, WKNavigationDelegate {
let id: String
let webView: WKWebView
let schemeHandler: SandboxSchemeHandler
private weak var plugin: SandboxPlugin?
private let customScheme: String
/// Container view that holds the WebView and spinner overlay.
let containerView: UIView
/// Native spinner overlay, removed when the first page finishes loading.
private var spinnerOverlay: UIView?
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
// Container view that holds the WebView + spinner overlay.
self.containerView = UIView(frame: frame)
self.webView = WKWebView(frame: containerView.bounds, configuration: config)
self.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.webView.isOpaque = false
self.webView.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
self.webView.scrollView.backgroundColor = self.webView.backgroundColor
self.webView.scrollView.bounces = false
self.containerView.addSubview(self.webView)
// Dark overlay behind the spinner.
let overlay = UIView(frame: containerView.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
overlay.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
self.containerView.addSubview(overlay)
// Native spinner uses UIActivityIndicatorView which animates on
// the render thread independently of JS/main-thread work.
let spinner = UIActivityIndicatorView(style: .medium)
spinner.color = UIColor(red: 124/255.0, green: 92/255.0, blue: 220/255.0, alpha: 1)
spinner.translatesAutoresizingMaskIntoConstraints = false
spinner.startAnimating()
overlay.addSubview(spinner)
NSLayoutConstraint.activate([
spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
])
self.spinnerOverlay = overlay
super.init()
// Register the message handler and navigation delegate after super.init().
userContentController.add(self, name: "sandboxBridge")
self.webView.navigationDelegate = self
}
/// Navigate the WebView to the sandbox's entry point.
func navigateToApp() {
let initialURL = URL(string: "\(customScheme)://app/index.html")!
webView.load(URLRequest(url: initialURL))
}
/// Remove the native loading overlay. Safe to call multiple times.
func hideSpinner() {
spinnerOverlay?.removeFromSuperview()
spinnerOverlay = nil
}
/// 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: - WKNavigationDelegate
/// Remove the spinner overlay once the first page finishes loading.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
hideSpinner()
}
// 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()
weak var plugin: SandboxPlugin?
/// Diagnostics: total number of start calls received.
var startCallCount: Int = 0
/// Diagnostics: last URL received by the handler.
var lastURL: String = "(none)"
/// Number of pending tasks (for diagnostics).
var pendingTaskCount: Int {
lock.lock()
let count = pendingTasks.count
lock.unlock()
return count
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
self.sandboxId = sandboxId
self.scheme = scheme
self.plugin = plugin
}
// MARK: WKURLSchemeHandler
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
startCallCount += 1
lastURL = urlSchemeTask.request.url?.absoluteString ?? "(nil)"
let request = urlSchemeTask.request
guard let url = request.url else {
urlSchemeTask.didFailWithError(NSError(
@@ -59,9 +443,10 @@ class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
pendingTasks[requestId] = urlSchemeTask
lock.unlock()
// Extract the sandbox ID from the hostname: sbx://<sandbox-id>/path
let sandboxId = url.host ?? "unknown"
// 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
@@ -73,8 +458,6 @@ class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
}
let path = url.path.isEmpty ? "/" : url.path
// Rewrite URL so JS sees a consistent format matching Android.
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
let serialisedRequest: [String: Any] = [
@@ -92,6 +475,7 @@ class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
}
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 {
@@ -100,8 +484,7 @@ class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
lock.unlock()
}
// MARK: Response Resolution
/// Called by the plugin when JS responds to a fetch request.
func resolveRequest(
requestId: String,
status: Int,
@@ -116,12 +499,15 @@ class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
}
lock.unlock()
// Decode the base64 body.
var bodyData: Data? = nil
if let b64 = bodyBase64 {
bodyData = Data(base64Encoded: b64)
}
let responseURL = task.request.url ?? URL(string: "sbx://unknown/")!
// 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,
@@ -138,6 +524,7 @@ class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
}
}
/// Cancel all pending tasks (called on destroy).
func cancelAll() {
lock.lock()
let tasks = pendingTasks
@@ -152,79 +539,3 @@ class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
}
}
}
// MARK: - Plugin
/// Capacitor plugin that bridges sandbox fetch events between native and JS.
///
/// On iOS, sandbox iframes use the `sbx://` custom URL scheme, registered
/// on the WKWebView configuration before the web view is created. Each
/// sandbox loads from `sbx://<sandbox-id>/path`, providing full origin
/// isolation (separate localStorage, cookies, etc.).
///
/// On Android, a custom `BridgeWebViewClient` subclass intercepts requests
/// to `https://<sandbox-id>.sandbox.native/path`.
@objc(SandboxPlugin)
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "SandboxPlugin"
public let jsName = "SandboxPlugin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "diagnose", returnType: CAPPluginReturnPromise),
]
public override func load() {
// Connect the shared handler to this plugin so it can emit events.
_sandboxHandler?.plugin = self
}
@objc func respondToFetch(_ call: CAPPluginCall) {
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 handler = _sandboxHandler else {
call.reject("Sandbox handler not initialised")
return
}
handler.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()
}
/// Diagnostic method callable from JS to inspect native state.
@objc func diagnose(_ call: CAPPluginCall) {
let handler = _sandboxHandler
call.resolve([
"sandboxHandlerSet": handler != nil,
"pluginConnected": handler?.plugin != nil,
"bridgeHasWebView": bridge?.webView != nil,
"hasListenersFetch": hasListeners("fetch"),
"pendingTaskCount": handler?.pendingTaskCount ?? 0,
"startCallCount": handler?.startCallCount ?? 0,
"lastURL": handler?.lastURL ?? "(no handler)",
])
}
// MARK: - Event Forwarding
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
notifyListeners("fetch", data: [
"id": sandboxId,
"requestId": requestId,
"request": request,
])
}
}
+10 -10
View File
@@ -1,17 +1,17 @@
{
"name": "ditto",
"version": "2.6.6",
"version": "2.6.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.6.6",
"version": "2.6.4",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/keyboard": "^8.0.3",
"@capacitor/keyboard": "^8.0.2",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capgo/capacitor-autofill-save-password": "^8.0.22",
@@ -60,7 +60,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.1",
"@nostrify/react": "^0.5.0",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -429,9 +429,9 @@
}
},
"node_modules/@capacitor/keyboard": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz",
"integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==",
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.2.tgz",
"integrity": "sha512-he6xKmTBp5AhVrWJeEi6RYkJ25FjLLdNruBU2wafpITk3Nb7UdzOj96x3K6etFuEj8/rtn9WXBTs1o2XA86A1A==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
@@ -2584,9 +2584,9 @@
"license": "MIT"
},
"node_modules/@nostrify/react": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.1.tgz",
"integrity": "sha512-gQUct8A7KLKvoLtv4bHpVDfmvzJlIHjZZI6DMui8vrSuzm8IqMRdAYADbR3ry1mlIQp8/c4EeR24piBpHK0WUw==",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.0.tgz",
"integrity": "sha512-IQf74SSusSIyhI9FkUQSUTsX20yeww5xHIUeexvxcWXEpVhYJYCwduK2yRB75NvYgXjcqYeDUGA2RvzBhDc/eA==",
"dependencies": {
"@nostrify/nostrify": "0.51.1",
"@nostrify/types": "0.36.9"
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.6.6",
"version": "2.6.5",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -18,7 +18,7 @@
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/keyboard": "^8.0.3",
"@capacitor/keyboard": "^8.0.2",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capgo/capacitor-autofill-save-password": "^8.0.22",
@@ -67,7 +67,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.1",
"@nostrify/react": "^0.5.0",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
-8
View File
@@ -1,13 +1,5 @@
# Changelog
## [2.6.6] - 2026-04-12
### Fixed
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
- Emoji shortcodes now render as color emoji instead of plain text glyphs
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
- Signing requests on Android are more reliable and no longer silently fail after switching apps
## [2.6.5] - 2026-04-11
### Changed
+11 -3
View File
@@ -26,7 +26,6 @@ import { genUserName } from '@/lib/genUserName';
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
import { EXTRA_KINDS } from '@/lib/extraKinds';
import { getKindLabel } from '@/lib/kindLabels';
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
import { cn } from '@/lib/utils';
@@ -1081,7 +1080,16 @@ function hasVideo(tags: string[][]): boolean {
return false;
}
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
31990: 'App',
32267: 'Zapstore App',
30063: 'Zapstore Release',
3063: 'Zapstore Asset',
15128: 'Nsite',
35128: 'Nsite',
31124: 'Blobbi',
};
export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) {
const { data: event, isLoading } = useAddrEvent(addr);
@@ -1097,7 +1105,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey
if (kindDef) return kindDef.label;
const sub = EXTRA_KINDS.flatMap((d) => d.subKinds ?? []).find((s) => s.kind === addr.kind);
if (sub) return sub.label;
return getKindLabel(addr.kind);
return WELL_KNOWN_KIND_LABELS[addr.kind] ?? `Kind ${addr.kind}`;
}, [kindDef, addr.kind]);
const KindIcon = useMemo(() => {
+1 -9
View File
@@ -8,7 +8,6 @@ import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { CursorFireEffect } from '@/components/CursorFireEffect';
import { Skeleton } from '@/components/ui/skeleton';
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
import { NsitePlayerContext, type NsitePlayerState } from '@/contexts/NsitePlayerContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { cn } from '@/lib/utils';
@@ -139,17 +138,10 @@ function MainLayoutInner() {
*/
export function MainLayout() {
const store = useMemo(() => new LayoutStore(), []);
const [activeSubdomain, setActiveSubdomain] = useState<string | null>(null);
const nsitePlayer = useMemo<NsitePlayerState>(
() => ({ activeSubdomain, setActiveSubdomain }),
[activeSubdomain],
);
return (
<LayoutStoreContext.Provider value={store}>
<NsitePlayerContext.Provider value={nsitePlayer}>
<MainLayoutInner />
</NsitePlayerContext.Provider>
<MainLayoutInner />
</LayoutStoreContext.Provider>
);
}
+3 -10
View File
@@ -52,7 +52,6 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useMuteList } from '@/hooks/useMuteList';
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { toast } from '@/hooks/useToast';
@@ -327,13 +326,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
const nip19Id = encodeEventNip19(event);
const nostrUri = `nostr:${nip19Id}`;
// Named nsite events (35128) use the nsite:// scheme in the sidebar for auto-play behavior.
// Root sites (15128) can't be rendered as sidebar items (no naddr), so they use the normal nostr: URI.
const isNamedNsite = event.kind === 35128;
const nsiteUri = isNamedNsite ? `nsite://${getNsiteSubdomain(event)}` : undefined;
const sidebarUri = nsiteUri ?? nostrUri;
const isInSidebar = orderedItems.includes(sidebarUri);
const isInSidebar = orderedItems.includes(nostrUri);
const close = () => onOpenChange(false);
@@ -356,10 +349,10 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
const handleToggleSidebar = () => {
if (isInSidebar) {
removeFromSidebar(sidebarUri);
removeFromSidebar(nostrUri);
toast({ title: 'Removed from sidebar' });
} else {
addToSidebar(sidebarUri);
addToSidebar(nostrUri);
toast({ title: 'Added to sidebar' });
}
close();
+12 -75
View File
@@ -1,31 +1,27 @@
import type { NostrEvent } from "@nostrify/nostrify";
import { ExternalLink, FileText, Globe, Pin, PinOff, Play, Server } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ExternalLink, FileText, Globe, Play, Server } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ExternalFavicon } from "@/components/ExternalFavicon";
import { NsitePreviewDialog } from "@/components/NsitePreviewDialog";
import { Skeleton } from "@/components/ui/skeleton";
import { useNsitePlayer } from "@/contexts/NsitePlayerContext";
import { useFeedSettings } from "@/hooks/useFeedSettings";
import { useLinkPreview } from "@/hooks/useLinkPreview";
import { toast } from "@/hooks/useToast";
import { getNsiteSubdomain } from "@/lib/nsiteSubdomain";
import { sanitizeUrl } from "@/lib/sanitizeUrl";
import { cn } from "@/lib/utils";
interface NsiteCardProps {
event: NostrEvent;
/**
* When set, automatically open the nsite preview. Change the value
* (e.g. increment a counter) to re-trigger even if the component is
* already mounted. `undefined` / `0` = don't auto-play.
*/
autoPlayKey?: number;
}
/** Build the nsite.lol gateway URL for an nsite event. */
function getNsiteUrl(event: NostrEvent): string {
return `https://${getNsiteSubdomain(event)}.nsite.lol`;
}
/** Renders an nsite deployment card with a rich link preview. */
export function NsiteCard({ event, autoPlayKey }: NsiteCardProps) {
export function NsiteCard({ event }: NsiteCardProps) {
const title = event.tags.find(([n]) => n === "title")?.[1];
const description = event.tags.find(([n]) => n === "description")?.[1];
const dTag = event.tags.find(([n]) => n === "d")?.[1];
@@ -34,62 +30,14 @@ export function NsiteCard({ event, autoPlayKey }: NsiteCardProps) {
const serverTags = event.tags.filter(([n]) => n === "server");
const isNamed = event.kind === 35128 && !!dTag;
const nsiteSubdomain = getNsiteSubdomain(event);
const siteUrl = `https://${nsiteSubdomain}.nsite.lol`;
const siteUrl = getNsiteUrl(event);
const displayName = title || (isNamed ? dTag : "Root Site");
const { addToSidebar, removeFromSidebar, orderedItems } = useFeedSettings();
const sidebarUri = isNamed ? `nsite://${nsiteSubdomain}` : undefined;
const isPinned = sidebarUri ? orderedItems.includes(sidebarUri) : false;
const { data: preview, isLoading } = useLinkPreview(siteUrl);
const image = preview?.thumbnail_url;
const previewTitle = preview?.title;
const { activeSubdomain, setActiveSubdomain } = useNsitePlayer();
const [previewOpen, setPreviewOpen] = useState(!!autoPlayKey);
// Ref tracks the latest activeSubdomain so the unmount cleanup can
// guard against clearing a *different* nsite's active state.
const activeRef = useRef(activeSubdomain);
activeRef.current = activeSubdomain;
const handleTogglePin = useCallback(() => {
if (!sidebarUri) return;
if (isPinned) {
removeFromSidebar(sidebarUri);
toast({ title: 'Removed from sidebar' });
} else {
addToSidebar(sidebarUri);
toast({ title: 'Added to sidebar' });
}
}, [sidebarUri, isPinned, addToSidebar, removeFromSidebar]);
// Sync open/close state with the global NsitePlayerContext.
const handlePreviewOpenChange = useCallback((open: boolean) => {
setPreviewOpen(open);
setActiveSubdomain(open ? nsiteSubdomain : null);
}, [nsiteSubdomain, setActiveSubdomain]);
// Open the player when autoPlayKey changes (e.g. sidebar clicked again).
useEffect(() => {
if (autoPlayKey) {
handlePreviewOpenChange(true);
}
}, [autoPlayKey, handlePreviewOpenChange]);
// Register on mount if auto-playing, and clean up on unmount.
useEffect(() => {
if (previewOpen) {
setActiveSubdomain(nsiteSubdomain);
}
return () => {
// Only clear if we are still the active subdomain.
if (activeRef.current === nsiteSubdomain) {
setActiveSubdomain(null);
}
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const [previewOpen, setPreviewOpen] = useState(false);
if (isLoading) {
return <NsiteCardSkeleton />;
@@ -167,7 +115,7 @@ export function NsiteCard({ event, autoPlayKey }: NsiteCardProps) {
<Button
size="sm"
className="h-7 text-xs"
onClick={(e) => { e.stopPropagation(); handlePreviewOpenChange(true); }}
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
>
<Play className="size-3 mr-1" />
Run
@@ -197,17 +145,6 @@ export function NsiteCard({ event, autoPlayKey }: NsiteCardProps) {
</a>
</Button>
)}
{sidebarUri && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs ml-auto text-muted-foreground"
onClick={(e) => { e.stopPropagation(); handleTogglePin(); }}
>
{isPinned ? <PinOff className="size-3 mr-1" /> : <Pin className="size-3 mr-1" />}
{isPinned ? 'Unpin' : 'Pin'}
</Button>
)}
</div>
</div>
@@ -216,7 +153,7 @@ export function NsiteCard({ event, autoPlayKey }: NsiteCardProps) {
appName={previewTitle || displayName || "nsite"}
appPicture={undefined}
open={previewOpen}
onOpenChange={handlePreviewOpenChange}
onOpenChange={setPreviewOpen}
/>
</>
);
-203
View File
@@ -1,203 +0,0 @@
import { useCallback, useSyncExternalStore } from 'react';
import { Check, Shield, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
clearNsitePermissions,
getNsiteAllowance,
getPermissionLabel,
removeNsitePermission,
setNsitePermission,
type NsiteAllowance,
type NsitePermission,
} from '@/lib/nsitePermissions';
// ---------------------------------------------------------------------------
// Subscribe to localStorage changes so the component re-renders when
// permissions are modified (e.g. by the prompt granting a new permission).
// ---------------------------------------------------------------------------
const STORAGE_KEY = 'nostr:nsite-permissions';
function subscribe(callback: () => void): () => void {
// Listen for changes from other tabs/windows.
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) callback();
};
window.addEventListener('storage', onStorage);
// For same-tab mutations, we override the localStorage setter to also
// dispatch a custom event. This is necessary because the `storage` event
// only fires across tabs, not within the same tab.
const onLocal = () => callback();
window.addEventListener('nsite-permissions-changed', onLocal);
return () => {
window.removeEventListener('storage', onStorage);
window.removeEventListener('nsite-permissions-changed', onLocal);
};
}
let _snapshotCache: string | null = null;
function getSnapshot(): string | null {
const current = localStorage.getItem(STORAGE_KEY);
if (current !== _snapshotCache) {
_snapshotCache = current;
}
return _snapshotCache;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface NsitePermissionManagerProps {
/** Canonical nsite subdomain identifier. */
siteId: string;
/** Human-readable site name. */
siteName: string;
}
/**
* Popover triggered from the nsite preview nav bar that shows and manages
* stored permissions for the current site.
*/
export function NsitePermissionManager({ siteId, siteName }: NsitePermissionManagerProps) {
const { user } = useCurrentUser();
// Subscribe to permission changes so the list stays in sync.
useSyncExternalStore(subscribe, getSnapshot);
const allowance: NsiteAllowance | undefined = user
? getNsiteAllowance(siteId, user.pubkey)
: undefined;
const permissions = allowance?.permissions ?? [];
const handleToggle = useCallback(
(perm: NsitePermission) => {
if (!user) return;
setNsitePermission(
siteId,
user.pubkey,
siteName,
perm.type,
perm.kind,
!perm.allowed,
);
},
[siteId, siteName, user],
);
const handleRemove = useCallback(
(perm: NsitePermission) => {
if (!user) return;
removeNsitePermission(siteId, user.pubkey, perm.type, perm.kind);
},
[siteId, user],
);
const handleClearAll = useCallback(() => {
if (!user) return;
clearNsitePermissions(siteId, user.pubkey);
}, [siteId, user]);
// Don't render the manager if no user is logged in.
if (!user) return null;
const hasPermissions = permissions.length > 0;
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
title="Site permissions"
>
<Shield className={`size-3.5 ${hasPermissions ? 'text-primary' : ''}`} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="min-w-0">
<p className="text-sm font-medium truncate">Permissions</p>
<p className="text-xs text-muted-foreground truncate">{siteName}</p>
</div>
{hasPermissions && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-destructive hover:text-destructive gap-1"
onClick={handleClearAll}
>
<Trash2 className="size-3" />
Revoke all
</Button>
)}
</div>
{/* Permission list */}
<div className="max-h-64 overflow-y-auto">
{!hasPermissions ? (
<div className="px-4 py-6 text-center">
<Shield className="size-8 text-muted-foreground/30 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
No permissions granted
</p>
<p className="text-xs text-muted-foreground/60 mt-1">
Permissions will appear here when the app requests them.
</p>
</div>
) : (
<div className="divide-y">
{permissions.map((perm) => (
<div
key={`${perm.type}-${perm.kind}`}
className="flex items-center gap-3 px-4 py-2.5 group"
>
{/* Status icon */}
<div className="shrink-0">
{perm.allowed ? (
<Check className="size-3.5 text-green-500" />
) : (
<X className="size-3.5 text-destructive" />
)}
</div>
{/* Label */}
<span className="text-sm flex-1 min-w-0 truncate">
{getPermissionLabel(perm.type, perm.kind)}
</span>
{/* Toggle */}
<Switch
checked={perm.allowed}
onCheckedChange={() => handleToggle(perm)}
className="shrink-0 scale-75 origin-right"
/>
{/* Remove */}
<button
type="button"
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(perm)}
title="Remove"
>
<Trash2 className="size-3" />
</button>
</div>
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
-223
View File
@@ -1,223 +0,0 @@
import { useState } from 'react';
import { Check, KeyRound, Lock, Pen, ShieldAlert, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Checkbox } from '@/components/ui/checkbox';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Label } from '@/components/ui/label';
import { getKindLabel } from '@/lib/nsitePermissions';
import type { NsitePromptState, NsitePromptDecision } from '@/hooks/useNsiteSignerRpc';
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface NsitePermissionPromptProps {
/** App icon URL, if available. */
appPicture?: string;
/** Human-readable app name. */
appName: string;
/** The nsite gateway URL, used to fetch the site favicon. */
siteUrl?: string;
/** The pending prompt state from useNsiteSignerRpc. */
prompt: NsitePromptState;
/** Callback to resolve the prompt. */
onResolve: (decision: NsitePromptDecision) => void;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getPromptIcon(type: NsitePromptState['type']) {
switch (type) {
case 'signEvent':
return <Pen className="size-5 text-amber-500" />;
case 'nip04.encrypt':
case 'nip44.encrypt':
return <Lock className="size-5 text-blue-500" />;
case 'nip04.decrypt':
case 'nip44.decrypt':
return <KeyRound className="size-5 text-violet-500" />;
}
}
function getPromptTitle(type: NsitePromptState['type'], kind: number | null): string {
switch (type) {
case 'signEvent':
return kind !== null
? `Sign: ${getKindLabel(kind)}`
: 'Sign event';
case 'nip04.encrypt':
return 'Encrypt message (NIP-04)';
case 'nip04.decrypt':
return 'Decrypt message (NIP-04)';
case 'nip44.encrypt':
return 'Encrypt message (NIP-44)';
case 'nip44.decrypt':
return 'Decrypt message (NIP-44)';
}
}
function getPromptDescription(type: NsitePromptState['type']): string {
switch (type) {
case 'signEvent':
return 'This app wants to sign a Nostr event on your behalf.';
case 'nip04.encrypt':
case 'nip44.encrypt':
return 'This app wants to encrypt a message using your keys.';
case 'nip04.decrypt':
case 'nip44.decrypt':
return 'This app wants to decrypt a message using your keys.';
}
}
/** Truncate a string to a maximum character length. */
function truncate(str: string, max: number): string {
if (str.length <= max) return str;
return str.slice(0, max) + '\u2026';
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Overlay prompt shown when an nsite requests a signer operation that requires
* user approval. Renders on top of the nsite iframe within the preview panel.
*/
export function NsitePermissionPrompt({
appPicture,
appName,
siteUrl,
prompt,
onResolve,
}: NsitePermissionPromptProps) {
const [remember, setRemember] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const handleAllow = () => onResolve({ allowed: true, remember });
const handleDeny = () => onResolve({ allowed: false, remember });
const icon = getPromptIcon(prompt.type);
const title = getPromptTitle(prompt.type, prompt.kind);
const description = getPromptDescription(prompt.type);
// For signEvent, show a preview of the event content.
const eventContent = prompt.event?.content as string | undefined;
const eventJson = prompt.event ? JSON.stringify(prompt.event, null, 2) : null;
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="w-full max-w-sm rounded-xl border bg-card shadow-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center gap-3 px-5 pt-5 pb-3">
<div className="flex items-center justify-center size-10 rounded-full bg-muted">
<ExternalFavicon
url={siteUrl}
size={22}
fallback={<ShieldAlert className="size-5 text-muted-foreground" />}
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{appName}</p>
<p className="text-xs text-muted-foreground">Permission request</p>
</div>
{appPicture && (
<img
src={appPicture}
alt={appName}
className="size-8 rounded-md object-cover shrink-0"
/>
)}
</div>
{/* Body */}
<div className="px-5 pb-4 space-y-3">
{/* Operation */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className="shrink-0 mt-0.5">{icon}</div>
<div className="min-w-0">
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
</div>
{/* Event content preview (signEvent only) */}
{prompt.type === 'signEvent' && eventContent && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground mb-1">Content</p>
<p className="text-sm break-words whitespace-pre-wrap">
{truncate(eventContent, 280)}
</p>
</div>
)}
{/* Target pubkey (encrypt/decrypt) */}
{prompt.targetPubkey && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground mb-1">Target pubkey</p>
<p className="text-xs font-mono break-all">
{truncate(prompt.targetPubkey, 64)}
</p>
</div>
)}
{/* Raw event details (collapsible) */}
{eventJson && (
<Collapsible open={showDetails} onOpenChange={setShowDetails}>
<CollapsibleTrigger asChild>
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showDetails ? 'Hide details' : 'Show details'}
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 rounded-lg border bg-muted/30 p-3 text-xs font-mono max-h-40 overflow-auto whitespace-pre-wrap break-all">
{eventJson}
</pre>
</CollapsibleContent>
</Collapsible>
)}
{/* Remember checkbox */}
<div className="flex items-center gap-2 pt-1">
<Checkbox
id="nsite-remember"
checked={remember}
onCheckedChange={(checked) => setRemember(checked === true)}
/>
<Label
htmlFor="nsite-remember"
className="text-xs text-muted-foreground cursor-pointer select-none"
>
Remember for this site
</Label>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 px-5 pb-5">
<Button
variant="outline"
className="flex-1 gap-1.5"
onClick={handleDeny}
>
<X className="size-3.5" />
Deny
</Button>
<Button
className="flex-1 gap-1.5"
onClick={handleAllow}
>
<Check className="size-3.5" />
Allow
</Button>
</div>
</div>
</div>
);
}
+31 -80
View File
@@ -2,21 +2,14 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Package, X } from 'lucide-react';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Capacitor } from '@capacitor/core';
import { Button } from '@/components/ui/button';
import { NsitePermissionManager } from '@/components/NsitePermissionManager';
import { NsitePermissionPrompt } from '@/components/NsitePermissionPrompt';
import { SandboxFrame } from '@/components/SandboxFrame';
import { useCenterColumn } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNsiteSignerRpc } from '@/hooks/useNsiteSignerRpc';
import { APP_BLOSSOM_SERVERS, getEffectiveBlossomServers } from '@/lib/appBlossom';
import { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
import { getNsiteNostrProviderScript } from '@/lib/nsiteNostrProvider';
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
import { getPreviewInjectedScript } from '@/lib/previewInjectedScript';
import { getMimeType } from '@/lib/sandbox';
@@ -204,21 +197,13 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
const centerColumn = useCenterColumn();
const columnRect = useElementRect(open ? centerColumn : null);
const { config } = useAppContext();
const { user } = useCurrentUser();
// 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 siteUrl = `https://${nsiteSubdomain}.nsite.lol`;
const previewSubdomain = useMemo(() => deriveIframeSubdomain('nsite', nsiteSubdomain), [nsiteSubdomain]);
// NIP-07 signer proxy — only active when a user is logged in.
const signerRpc = useNsiteSignerRpc({
siteId: nsiteSubdomain,
siteName: appName,
});
// Build the manifest and server list from the event (memoised per event identity)
const manifest = useRef<Map<string, string>>(new Map());
const servers = useRef<string[]>([]);
@@ -239,24 +224,11 @@ 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: SPA path normalisation + NIP-07 provider (when logged in). */
const injectedScripts = useMemo<InjectedScript[]>(() => {
const scripts: InjectedScript[] = [{
path: '__injected__/preview.js',
content: getPreviewInjectedScript(),
}];
// When a user is logged in, inject a NIP-07 provider so the nsite can
// use window.nostr to interact with the user's signer.
if (user) {
scripts.push({
path: '__injected__/nostr-provider.js',
content: getNsiteNostrProviderScript(user.pubkey),
});
}
return scripts;
}, [user]);
/** Injected scripts: just the path normalisation snippet for SPA support. */
const injectedScripts = useMemo<InjectedScript[]>(() => [{
path: '__injected__/preview.js',
content: getPreviewInjectedScript(),
}], []);
/**
* Called by SandboxFrame before the native WebView is created.
@@ -335,67 +307,46 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
}}
>
{/* Nav bar */}
<div className="min-h-11 border-b bg-muted/30 shrink-0 safe-area-top">
<div className="px-3 py-2 flex items-center gap-2 w-full">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{appPicture ? (
<img
src={appPicture}
alt={appName}
className="size-6 rounded-md object-cover shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<ExternalFavicon
url={siteUrl}
size={18}
fallback={<Package className="size-3.5 text-primary/50" />}
/>
</div>
)}
<span className="text-sm font-medium truncate">{appName}</span>
</div>
{/* Permissions + Close */}
{user && (
<NsitePermissionManager siteId={nsiteSubdomain} siteName={appName} />
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{appPicture ? (
<img
src={appPicture}
alt={appName}
className="size-6 rounded-md object-cover shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Package className="size-3.5 text-primary/50" />
</div>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => onOpenChange(false)}
title="Close"
>
<X className="size-3.5" />
</Button>
<span className="text-sm font-medium truncate">{appName}</span>
</div>
{/* Close */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => onOpenChange(false)}
title="Close"
>
<X className="size-3.5" />
</Button>
</div>
{/* Sandboxed iframe */}
<div className="flex-1 min-h-0 bg-background relative">
<div className="flex-1 min-h-0 bg-background">
<SandboxFrame
key={`${previewSubdomain}-${open}`}
id={previewSubdomain}
resolveFile={resolveFile}
onReady={onReady}
onRpc={user ? signerRpc.onRpc : undefined}
injectedScripts={injectedScripts}
className="w-full h-full border-0"
title={`${appName} preview`}
/>
{/* Permission prompt overlay */}
{signerRpc.pendingPrompt && (
<NsitePermissionPrompt
appPicture={appPicture}
appName={appName}
siteUrl={siteUrl}
prompt={signerRpc.pendingPrompt}
onResolve={signerRpc.resolvePrompt}
/>
)}
</div>
</div>,
document.body,
-154
View File
@@ -1,154 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { GripVertical, Rocket, X } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { nip19 } from 'nostr-tools';
import { useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { nsiteUriToSubdomain } from '@/lib/sidebarItems';
import { parseNsiteSubdomain } from '@/lib/nsiteSubdomain';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { useNsitePlayer } from '@/contexts/NsitePlayerContext';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { useNostrEventSidebar } from '@/hooks/useNostrEventSidebar';
import { Skeleton } from '@/components/ui/skeleton';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface NsiteSidebarItemProps {
/** The full nsite:// URI, e.g. "nsite://3cbg51pm00nms2dp8rm..." */
id: string;
/** Ignored -- active state is derived from NsitePlayerContext instead. Kept for caller consistency with other sidebar item types. */
active?: boolean;
editing: boolean;
onRemove: (id: string, index?: number) => void;
onClick?: (e: React.MouseEvent) => void;
/** Extra classes on the link. */
linkClassName?: string;
}
// ── Label sub-component ───────────────────────────────────────────────────────
function NsiteSidebarLabel({ subdomain, parsed }: { subdomain: string; parsed: ReturnType<typeof parseNsiteSubdomain> }) {
const siteUrl = `https://${subdomain}.nsite.lol`;
const { data: preview } = useLinkPreview(siteUrl);
const addr = parsed && parsed.kind === 35128
? { kind: parsed.kind, pubkey: parsed.pubkey, identifier: parsed.identifier }
: undefined;
const { data: eventData, isLoading } = useNostrEventSidebar({ addr });
if (isLoading && !eventData && !preview) {
return <Skeleton className="h-4 w-20" />;
}
// Prefer the link preview title (the live site <title>), then the event tag label
const label = preview?.title || eventData?.label || 'Nsite';
return (
<span className="truncate">
{label}
</span>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function NsiteSidebarItem({
id, editing, onRemove, onClick, linkClassName,
}: NsiteSidebarItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
const navigate = useNavigate();
const subdomain = nsiteUriToSubdomain(id);
const parsed = useMemo(() => parseNsiteSubdomain(subdomain), [subdomain]);
// Highlight when the nsite player is open for this subdomain.
const { activeSubdomain } = useNsitePlayer();
const active = activeSubdomain === subdomain;
// Build the naddr path for navigation. For named sites (35128), encode as naddr.
// For root sites (15128), we'd need a nevent which requires the event ID — fall back to null.
const naddrPath = useMemo(() => {
if (!parsed) return null;
if (parsed.kind === 35128) {
const naddr = nip19.naddrEncode({
kind: parsed.kind,
pubkey: parsed.pubkey,
identifier: parsed.identifier,
});
return `/${naddr}`;
}
// Root site (15128) — we can't construct an naddr without a d-tag,
// and nevent requires event ID. For now, root site nsite:// URIs are not supported.
return null;
}, [parsed]);
// Navigate with a fresh timestamp on every click so the detail page
// can detect repeated clicks and re-open the player.
const handleClick = useCallback((e: React.MouseEvent) => {
onClick?.(e);
if (e.defaultPrevented || !naddrPath) return;
e.preventDefault();
navigate(naddrPath, { state: { nsiteAutoPlay: true, nsiteAutoPlayTs: Date.now() } });
}, [naddrPath, navigate, onClick]);
if (!parsed || !naddrPath) {
// Invalid or unsupported nsite URI — render nothing
return null;
}
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
)}
<a
href={naddrPath}
onClick={handleClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
)}
>
<span className="shrink-0">
<ExternalFavicon
url={`https://${subdomain}.nsite.lol`}
size={20}
fallback={<Rocket className="size-5" />}
className="size-6 flex items-center justify-center"
/>
</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
<NsiteSidebarLabel subdomain={subdomain} parsed={parsed} />
</span>
</a>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<X className="size-4" />
</button>
)}
</div>
);
}
+158 -132
View File
@@ -1,6 +1,5 @@
import {
useRef,
useState,
useEffect,
useCallback,
useMemo,
@@ -10,7 +9,6 @@ import {
} from 'react';
import { Capacitor } from '@capacitor/core';
import { Loader2 } from 'lucide-react';
import { useAppContext } from '@/hooks/useAppContext';
import {
@@ -27,6 +25,7 @@ import type {
import {
SandboxPlugin,
type SandboxFetchEvent,
type SandboxScriptMessageEvent,
} from '@/lib/sandboxPlugin';
// ---------------------------------------------------------------------------
@@ -325,7 +324,6 @@ const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
<iframe
ref={iframeRef}
src={`${origin}/`}
allow="clipboard-write"
{...iframeProps}
/>
);
@@ -333,37 +331,17 @@ const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
);
// ---------------------------------------------------------------------------
// Native (Capacitor) implementation — uses a real <iframe> served by native
// Native (Capacitor) implementation
// ---------------------------------------------------------------------------
/**
* Compute the iframe origin for native platforms.
*
* - iOS: `sbx://<sandbox-id>` — intercepted by the `SandboxRequestHandler`
* registered on the WKWebView configuration as a `WKURLSchemeHandler` for
* the `sbx://` custom scheme. Each sandbox ID is a unique origin, giving
* full localStorage / IndexedDB / cookie isolation.
* - Android: `https://<sandbox-id>.sandbox.native` — intercepted by the
* custom BridgeWebViewClient subclass.
*/
function getNativeOrigin(id: string): string {
if (Capacitor.getPlatform() === 'ios') {
return `sbx://${id}`;
}
// Android
return `https://${id}.sandbox.native`;
}
const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
function SandboxFrameNative(
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, className, style, ...iframeProps },
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, className, style, title },
ref,
) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const readyRef = useRef(false);
const [loading, setLoading] = useState(true);
const origin = useMemo(() => getNativeOrigin(id), [id]);
const placeholderRef = useRef<HTMLDivElement>(null);
const createdRef = useRef(false);
const destroyedRef = useRef(false);
// Keep latest callbacks in refs.
const resolveFileRef = useRef(resolveFile);
@@ -379,14 +357,17 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
// -----------------------------------------------------------------
// Post a message to the iframe via postMessage
// Post a message into the native sandbox
// -----------------------------------------------------------------
const post = useCallback(
const postToSandbox = useCallback(
(msg: Record<string, unknown>) => {
iframeRef.current?.contentWindow?.postMessage(msg, origin);
if (!createdRef.current || destroyedRef.current) return;
SandboxPlugin.postMessage({ id, message: msg }).catch((err) => {
console.error('[SandboxFrame] postMessage failed:', err);
});
},
[origin],
[id],
);
// Expose imperative handle.
@@ -394,27 +375,38 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
ref,
() => ({
postMessage: (msg: Record<string, unknown>) => {
post(msg);
postToSandbox(msg);
},
focus: () => {
iframeRef.current?.focus();
// No-op on native — the WebView is overlaid, not an iframe.
},
}),
[post],
[postToSandbox],
);
// -----------------------------------------------------------------
// Handle fetch events from the native scheme handler
// Lifecycle: onReady -> create WebView -> listen for events -> destroy
// -----------------------------------------------------------------
useEffect(() => {
let cancelled = false;
if (createdRef.current) return;
const listeners: Array<{ remove: () => void }> = [];
let cancelled = false;
async function setup() {
// Register the fetch listener BEFORE doing anything else.
// On Android, shouldInterceptRequest fires on a background thread
// as soon as the iframe src is set — the listener must be ready.
// Measure the placeholder position.
const el = placeholderRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
// Register listeners BEFORE creating the WebView. On Android,
// `shouldInterceptRequest` fires on a background thread as soon
// as the WebView starts loading — if the fetch listener isn't
// registered yet, the event is lost and the request times out
// (the thread blocks via CountDownLatch waiting for a response
// that never arrives).
const fetchListener = await SandboxPlugin.addListener(
'fetch',
(event: SandboxFetchEvent) => {
@@ -424,46 +416,57 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
);
listeners.push(fetchListener);
if (cancelled) return;
const scriptListener = await SandboxPlugin.addListener(
'scriptMessage',
(event: SandboxScriptMessageEvent) => {
if (event.id !== id) return;
handleNativeScriptMessage(event);
},
);
listeners.push(scriptListener);
// Run onReady (e.g. Android pre-fetches all blobs here).
if (cancelled || destroyedRef.current) return;
// Create the native WebView with a loading spinner — does NOT
// navigate yet, so no fetch events fire at this point.
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) {
SandboxPlugin.destroy({ id }).catch(() => {});
return;
}
createdRef.current = true;
// Run onReady while the spinner is visible and animating.
// On Android this pre-fetches all blobs so every resolveFile call
// after navigation is an instant cache hit.
// On iOS/web this is typically a no-op or instant.
try {
await onReadyRef.current?.();
} catch (err) {
console.error('[SandboxFrame] onReady failed:', err);
}
if (cancelled) return;
if (cancelled || destroyedRef.current) return;
// Diagnose native state before loading.
try {
const diag = await SandboxPlugin.diagnose();
console.log('[SandboxFrame] diagnose BEFORE src:', JSON.stringify(diag));
} catch (err) {
console.warn('[SandboxFrame] diagnose failed:', err);
}
// Set the iframe src to start loading content.
// This triggers native fetch interception via the scheme handler.
readyRef.current = true;
const src = `${origin}/index.html`;
console.log(`[SandboxFrame] setting iframe.src=${src}`);
if (iframeRef.current) {
iframeRef.current.src = src;
}
// Diagnose again after a delay to see if handler was called.
await new Promise((r) => setTimeout(r, 2000));
if (!cancelled) {
try {
const diag = await SandboxPlugin.diagnose();
console.log('[SandboxFrame] diagnose AFTER src (2s):', JSON.stringify(diag));
} catch (err) {
console.warn('[SandboxFrame] diagnose after failed:', err);
}
}
// Start loading the sandbox content — fetch events will now fire
// and be handled by the listeners registered above.
await SandboxPlugin.navigate({ id });
}
// ---------------------------------------------------------------
// Handle a fetch request from the native WebView
// ---------------------------------------------------------------
async function handleNativeFetch(event: SandboxFetchEvent) {
const reqUrl = event.request.url;
@@ -471,6 +474,9 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
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] ?? '/';
}
@@ -482,6 +488,7 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
cspRef.current,
(result) => {
SandboxPlugin.respondToFetch({
id,
requestId: event.requestId,
response: result as {
status: number;
@@ -495,6 +502,7 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
},
(_code, message) => {
SandboxPlugin.respondToFetch({
id,
requestId: event.requestId,
response: {
status: 500,
@@ -509,78 +517,104 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
);
}
// ---------------------------------------------------------------
// 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, origin]);
}, [id, postToSandbox]);
// -----------------------------------------------------------------
// Listen for postMessage from the iframe (RPC from injected scripts)
// Keep frame in sync with placeholder size/position
//
// Both consumers (WebxdcEmbed, NsitePreviewDialog) render inside
// position:fixed panels, so the placeholder never moves on scroll.
// A ResizeObserver is sufficient to track layout changes.
// -----------------------------------------------------------------
useEffect(() => {
function onMessage(event: MessageEvent) {
// On iOS the origin is "sbx://<id>",
// on Android "https://<id>.sandbox.native".
if (event.origin !== origin) return;
if (event.source !== iframeRef.current?.contentWindow) return;
const el = placeholderRef.current;
if (!el) return;
const msg = event.data;
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
// Handle RPC requests from injected scripts.
if (msg.id !== undefined && msg.method && onRpcRef.current) {
handleRpc(msg.id, msg.method, msg.params ?? {});
}
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.
});
}
async function handleRpc(
rpcId: string | number,
method: string,
params: unknown,
) {
try {
const result = await onRpcRef.current!(method, params, post);
post({ jsonrpc: '2.0', id: rpcId, result: result ?? null });
} catch (err) {
post({
jsonrpc: '2.0',
id: rpcId,
error: { code: -1, message: String(err) },
});
}
}
const ro = new ResizeObserver(updateFrame);
ro.observe(el);
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [origin, post]);
return () => {
ro.disconnect();
};
}, [id]);
// Hide the spinner once the iframe fires its load event (initial HTML parsed).
const handleLoad = useCallback(() => setLoading(false), []);
// Don't set src initially — it's set after onReady completes in setup().
return (
<div className={className} style={{ ...style, position: 'relative' }}>
<iframe
ref={iframeRef}
onLoad={handleLoad}
allow="clipboard-write"
style={{ width: '100%', height: '100%', border: 'none' }}
{...iframeProps}
/>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 className="size-10 animate-spin text-primary/70" />
</div>
)}
</div>
<div
ref={placeholderRef}
className={className}
style={style}
title={title}
data-sandbox-id={id}
/>
);
},
);
@@ -595,17 +629,9 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
* 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 regular
* `<iframe>` element whose requests are intercepted by native code:
* - iOS: `WKURLSchemeHandler` for the `sbx://` custom scheme, registered
* on the WKWebView configuration. Each sandbox loads from
* `sbx://<sandbox-id>/path` with full origin isolation.
* - Android: Custom BridgeWebViewClient intercepting `*.sandbox.native`
*
* Each sandbox gets a unique origin (via hostname or scheme+host), so
* localStorage/IndexedDB are isolated per sandbox. Since the sandbox is a
* regular DOM element, web UI (permission dialogs, popovers) naturally
* layers on top.
* 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.
+1 -17
View File
@@ -7,7 +7,6 @@ import {
import { sidebarItemIcon, itemPath } from '@/lib/sidebarItems';
import type { HiddenSidebarItem } from '@/hooks/useFeedSettings';
import { nip19 } from 'nostr-tools';
import { parseNsiteSubdomain } from '@/lib/nsiteSubdomain';
interface SidebarMoreMenuProps {
editing: boolean;
@@ -153,21 +152,6 @@ export function SidebarMoreMenu({
return;
}
// Nsite URI: nsite://<subdomain>
if (raw.startsWith('nsite://')) {
const subdomain = raw.slice('nsite://'.length);
const parsed = parseNsiteSubdomain(subdomain);
if (!parsed || parsed.kind !== 35128) {
setLinkError('Invalid nsite identifier (only named sites are supported)');
return;
}
onAdd(raw);
setLinkInput(false);
setLinkValue('');
setLinkError('');
return;
}
// Nostr: strip "nostr:" prefix if present for validation
const bech32 = raw.startsWith('nostr:') ? raw.slice(6) : raw;
@@ -240,7 +224,7 @@ export function SidebarMoreMenu({
setLinkError('');
}
}}
placeholder="URL, npub1..., nsite://..., ..."
placeholder="URL, npub1..., iso3166:US, ..."
className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60"
autoFocus
/>
+1 -15
View File
@@ -8,11 +8,10 @@ import {
SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isNostrUri, isExternalUri, isNsiteUri } from '@/lib/sidebarItems';
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isNostrUri, isExternalUri } from '@/lib/sidebarItems';
import { cn } from '@/lib/utils';
import { useCallback } from 'react';
import { NostrEventSidebarItem } from '@/components/NostrEventSidebarItem';
import { NsiteSidebarItem } from '@/components/NsiteSidebarItem';
import { ExternalContentSidebarItem } from '@/components/ExternalContentSidebarItem';
// ── Sortable item ─────────────────────────────────────────────────────────────
@@ -182,19 +181,6 @@ export function SidebarNavList({
/>
);
}
if (isNsiteUri(id)) {
return (
<NsiteSidebarItem
key={id}
id={id}
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onClick={getOnClick?.(id)}
linkClassName={linkClassName}
/>
);
}
if (isNostrUri(id)) {
return (
<NostrEventSidebarItem
+36 -38
View File
@@ -127,45 +127,43 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
onClick={(e) => e.stopPropagation()}
>
{/* Nav bar */}
<div className="min-h-11 border-b bg-muted/30 shrink-0 safe-area-top">
<div className="px-3 py-2 flex items-center gap-2 w-full">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{icon ? (
<img
src={icon}
alt={name ?? 'Webxdc App'}
className="size-6 rounded-md object-cover shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Blocks className="size-3.5 text-primary/50" />
</div>
)}
<span className="text-sm font-medium truncate">{name ?? 'Webxdc App'}</span>
</div>
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{icon ? (
<img
src={icon}
alt={name ?? 'Webxdc App'}
className="size-6 rounded-md object-cover shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Blocks className="size-3.5 text-primary/50" />
</div>
)}
<span className="text-sm font-medium truncate">{name ?? 'Webxdc App'}</span>
</div>
{/* Controls */}
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
className={cn('h-7 w-7 p-0 shrink-0', showGamepad && 'text-primary')}
onClick={toggleGamepad}
title={showGamepad ? 'Hide gamepad' : 'Show gamepad'}
>
<Gamepad2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={handleClose}
title="Close"
>
<X className="size-3.5" />
</Button>
</div>
{/* Controls */}
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
className={cn('h-7 w-7 p-0 shrink-0', showGamepad && 'text-primary')}
onClick={toggleGamepad}
title={showGamepad ? 'Hide gamepad' : 'Show gamepad'}
>
<Gamepad2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={handleClose}
title="Close"
>
<X className="size-3.5" />
</Button>
</div>
</div>
+47 -8
View File
@@ -16,6 +16,7 @@ import {
generateNostrConnectURI,
type NostrConnectParams,
} from '@/hooks/useLoginActions';
import { androidResume } from '@/lib/androidResume';
import { getNsecCredential } from '@/lib/credentialManager';
import { DialogTitle } from '@radix-ui/react-dialog';
import { useAppContext } from '@/hooks/useAppContext';
@@ -78,18 +79,22 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
}, [login, config.appName]);
// Start listening for connection (async) - runs after params are set.
//
// On Android, switching to Amber freezes the WebSocket so the NIP-46
// response is silently dropped. When Ditto returns to the foreground we
// abort the stale subscription and start a fresh one — the relay still
// has the response event so `limit: 1` picks it up immediately.
useEffect(() => {
if (!nostrConnectParams || isWaitingForConnect) return;
let cancelled = false;
let stopWatching: (() => void) | undefined;
const startListening = async () => {
setIsWaitingForConnect(true);
abortControllerRef.current = new AbortController();
const attemptConnect = async (signal: AbortSignal) => {
try {
await login.nostrconnect(nostrConnectParams, abortControllerRef.current.signal);
await login.nostrconnect(nostrConnectParams, signal);
if (!cancelled) {
stopWatching?.();
onLogin();
onClose();
}
@@ -97,9 +102,42 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
if (cancelled) return;
// AbortError means we intentionally aborted (dialog closed or retry)
if (error instanceof Error && error.name === 'AbortError') return;
console.error('Nostrconnect failed:', error);
setConnectError(error instanceof Error ? error.message : String(error));
setIsWaitingForConnect(false);
throw error;
}
};
const startListening = async () => {
setIsWaitingForConnect(true);
abortControllerRef.current = new AbortController();
// On Android, watch for foreground resume and retry the subscription.
({ destroy: stopWatching } = androidResume({
threshold: 0,
onResume: () => {
if (cancelled) return;
console.log('[LoginDialog] foreground resume — retrying nostrconnect');
// Abort the current (stale) subscription
abortControllerRef.current?.abort();
// Start a fresh subscription
abortControllerRef.current = new AbortController();
attemptConnect(abortControllerRef.current.signal).catch((error) => {
if (!cancelled) {
console.error('Nostrconnect retry failed:', error);
setConnectError(error instanceof Error ? error.message : String(error));
setIsWaitingForConnect(false);
}
});
},
}));
try {
await attemptConnect(abortControllerRef.current.signal);
} catch (error) {
if (!cancelled) {
console.error('Nostrconnect failed:', error);
setConnectError(error instanceof Error ? error.message : String(error));
setIsWaitingForConnect(false);
}
}
};
@@ -107,6 +145,7 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
return () => {
cancelled = true;
stopWatching?.();
};
}, [nostrConnectParams, login, onLogin, onClose, isWaitingForConnect]);
-23
View File
@@ -1,23 +0,0 @@
import { createContext, useContext } from 'react';
/**
* Tracks which nsite (by subdomain) currently has its player open.
* Used by the sidebar to highlight the active nsite item, and by
* NsiteCard to register/unregister the open player.
*/
export interface NsitePlayerState {
/** The subdomain of the currently-open nsite player, or null. */
activeSubdomain: string | null;
/** Set the active nsite subdomain (call with null to clear). */
setActiveSubdomain: (subdomain: string | null) => void;
}
export const NsitePlayerContext = createContext<NsitePlayerState>({
activeSubdomain: null,
setActiveSubdomain: () => {},
});
/** Hook to read/write the active nsite player subdomain. */
export function useNsitePlayer(): NsitePlayerState {
return useContext(NsitePlayerContext);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { type FeedSettings } from "@/contexts/AppContext";
import { useAppContext } from "@/hooks/useAppContext";
import { useEncryptedSettings } from "@/hooks/useEncryptedSettings";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { SIDEBAR_ITEMS, SIDEBAR_ITEM_IDS, SIDEBAR_DIVIDER_ID, isNostrUri, isExternalUri, isNsiteUri } from "@/lib/sidebarItems";
import { SIDEBAR_ITEMS, SIDEBAR_ITEM_IDS, SIDEBAR_DIVIDER_ID, isNostrUri, isExternalUri } from "@/lib/sidebarItems";
import { useCallback, useMemo } from "react";
// ── Order computation ─────────────────────────────────────────────────────────
@@ -49,7 +49,7 @@ function computeOrderedItems(
if (seen.has(item)) continue;
seen.add(item);
if (SIDEBAR_ITEM_IDS.has(item) || isNostrUri(item) || isExternalUri(item) || isNsiteUri(item)) {
if (SIDEBAR_ITEM_IDS.has(item) || isNostrUri(item) || isExternalUri(item)) {
ordered.push(item);
}
// else: unknown entry — skip
-277
View File
@@ -1,277 +0,0 @@
/**
* Hook that provides a JSON-RPC handler for proxying NIP-07 signer calls
* from a sandboxed nsite iframe to the parent user's signer.
*
* Each `nostr.*` RPC method is gated by the permission system. If no
* stored decision exists, a prompt is shown to the user. Prompts are
* serialized (one at a time) to prevent overwhelming the user.
*/
import { useCallback, useRef, useState } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
getNsitePermission,
setNsitePermission,
type NsitePermissionType,
} from '@/lib/nsitePermissions';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Describes a pending permission prompt waiting for the user's decision. */
export interface NsitePromptState {
/** The permission type being requested. */
type: NsitePermissionType;
/** For signEvent: the event kind. Null otherwise. */
kind: number | null;
/** For signEvent: the unsigned event template. */
event?: Record<string, unknown>;
/** For encrypt/decrypt: the target pubkey. */
targetPubkey?: string;
}
/** The user's response to a permission prompt. */
export interface NsitePromptDecision {
/** Whether the operation is allowed. */
allowed: boolean;
/** Whether to remember this decision. */
remember: boolean;
}
interface UseNsiteSignerRpcOptions {
/** Canonical nsite subdomain identifier. */
siteId: string;
/** Human-readable site name for storage. */
siteName: string;
}
interface UseNsiteSignerRpcResult {
/** The `onRpc` callback to pass to SandboxFrame. */
onRpc: (
method: string,
params: unknown,
post: (msg: Record<string, unknown>) => void,
) => Promise<unknown>;
/** Current pending prompt, or null if no prompt is active. */
pendingPrompt: NsitePromptState | null;
/** Call this to resolve the current prompt. */
resolvePrompt: (decision: NsitePromptDecision) => void;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useNsiteSignerRpc({
siteId,
siteName,
}: UseNsiteSignerRpcOptions): UseNsiteSignerRpcResult {
const { user } = useCurrentUser();
const [pendingPrompt, setPendingPrompt] = useState<NsitePromptState | null>(null);
// Ref to the resolve/reject pair for the current prompt, so the prompt UI
// can resolve it without a stale closure.
const promptResolverRef = useRef<{
resolve: (decision: NsitePromptDecision) => void;
reject: (err: Error) => void;
} | null>(null);
/**
* Show a permission prompt and wait for the user's decision.
* Only one prompt is active at a time (enforced by the injected script's
* serial queue — it only sends one RPC at a time).
*/
const showPrompt = useCallback(
(state: NsitePromptState): Promise<NsitePromptDecision> => {
return new Promise<NsitePromptDecision>((resolve, reject) => {
promptResolverRef.current = { resolve, reject };
setPendingPrompt(state);
});
},
[],
);
/** Resolve the current prompt with the user's decision. */
const resolvePrompt = useCallback(
(decision: NsitePromptDecision) => {
if (promptResolverRef.current) {
promptResolverRef.current.resolve(decision);
promptResolverRef.current = null;
}
setPendingPrompt(null);
},
[],
);
/**
* Check permission and optionally prompt. Returns true if allowed.
* Throws an error (with a user-facing message) if denied.
*/
const checkPermission = useCallback(
async (
type: NsitePermissionType,
kind: number | null,
promptState: NsitePromptState,
): Promise<void> => {
if (!user) throw new Error('Not logged in');
const stored = getNsitePermission(siteId, user.pubkey, type, kind);
if (stored === 'allow') return;
if (stored === 'deny') throw new Error('User rejected');
// No stored decision — ask the user.
const decision = await showPrompt(promptState);
if (decision.remember) {
setNsitePermission(siteId, user.pubkey, siteName, type, kind, decision.allowed);
}
if (!decision.allowed) {
throw new Error('User rejected');
}
},
[siteId, siteName, user, showPrompt],
);
// ---------------------------------------------------------------------------
// RPC handler
// ---------------------------------------------------------------------------
const onRpc = useCallback(
async (
method: string,
params: unknown,
): Promise<unknown> => {
if (!user) {
throw new Error('Not logged in');
}
const signer = user.signer;
const p = (params ?? {}) as Record<string, unknown>;
switch (method) {
// ------------------------------------------------------------------
// getPublicKey — always allowed
// ------------------------------------------------------------------
case 'nostr.getPublicKey': {
return user.pubkey;
}
// ------------------------------------------------------------------
// signEvent — permission gated per kind
// ------------------------------------------------------------------
case 'nostr.signEvent': {
const event = p.event as Record<string, unknown> | undefined;
if (!event || typeof event.kind !== 'number') {
throw new Error('Invalid event');
}
const kind = event.kind as number;
await checkPermission('signEvent', kind, {
type: 'signEvent',
kind,
event,
});
// Build the event template the signer expects.
const template = {
kind: event.kind as number,
content: (event.content as string) ?? '',
tags: (event.tags as string[][]) ?? [],
created_at: (event.created_at as number) ?? Math.floor(Date.now() / 1000),
};
const signed = await signer.signEvent(template);
return signed;
}
// ------------------------------------------------------------------
// NIP-04 encryption
// ------------------------------------------------------------------
case 'nostr.nip04.encrypt': {
if (!signer.nip04) throw new Error('Signer does not support NIP-04');
const pubkey = p.pubkey as string;
const plaintext = p.plaintext as string;
if (!pubkey || typeof plaintext !== 'string') {
throw new Error('Invalid params');
}
await checkPermission('nip04.encrypt', null, {
type: 'nip04.encrypt',
kind: null,
targetPubkey: pubkey,
});
return await signer.nip04.encrypt(pubkey, plaintext);
}
case 'nostr.nip04.decrypt': {
if (!signer.nip04) throw new Error('Signer does not support NIP-04');
const pubkey = p.pubkey as string;
const ciphertext = p.ciphertext as string;
if (!pubkey || typeof ciphertext !== 'string') {
throw new Error('Invalid params');
}
await checkPermission('nip04.decrypt', null, {
type: 'nip04.decrypt',
kind: null,
targetPubkey: pubkey,
});
return await signer.nip04.decrypt(pubkey, ciphertext);
}
// ------------------------------------------------------------------
// NIP-44 encryption
// ------------------------------------------------------------------
case 'nostr.nip44.encrypt': {
if (!signer.nip44) throw new Error('Signer does not support NIP-44');
const pubkey = p.pubkey as string;
const plaintext = p.plaintext as string;
if (!pubkey || typeof plaintext !== 'string') {
throw new Error('Invalid params');
}
await checkPermission('nip44.encrypt', null, {
type: 'nip44.encrypt',
kind: null,
targetPubkey: pubkey,
});
return await signer.nip44.encrypt(pubkey, plaintext);
}
case 'nostr.nip44.decrypt': {
if (!signer.nip44) throw new Error('Signer does not support NIP-44');
const pubkey = p.pubkey as string;
const ciphertext = p.ciphertext as string;
if (!pubkey || typeof ciphertext !== 'string') {
throw new Error('Invalid params');
}
await checkPermission('nip44.decrypt', null, {
type: 'nip44.decrypt',
kind: null,
targetPubkey: pubkey,
});
return await signer.nip44.decrypt(pubkey, ciphertext);
}
default:
throw new Error(`Method not found: ${method}`);
}
},
[user, checkPermission],
);
return { onRpc, pendingPrompt, resolvePrompt };
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Detects when a web app returns to the foreground after being backgrounded,
* primarily to work around Android's WebSocket zombie connection problem.
*
* Android aggressively throttles backgrounded tabs, causing WebSocket connections
* to silently miss events without triggering close/error handlers. This utility
* detects the resume and reports how long the app was in the background, so
* callers can force reconnection or re-query missed data.
*
* Framework-agnostic — no React dependency. Can be used in libraries.
*/
export interface AndroidResumeOptions {
/** Minimum background duration (ms) before triggering. Default: 0 */
threshold?: number;
/** Called when the app returns to foreground after exceeding the threshold. */
onResume?: (backgroundDurationMs: number) => void;
/**
* If true, only activates on Android user agents.
* Set to false to test on desktop. Default: true
*/
androidOnly?: boolean;
}
function isAndroid(): boolean {
return typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent);
}
export function androidResume(options: AndroidResumeOptions = {}): { destroy: () => void } {
const { threshold = 0, onResume, androidOnly = true } = options;
const noop = { destroy: () => {} };
// No-op in non-browser environments (e.g. Node.js, Deno without DOM).
if (typeof document === 'undefined') return noop;
if (androidOnly && !isAndroid()) return noop;
let hiddenAt: number | null = null;
const handler = () => {
if (document.visibilityState === 'hidden') {
hiddenAt = Date.now();
} else if (document.visibilityState === 'visible') {
if (hiddenAt === null) return;
const duration = Date.now() - hiddenAt;
hiddenAt = null;
if (duration >= threshold) {
onResume?.(duration);
}
}
};
document.addEventListener('visibilitychange', handler);
return {
destroy: () => {
document.removeEventListener('visibilitychange', handler);
},
};
}
-395
View File
@@ -1,395 +0,0 @@
/**
* Central registry of Nostr event kind labels.
*
* This is the single source of truth for kind → human-readable label mappings.
* All other files that need kind labels should import from here rather than
* maintaining their own maps.
*
* Sources:
* - NIP README kinds table (https://github.com/nostr-protocol/nips)
* - Ditto reference (https://about.ditto.pub/reference)
* - Existing codebase registries (consolidated)
*
* Labels are bare noun phrases (no articles, no verbs) so each consumer can
* add its own grammar:
* - NsitePermissionPrompt: "Sign: Short text note"
* - CommentContext: "a short text note"
* - NotificationsPage: "reacted to your short text note"
* - signerWithNudge: "Approve post in signer" (uses its own override)
*/
// ---------------------------------------------------------------------------
// The registry
// ---------------------------------------------------------------------------
/** Map of every known Nostr event kind to a short human-readable label. */
export const KIND_LABELS: Record<number, string> = {
// NIP-01 core
0: 'Profile',
1: 'Short text note',
2: 'Recommend relay',
3: 'Follows',
4: 'Encrypted message',
5: 'Deletion',
6: 'Repost',
7: 'Reaction',
8: 'Badge award',
9: 'Chat message',
10: 'Group chat threaded reply',
11: 'Thread',
12: 'Group thread reply',
13: 'Seal',
14: 'Direct message',
15: 'File message',
16: 'Generic repost',
17: 'Reaction to a website',
20: 'Photo',
21: 'Video',
22: 'Short video',
24: 'Public message',
// NKBIP-03
30: 'Internal reference',
31: 'External web reference',
32: 'Hardcopy reference',
33: 'Prompt reference',
// NIP-28 Public Chat
40: 'Channel creation',
41: 'Channel metadata',
42: 'Channel message',
43: 'Channel hide message',
44: 'Channel mute user',
// NIP-62
62: 'Request to vanish',
// NIP-64
64: 'Chess (PGN)',
// Marmot
443: 'KeyPackage',
444: 'Welcome message',
445: 'Group event',
// NIP-54
818: 'Merge request',
// NIP-88 Poll
1018: 'Poll vote',
// NIP-15 Marketplace
1021: 'Bid',
1022: 'Bid confirmation',
// NIP-03
1040: 'OpenTimestamps',
// NIP-59
1059: 'Gift wrap',
// NIP-94
1063: 'File metadata',
// NIP-88
1068: 'Poll',
// NIP-22
1111: 'Comment',
// NIP-A0 Voice
1222: 'Voice message',
1244: 'Voice message comment',
// NIP-53 Live
1311: 'Live chat message',
// NIP-C0
1337: 'Code snippet',
// NIP-34 Git
1617: 'Patch',
1618: 'Pull request',
1619: 'Pull request update',
1621: 'Issue',
1622: 'Git reply',
1630: 'Git status (open)',
1631: 'Git status (applied)',
1632: 'Git status (closed)',
1633: 'Git status (draft)',
// Nostrocket
1971: 'Problem tracker',
// NIP-56
1984: 'Report',
// NIP-32
1985: 'Label',
// Relay reviews
1986: 'Relay review',
// AI embeddings
1987: 'AI embeddings',
// NIP-35 Torrents
2003: 'Torrent',
2004: 'Torrent comment',
// Coinjoin
2022: 'Coinjoin pool',
// NIP-82 (Zapstore)
3063: 'Zapstore asset',
// Ditto custom kinds
3367: 'Color moment',
// NIP-72
4550: 'Community post approval',
// NIP-90 DVM (ranges)
5000: 'Job request',
6000: 'Job result',
7000: 'Job feedback',
// NIP-60 Cashu
7374: 'Reserved Cashu wallet tokens',
7375: 'Cashu wallet tokens',
7376: 'Cashu wallet history',
// Geocaching
7516: 'Found log',
7517: 'Geocache proof of find',
// NIP-43
8000: 'Add user',
8001: 'Remove user',
// Ditto letters
8211: 'Letter',
// NIP-29 Group control (range)
9000: 'Group control event',
// NIP-75
9041: 'Zap goal',
// NIP-61
9321: 'Nutzap',
// Tidal
9467: 'Tidal login',
// NIP-57 Zaps
9734: 'Zap request',
9735: 'Zap',
// NIP-84
9802: 'Highlight',
// ---- Replaceable events (10000+) ----
// NIP-51 Lists
10000: 'Mute list',
10001: 'Pin list',
// NIP-65
10002: 'Relay list',
// NIP-51
10003: 'Bookmark list',
10004: 'Communities list',
10005: 'Public chats list',
10006: 'Blocked relays list',
10007: 'Search relays list',
// NIP-58
10008: 'Profile badges',
// NIP-29
10009: 'User groups',
// NIP-39
10011: 'External identities',
// NIP-51
10012: 'Favorite relays list',
// NIP-37
10013: 'Private event relay list',
// NIP-51
10015: 'Interests list',
// NIP-61
10019: 'Nutzap mint recommendation',
// NIP-51
10020: 'Media follows',
10030: 'Emoji list',
// NIP-17
10050: 'DM relay list',
// Marmot
10051: 'KeyPackage relays list',
// Blossom
10063: 'Blossom server list',
// NIP-96 (deprecated)
10096: 'File storage server list',
// NIP-66
10166: 'Relay monitor announcement',
// NIP-53
10312: 'Room presence',
// Nostr Epoxy
10377: 'Proxy announcement',
11111: 'Transport method announcement',
// Bookstr
10073: 'Read books',
10074: 'Currently reading',
10075: 'To be read',
// Blobbi
11125: 'Blobbonaut profile',
// NIP-47 Wallet
13194: 'Wallet info',
// NIP-43
13534: 'Membership lists',
// Corny Chat
14388: 'User sound effect lists',
// Blobbi
14919: 'Blobbi interaction',
14920: 'Blobbi breeding',
14921: 'Blobbi record',
// NIP-5A nsites
15128: 'Nsite',
// Weather station
16158: 'Weather station',
// Theme
16767: 'Active profile theme',
// Profile tabs
16769: 'Profile tabs',
// NIP-60
17375: 'Cashu wallet event',
// Lightning.Pub
21000: 'Lightning Pub RPC',
// NIP-42
22242: 'Client authentication',
// NIP-47
23194: 'Wallet request',
23195: 'Wallet response',
// NIP-46
24133: 'Nostr Connect',
// Blossom
24242: 'Blob stored on mediaserver',
// NIP-98
27235: 'HTTP auth',
// NIP-43
28934: 'Join request',
28935: 'Invite request',
28936: 'Leave request',
// Webxdc
4932: 'Webxdc sync',
20932: 'Webxdc sync',
// ---- Addressable events (30000+) ----
// NIP-51 Sets
30000: 'Follow set',
30001: 'Generic list',
30002: 'Relay set',
30003: 'Bookmark set',
30004: 'Curation set',
30005: 'Video set',
30006: 'Picture set',
30007: 'Kind mute set',
// NIP-58
30008: 'Badge set',
30009: 'Badge definition',
// NIP-51
30015: 'Interest set',
// NIP-15 Marketplace
30017: 'Stall',
30018: 'Product',
30019: 'Marketplace UI/UX',
30020: 'Auction product',
// NIP-23
30023: 'Article',
30024: 'Draft long-form content',
// NIP-30
30030: 'Emoji set',
// NKBIP-01
30040: 'Curated publication index',
30041: 'Curated publication content',
// NIP-82 (Zapstore)
30063: 'Zapstore release',
// NIP-78
30078: 'App settings',
// NIP-66
30166: 'Relay discovery',
// NIP-51
30267: 'App curation set',
// NIP-53
30311: 'Live event',
30312: 'Interactive room',
30313: 'Conference event',
// NIP-38
30315: 'User status',
// NIP-85
30382: 'User trusted assertion',
30383: 'Event trusted assertion',
30384: 'Addressable event trusted assertion',
// Corny Chat
30388: 'Slide set',
// NIP-99
30402: 'Classified listing',
30403: 'Draft classified listing',
// Podcasts
30054: 'Podcast episode',
30055: 'Podcast trailer',
// NIP-34 Git
30617: 'Repository',
30618: 'Repository state',
// NIP-54 Wiki
30818: 'Wiki article',
30819: 'Wiki redirect',
// Custom NIP
30817: 'Custom NIP',
// NIP-37
31234: 'Draft event',
// Corny Chat
31388: 'Link set',
// Custom Feeds
31890: 'Feed',
// NIP-52 Calendar
31922: 'Date calendar event',
31923: 'Time calendar event',
31924: 'Calendar',
31925: 'Calendar event RSVP',
// NIP-89
31989: 'App recommendation',
31990: 'App',
// Bookstr
31985: 'Book review',
// Blobbi
31124: 'Blobbi',
// Zapstore
32267: 'Zapstore app',
// Corny Chat
32388: 'User room favorites',
33388: 'High scores',
// NIP-71
34235: 'Addressable video',
34236: 'Addressable short video',
// Corny Chat
34388: 'Sound effects',
// Music
34139: 'Music playlist',
// NIP-72
34550: 'Community definition',
// NIP-5A
34128: 'Nsite (legacy)',
35128: 'Nsite',
// Theme
36767: 'Theme definition',
// Music
36787: 'Music track',
// Ditto custom
37381: 'Magic deck',
37516: 'Geocache listing',
// NIP-87
38172: 'Cashu mint announcement',
38173: 'Fedimint announcement',
// NIP-69
38383: 'Peer-to-peer order',
// NIP-51
39089: 'Follow pack',
39092: 'Media follow pack',
// NIP-B0
39701: 'Web bookmark',
};
// ---------------------------------------------------------------------------
// Lookup function
// ---------------------------------------------------------------------------
/**
* Get the human-readable label for a Nostr event kind.
*
* Falls back to `"Kind <n>"` for unknown kinds, unless a custom fallback
* is provided.
*/
export function getKindLabel(kind: number, fallback?: string): string {
return KIND_LABELS[kind] ?? fallback ?? `Kind ${kind}`;
}
-116
View File
@@ -1,116 +0,0 @@
/**
* Generates the injected NIP-07 provider script for nsite sandboxed iframes.
*
* The script defines `window.nostr` conforming to the NIP-07 interface.
* `getPublicKey()` returns the embedded pubkey instantly (always allowed).
* All other methods (`signEvent`, `nip04.*`, `nip44.*`) send JSON-RPC 2.0
* requests to the parent frame via `postMessage` and await responses.
*
* A serial queue ensures only one RPC is in flight at a time, preventing
* the parent from being overwhelmed with concurrent permission prompts.
*/
export function getNsiteNostrProviderScript(pubkey: string): string {
return `(function() {
'use strict';
// ------------------------------------------------------------------
// Serial queue — one RPC at a time to avoid concurrent prompts
// ------------------------------------------------------------------
var _queue = [];
var _running = false;
function enqueue(fn) {
return new Promise(function(resolve, reject) {
_queue.push({ fn: fn, resolve: resolve, reject: reject });
drain();
});
}
function drain() {
if (_running || _queue.length === 0) return;
_running = true;
var item = _queue.shift();
item.fn().then(
function(v) { _running = false; item.resolve(v); drain(); },
function(e) { _running = false; item.reject(e); drain(); }
);
}
// ------------------------------------------------------------------
// JSON-RPC transport over postMessage
// ------------------------------------------------------------------
var _nextId = 1;
var _pending = {};
window.addEventListener('message', function(event) {
var msg = event.data;
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
if (msg.id === undefined || msg.id === null) return;
var cb = _pending[msg.id];
if (!cb) return;
delete _pending[msg.id];
if (msg.error) {
cb.reject(new Error(msg.error.message || 'RPC error'));
} else {
cb.resolve(msg.result);
}
});
function rpc(method, params) {
return enqueue(function() {
return new Promise(function(resolve, reject) {
var id = _nextId++;
_pending[id] = { resolve: resolve, reject: reject };
window.parent.postMessage({
jsonrpc: '2.0',
id: id,
method: method,
params: params || {}
}, '*');
});
});
}
// ------------------------------------------------------------------
// NIP-07 provider
// ------------------------------------------------------------------
var pubkey = ${JSON.stringify(pubkey)};
window.nostr = {
getPublicKey: function() {
return Promise.resolve(pubkey);
},
signEvent: function(event) {
return rpc('nostr.signEvent', { event: event });
},
getRelays: function() {
return Promise.resolve({});
},
nip04: {
encrypt: function(pubkey, plaintext) {
return rpc('nostr.nip04.encrypt', { pubkey: pubkey, plaintext: plaintext });
},
decrypt: function(pubkey, ciphertext) {
return rpc('nostr.nip04.decrypt', { pubkey: pubkey, ciphertext: ciphertext });
}
},
nip44: {
encrypt: function(pubkey, plaintext) {
return rpc('nostr.nip44.encrypt', { pubkey: pubkey, plaintext: plaintext });
},
decrypt: function(pubkey, ciphertext) {
return rpc('nostr.nip44.decrypt', { pubkey: pubkey, ciphertext: ciphertext });
}
}
};
// Signal availability to the nsite.
try {
window.dispatchEvent(new Event('nostr:ready'));
} catch(e) {}
})();`;
}
-238
View File
@@ -1,238 +0,0 @@
/**
* Permission model and localStorage persistence for nsite NIP-07 signer proxy.
*
* Permissions are scoped to (userPubkey, siteId) and are granular:
* - `signEvent` permissions are stored per event kind
* - Encryption/decryption permissions are stored per operation type
*
* `getPublicKey` is always allowed (clicking "Run" implies consent) and is
* not tracked in this system.
*/
import { getKindLabel } from '@/lib/kindLabels';
// Re-export so existing consumers of `getKindLabel` from this module keep working.
export { getKindLabel } from '@/lib/kindLabels';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Operations that require permission. `getPublicKey` is always allowed. */
export type NsitePermissionType =
| 'signEvent'
| 'nip04.encrypt'
| 'nip04.decrypt'
| 'nip44.encrypt'
| 'nip44.decrypt';
/** A single remembered permission decision. */
export interface NsitePermission {
/** Operation type. */
type: NsitePermissionType;
/** Event kind — only meaningful for `signEvent`, null otherwise. */
kind: number | null;
/** Whether this operation is allowed. */
allowed: boolean;
}
/** All remembered permissions for one (user, site) pair. */
export interface NsiteAllowance {
/** Canonical nsite subdomain identifier (from `getNsiteSubdomain`). */
siteId: string;
/** Human-readable site name. */
siteName: string;
/** Hex pubkey of the user who granted the permissions. */
userPubkey: string;
/** Individual permission decisions. */
permissions: NsitePermission[];
/** Unix timestamp (ms) when this allowance was first created. */
createdAt: number;
}
// ---------------------------------------------------------------------------
// Storage helpers
// ---------------------------------------------------------------------------
const STORAGE_KEY = 'nostr:nsite-permissions';
/** Read all allowances from localStorage. */
function readAllowances(): NsiteAllowance[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/** Write all allowances to localStorage and notify same-tab subscribers. */
function writeAllowances(allowances: NsiteAllowance[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(allowances));
// The `storage` event only fires across tabs. Dispatch a custom event so
// same-tab subscribers (e.g. NsitePermissionManager) also re-render.
window.dispatchEvent(new Event('nsite-permissions-changed'));
}
/** Find the allowance for a specific (siteId, userPubkey) pair. */
function findAllowance(
allowances: NsiteAllowance[],
siteId: string,
userPubkey: string,
): NsiteAllowance | undefined {
return allowances.find(
(a) => a.siteId === siteId && a.userPubkey === userPubkey,
);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Look up a stored permission decision.
*
* @returns `'allow'` or `'deny'` if remembered, `'ask'` if no decision stored.
*/
export function getNsitePermission(
siteId: string,
userPubkey: string,
type: NsitePermissionType,
kind: number | null = null,
): 'allow' | 'deny' | 'ask' {
const allowances = readAllowances();
const allowance = findAllowance(allowances, siteId, userPubkey);
if (!allowance) return 'ask';
const match = allowance.permissions.find((p) => {
if (p.type !== type) return false;
// For signEvent, match on kind; for others kind is always null.
if (type === 'signEvent') return p.kind === kind;
return true;
});
if (!match) return 'ask';
return match.allowed ? 'allow' : 'deny';
}
/**
* Store a permission decision. Creates the allowance if it doesn't exist.
* Updates an existing permission entry if one matches.
*/
export function setNsitePermission(
siteId: string,
userPubkey: string,
siteName: string,
type: NsitePermissionType,
kind: number | null,
allowed: boolean,
): void {
const allowances = readAllowances();
let allowance = findAllowance(allowances, siteId, userPubkey);
if (!allowance) {
allowance = {
siteId,
siteName,
userPubkey,
permissions: [],
createdAt: Date.now(),
};
allowances.push(allowance);
}
// Find existing entry for this (type, kind) pair.
const idx = allowance.permissions.findIndex((p) => {
if (p.type !== type) return false;
if (type === 'signEvent') return p.kind === kind;
return true;
});
const entry: NsitePermission = { type, kind, allowed };
if (idx >= 0) {
allowance.permissions[idx] = entry;
} else {
allowance.permissions.push(entry);
}
writeAllowances(allowances);
}
/**
* Remove a single permission entry from a site's allowance.
*/
export function removeNsitePermission(
siteId: string,
userPubkey: string,
type: NsitePermissionType,
kind: number | null,
): void {
const allowances = readAllowances();
const allowance = findAllowance(allowances, siteId, userPubkey);
if (!allowance) return;
allowance.permissions = allowance.permissions.filter((p) => {
if (p.type !== type) return true;
if (type === 'signEvent') return p.kind !== kind;
return false;
});
// Remove the allowance entirely if no permissions remain.
if (allowance.permissions.length === 0) {
const idx = allowances.indexOf(allowance);
if (idx >= 0) allowances.splice(idx, 1);
}
writeAllowances(allowances);
}
/**
* Clear all stored permissions for a site.
*/
export function clearNsitePermissions(
siteId: string,
userPubkey: string,
): void {
const allowances = readAllowances();
const filtered = allowances.filter(
(a) => !(a.siteId === siteId && a.userPubkey === userPubkey),
);
writeAllowances(filtered);
}
/**
* Get the full allowance record for a site, or undefined if none exists.
*/
export function getNsiteAllowance(
siteId: string,
userPubkey: string,
): NsiteAllowance | undefined {
return findAllowance(readAllowances(), siteId, userPubkey);
}
// ---------------------------------------------------------------------------
// Human-readable labels
// ---------------------------------------------------------------------------
/** Get a human-readable label for a permission type and optional kind. */
export function getPermissionLabel(
type: NsitePermissionType,
kind: number | null,
): string {
switch (type) {
case 'signEvent': {
if (kind === null) return 'Sign event';
return `Sign: ${getKindLabel(kind)}`;
}
case 'nip04.encrypt':
return 'Encrypt (NIP-04)';
case 'nip04.decrypt':
return 'Decrypt (NIP-04)';
case 'nip44.encrypt':
return 'Encrypt (NIP-44)';
case 'nip44.decrypt':
return 'Decrypt (NIP-44)';
}
}
+1 -58
View File
@@ -1,9 +1,6 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
/** The fixed length of a base36-encoded 32-byte pubkey. */
const BASE36_PUBKEY_LENGTH = 50;
/** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */
export function hexToBase36(hex: string): string {
let n = 0n;
@@ -11,61 +8,7 @@ export function hexToBase36(hex: string): string {
n = n * 16n + BigInt(parseInt(hex[i], 16));
}
const b36 = n.toString(36);
return b36.padStart(BASE36_PUBKEY_LENGTH, '0');
}
/** Decode a base36-encoded pubkey back to a 64-char hex string. */
function base36ToHex(b36: string): string {
const n = [...b36].reduce((acc, ch) => acc * 36n + BigInt(parseInt(ch, 36)), 0n);
return n.toString(16).padStart(64, '0');
}
/**
* Parsed nsite subdomain — either a root site (kind 15128) or a named site (kind 35128).
*/
export interface ParsedNsiteSubdomain {
/** The hex pubkey of the site owner. */
pubkey: string;
/** The event kind (15128 for root, 35128 for named). */
kind: 15128 | 35128;
/** The d-tag identifier (empty string for root sites). */
identifier: string;
}
/**
* Parse an nsite subdomain back into its components.
*
* - Root site subdomain: `<npub1...>` → kind 15128, identifier ""
* - Named site subdomain: `<50-char-base36><dTag>` → kind 35128, identifier = dTag
*
* Returns null if the subdomain cannot be parsed.
*/
export function parseNsiteSubdomain(subdomain: string): ParsedNsiteSubdomain | null {
// Root site: subdomain is an npub
if (subdomain.startsWith('npub1')) {
try {
const decoded = nip19.decode(subdomain);
if (decoded.type !== 'npub') return null;
return { pubkey: decoded.data as string, kind: 15128, identifier: '' };
} catch {
return null;
}
}
// Named site: first 50 chars are base36 pubkey, rest is d-tag
if (subdomain.length <= BASE36_PUBKEY_LENGTH) return null;
const b36Part = subdomain.slice(0, BASE36_PUBKEY_LENGTH);
const dTag = subdomain.slice(BASE36_PUBKEY_LENGTH);
// Validate base36 characters
if (!/^[0-9a-z]+$/.test(b36Part)) return null;
try {
const pubkey = base36ToHex(b36Part);
return { pubkey, kind: 35128, identifier: dTag };
} catch {
return null;
}
return b36.padStart(50, '0');
}
/**
+70 -30
View File
@@ -1,19 +1,13 @@
/**
* SandboxPlugin — Capacitor plugin for native sandbox iframe support.
* SandboxPlugin — Capacitor plugin for native sandboxed WebViews.
*
* On iOS, sandbox iframes use the `sbx://` custom URL scheme, registered on
* the WKWebView configuration via `setURLSchemeHandler(_:forURLScheme:)` in
* `DittoBridgeViewController`. Each sandbox loads from `sbx://<sandbox-id>/path`,
* giving every sandbox a unique web origin with full storage isolation.
* 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.
*
* On Android, a custom BridgeWebViewClient subclass intercepts requests to
* `https://<sandbox-id>.sandbox.native/path` from iframes in the main WebView.
*
* Both platforms forward intercepted requests to the JS layer as `fetch`
* events. JS resolves the file and responds with `respondToFetch()`.
*
* Sandbox content lives in regular `<iframe>` elements, so web UI
* (permission prompts, popovers) naturally layers on top.
* 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';
@@ -23,11 +17,24 @@ import type { PluginListenerHandle } from '@capacitor/core';
// Plugin method options
// ---------------------------------------------------------------------------
/** A serialised fetch response sent back to the native scheme handler. */
/** 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 {
/** Unique request ID from the fetch event. */
id: string;
requestId: string;
/** The serialised HTTP response. */
response: {
status: number;
statusText: string;
@@ -36,13 +43,24 @@ export interface SandboxRespondToFetchOptions {
};
}
/** 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 scheme handler. */
/** A fetch request forwarded from the native WebView's URL scheme handler. */
export interface SandboxFetchEvent {
/** The sandbox ID (hostname) this request belongs to. */
/** The sandbox ID this request belongs to. */
id: string;
/** Unique request ID — pass back to `respondToFetch`. */
requestId: string;
@@ -55,31 +73,53 @@ export interface SandboxFetchEvent {
};
}
/** 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
// ---------------------------------------------------------------------------
/** Diagnostic state returned by the native plugin. */
export interface SandboxDiagnostics {
sandboxHandlerSet: boolean;
pluginConnected: boolean;
bridgeHasWebView: boolean;
hasListenersFetch: boolean;
pendingTaskCount: number;
/** Options for navigating the sandbox WebView to its entry point. */
export interface SandboxNavigateOptions {
id: string;
}
export interface SandboxPluginInterface {
/** Send a fetch response back to the native scheme handler for a pending request. */
/** Create a new sandbox WebView with a loading spinner (does not navigate). */
create(options: SandboxCreateOptions): Promise<void>;
/** Navigate the sandbox WebView to its entry point (triggers resource loading). */
navigate(options: SandboxNavigateOptions): 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>;
/** Return diagnostic state from the native side (iOS only). */
diagnose(): Promise<SandboxDiagnostics>;
/** Post a JSON-RPC message to injected scripts inside the sandbox. */
postMessage(options: SandboxPostMessageOptions): Promise<void>;
/** Listen for fetch requests from sandbox iframes intercepted by native code. */
/** 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>;
}
// ---------------------------------------------------------------------------
@@ -89,6 +129,6 @@ export interface SandboxPluginInterface {
/**
* The SandboxPlugin Capacitor plugin.
* Only usable on native platforms (iOS/Android). On web, SandboxFrame
* uses the iframe.diy fetch proxy sandbox.
* falls back to the iframe.diy service worker sandbox.
*/
export const SandboxPlugin = registerPlugin<SandboxPluginInterface>('SandboxPlugin');
-19
View File
@@ -59,16 +59,6 @@ export function isNostrUri(id: string): boolean {
return id.startsWith("nostr:");
}
/** Returns true if the given sidebar order ID is an `nsite://` URI. */
export function isNsiteUri(id: string): boolean {
return id.startsWith("nsite://");
}
/** Extracts the nsite subdomain from an `nsite://` URI. */
export function nsiteUriToSubdomain(uri: string): string {
return uri.slice("nsite://".length);
}
/** Extracts the NIP-19 bech32 identifier from a `nostr:` URI. Returns the raw string if not a nostr: URI. */
export function nostrUriToNip19(uri: string): string {
return uri.startsWith("nostr:") ? uri.slice(6) : uri;
@@ -288,15 +278,6 @@ export function isItemActive(
return pathname === `/${nip19Id}`;
}
// Nsite URI items: active when the nsite preview is open for this subdomain.
// The pathname will be the naddr of the nsite event, which we can't cheaply
// derive here without async resolution. For now, nsite items are never
// highlighted as "active" via pathname — the visual indication comes from
// the nsite preview panel being open.
if (isNsiteUri(id)) {
return false;
}
// External content items: active when pathname matches /i/<encoded-value>
if (isExternalUri(id)) {
return pathname === `/i/${encodeURIComponent(id)}` || pathname === `/i/${id}`;
+111 -61
View File
@@ -1,8 +1,8 @@
import type { NostrEvent, NostrSigner } from '@nostrify/types';
import { createElement } from 'react';
import { toast } from '@/hooks/useToast';
import { androidResume } from '@/lib/androidResume';
import { NudgeToastContent } from '@/components/SignerToastContent';
import { getKindLabel } from '@/lib/kindLabels';
// ---------------------------------------------------------------------------
// Constants
@@ -16,6 +16,9 @@ const NUDGE_DELAY_MS = 4_000;
const HARD_TIMEOUT_MS = 45_000;
/** Max number of automatic retries on Android foreground resume. */
const MAX_RETRIES = 2;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -26,15 +29,18 @@ function isAndroid(): boolean {
type OpType = 'sign' | 'encrypt' | 'decrypt';
/**
* Context-specific overrides for nudge toast descriptions.
* Falls back to the central kind label registry for kinds not listed here.
*/
const NUDGE_OVERRIDES: Record<number, string> = {
/** Human-readable labels for event kinds shown in nudge toasts. */
const KIND_LABELS: Record<number, string> = {
0: 'profile update',
1: 'post',
3: 'contact list update',
5: 'deletion',
6: 'repost',
7: 'reaction',
11: 'post',
16: 'repost',
1111: 'comment',
1984: 'report',
4932: 'webxdc sync',
10000: 'mute list update',
10001: 'pinned notes update',
@@ -52,11 +58,7 @@ const NUDGE_OVERRIDES: Record<number, string> = {
};
function labelForOp(kind: number | undefined, opType: OpType): string {
if (kind !== undefined) {
if (NUDGE_OVERRIDES[kind]) return NUDGE_OVERRIDES[kind];
const central = getKindLabel(kind, '');
if (central) return central.toLowerCase();
}
if (kind !== undefined && KIND_LABELS[kind]) return KIND_LABELS[kind];
if (opType === 'encrypt') return 'encryption';
if (opType === 'decrypt') return 'decryption';
return 'signing';
@@ -68,8 +70,9 @@ function labelForOp(kind: number | undefined, opType: OpType): string {
const CANCEL = Symbol('cancel');
const TIMEOUT = Symbol('timeout');
const RESUME = Symbol('resume');
type Signal = typeof CANCEL | typeof TIMEOUT;
type Signal = typeof CANCEL | typeof TIMEOUT | typeof RESUME;
// ---------------------------------------------------------------------------
// Toast deduplication — prevent a storm of identical nudge toasts
@@ -95,9 +98,10 @@ function showNudgeToast(opts: {
kind: number | undefined;
opType: OpType;
isBunkerConnected: (() => boolean) | undefined;
afterForegroundResume: boolean;
onCancel: () => void;
}): { dismiss: () => void } {
const { kind, opType, isBunkerConnected, onCancel } = opts;
const { kind, opType, isBunkerConnected, afterForegroundResume, onCancel } = opts;
const android = isAndroid();
const relayOk = isBunkerConnected ? isBunkerConnected() : true;
const subject = labelForOp(kind, opType);
@@ -116,6 +120,9 @@ function showNudgeToast(opts: {
if (!relayOk) {
title = 'Signer relay unreachable';
descriptionText = 'Check your connection and try again.';
} else if (android && afterForegroundResume) {
title = `Approve ${subject} — try again`;
descriptionText = 'Use the button below. Switching apps manually can interrupt the connection.';
} else if (android) {
title = `Approve ${subject}`;
descriptionText = 'Set to auto-approve for a smoother experience.';
@@ -177,6 +184,11 @@ interface RunResult<T> {
* Runs `op` with:
* - A nudge toast after NUDGE_DELAY_MS if still pending.
* - A hard timeout at HARD_TIMEOUT_MS.
* - On Android, automatic retry when the app returns to the foreground
* (WebSocket connections are frozen while backgrounded, so NIP-46 responses
* are missed).
*
* Uses an iterative retry loop instead of recursion.
*/
async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<RunResult<T>> {
const { kind, opType, isBunkerConnected } = opts;
@@ -188,61 +200,96 @@ async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<Run
| { tag: 'signal'; signal: Signal };
let nudgeFired = false;
let afterForegroundResume = false;
// Signal channels — each resolves with a sentinel when its condition fires.
const cancelSignal = deferred<typeof CANCEL>();
const timeoutSignal = deferred<typeof TIMEOUT>();
// Previous op promises that are still in-flight. On Android foreground
// resume we issue a fresh `op()` but keep racing previous ones so a late
// response from an earlier attempt is still accepted (avoids duplicate
// signer prompts).
const pendingOps: Promise<Outcome>[] = [];
// --- Nudge timer ---
let dismissNudge: (() => void) | undefined;
const nudgeTimer = setTimeout(() => {
nudgeFired = true;
const handle = showNudgeToast({
kind, opType, isBunkerConnected,
onCancel: () => cancelSignal.resolve(CANCEL),
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
// Signal channels — each resolves with a sentinel when its condition fires.
const cancelSignal = deferred<typeof CANCEL>();
const timeoutSignal = deferred<typeof TIMEOUT>();
const resumeSignal = deferred<typeof RESUME>();
// --- Nudge timer ---
let dismissNudge: (() => void) | undefined;
const delay = NUDGE_DELAY_MS;
const nudgeTimer = setTimeout(() => {
nudgeFired = true;
const handle = showNudgeToast({
kind, opType, isBunkerConnected, afterForegroundResume,
onCancel: () => cancelSignal.resolve(CANCEL),
});
dismissNudge = handle.dismiss;
}, delay);
// --- Hard timeout ---
const hardTimer = setTimeout(() => timeoutSignal.resolve(TIMEOUT), HARD_TIMEOUT_MS);
// --- Android foreground resume watcher ---
const { destroy: stopWatching } = androidResume({
threshold: 0,
onResume: () => {
toast({ title: 'Checking for signer response\u2026', duration: 4000 });
resumeSignal.resolve(RESUME);
},
});
dismissNudge = handle.dismiss;
}, NUDGE_DELAY_MS);
// --- Hard timeout ---
const hardTimer = setTimeout(() => timeoutSignal.resolve(TIMEOUT), HARD_TIMEOUT_MS);
function cleanup() {
clearTimeout(nudgeTimer);
clearTimeout(hardTimer);
stopWatching();
dismissNudge?.();
}
function cleanup() {
clearTimeout(nudgeTimer);
clearTimeout(hardTimer);
dismissNudge?.();
// Start a new op and add it to the pending set.
const newOp: Promise<Outcome> = op().then(
(value): Outcome => ({ tag: 'value', value }),
(error): Outcome => ({ tag: 'error', error }),
);
pendingOps.push(newOp);
const signalOutcome: Promise<Outcome> = Promise.race([
cancelSignal.promise,
timeoutSignal.promise,
resumeSignal.promise,
]).then((signal): Outcome => ({ tag: 'signal', signal }));
// Race all pending ops (current + any still in-flight from prior
// attempts) against the signal channels.
const outcome = await Promise.race([...pendingOps, signalOutcome]);
cleanup();
// --- Handle outcome ---
if (outcome.tag === 'value') {
if (nudgeFired) showSuccessToast(opType);
return { value: outcome.value, nudgeFired };
}
if (outcome.tag === 'error') {
throw outcome.error;
}
// outcome.tag === 'signal'
switch (outcome.signal) {
case CANCEL:
throw new Error('Signing cancelled by user');
case TIMEOUT:
throw new Error('Signer timed out');
case RESUME:
afterForegroundResume = true;
console.log('[signerWithNudge] retrying after foreground resume');
continue;
}
}
const opOutcome: Promise<Outcome> = op().then(
(value): Outcome => ({ tag: 'value', value }),
(error): Outcome => ({ tag: 'error', error }),
);
const signalOutcome: Promise<Outcome> = Promise.race([
cancelSignal.promise,
timeoutSignal.promise,
]).then((signal): Outcome => ({ tag: 'signal', signal }));
const outcome = await Promise.race([opOutcome, signalOutcome]);
cleanup();
if (outcome.tag === 'value') {
if (nudgeFired) showSuccessToast(opType);
return { value: outcome.value, nudgeFired };
}
if (outcome.tag === 'error') {
throw outcome.error;
}
// outcome.tag === 'signal'
switch (outcome.signal) {
case CANCEL:
throw new Error('Signing cancelled by user');
case TIMEOUT:
throw new Error('Signer timed out');
}
throw new Error('Signer timed out after retries');
}
// ---------------------------------------------------------------------------
@@ -254,6 +301,9 @@ async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<Run
*
* - Shows a nudge toast after 4s if a signing or encryption op is still
* pending, so the user knows to check their signer app.
* - On Android, automatically retries when the app returns to the foreground,
* recovering from missed NIP-46 responses dropped while the WebSocket was
* frozen in the background.
* - When a nip44 encrypt is immediately followed by a signEvent (e.g. saving
* encrypted settings), shows a phase-transition toast so the user knows to
* approve the second request.
+3 -7
View File
@@ -21,16 +21,12 @@ import '@fontsource-variable/inter';
// Uses a MutationObserver so it reacts to all subsequent theme changes
// (class changes for builtin themes, style-content changes for custom themes).
import { Capacitor, SystemBars, SystemBarsStyle } from '@capacitor/core';
import { Keyboard } from '@capacitor/keyboard';
import { getBackgroundThemeMode } from '@/lib/colorUtils';
if (Capacitor.isNativePlatform()) {
// Hide the iOS keyboard accessory bar (prev/next/done toolbar above the keyboard).
// Only runs on iOS — setAccessoryBarVisible is unimplemented on Android.
if (Capacitor.getPlatform() === 'ios') {
import('@capacitor/keyboard').then(({ Keyboard }) => {
Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => {});
}).catch(() => {});
}
// Hide the iOS keyboard accessory bar (prev/next/done toolbar above the keyboard)
Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => {});
/**
* Sync the native system bar icon style with the active CSS theme.
*
+23 -32
View File
@@ -20,7 +20,7 @@ import {
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
/** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the detail page bundle. */
const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent })));
import { AudioVisualizer } from "@/components/AudioVisualizer";
@@ -92,7 +92,6 @@ import { useAppContext } from "@/hooks/useAppContext";
import { type AddrCoords, useAddrEvent, useEvent } from "@/hooks/useEvent";
import { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
import { KIND_LABELS } from "@/lib/kindLabels";
import { formatNumber } from "@/lib/formatNumber";
import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls";
@@ -124,27 +123,36 @@ const BOOK_REVIEW_KIND = 31985;
/** NIP-62 Request to Vanish. */
const VANISH_KIND = 62;
/**
* Map a kind number to a human-readable shell title for the loading state.
*
* Group-based overrides and composite labels (e.g. "Badge Details",
* "Badge Collection") are kept here. Everything else falls through to the
* central kind label registry.
*/
/** Map a kind number to a human-readable shell title for the loading state. */
function shellTitleForKind(kind?: number): string {
if (!kind) return "Loading...";
// Group-based overrides
if (MUSIC_KINDS.has(kind)) return "Track Details";
if (PODCAST_KINDS.has(kind)) return "Episode Details";
if (CALENDAR_EVENT_KINDS.has(kind)) return "Event Details";
if (FOLLOW_PACK_KINDS.has(kind)) return "Follow Pack";
if (kind === LIVE_STREAM_KIND) return "Live Stream";
// Composite labels that differ from the raw kind name
if (kind === 30617) return "Repository";
if (kind === 1617) return "Patch";
if (kind === 1618) return "Pull Request";
if (kind === 30817) return "Custom NIP";
if (kind === BADGE_DEFINITION_KIND) return "Badge Details";
if (kind === BADGE_PROFILE_KIND_NEW || kind === BADGE_PROFILE_KIND_LEGACY) return "Badge Collection";
// Fall back to the central registry
const label = KIND_LABELS[kind];
if (label) return label;
if (kind === BOOK_REVIEW_KIND) return "Book Review";
if (kind === 32267) return "Zapstore App";
if (kind === 30063) return "Zapstore Release";
if (kind === 3063) return "Zapstore Asset";
if (kind === 31990) return "App";
if (kind === 15128 || kind === 35128) return "Nsite";
if (kind === VANISH_KIND) return "Request to Vanish";
if (kind === 20) return "Photo";
if (kind === 4) return "Encrypted Message";
if (kind === 8211) return "Letter";
if (kind === 6 || kind === 16) return "Repost";
if (kind === 7) return "Reaction";
if (kind === 1018) return "Poll Vote";
if (kind === 9735) return "Zap";
if (kind === 0) return "Profile";
if (kind === 31124) return "Blobbi";
return "Post Details";
}
@@ -929,28 +937,11 @@ function BookReviewRating({ event }: { event: NostrEvent }) {
function PostDetailContent({ event }: { event: NostrEvent }) {
const { muteItems } = useMuteList();
const queryClient = useQueryClient();
const location = useLocation();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = getDisplayName(metadata, event.pubkey);
const navigate = useNavigate();
// Auto-play nsite when navigated from a pinned nsite sidebar item.
// Uses React Router state (not URL params) so external URLs cannot trigger auto-launch.
// The state includes a timestamp so each sidebar click produces a distinct key,
// allowing the player to re-open even when already on the same page.
const routeState = location.state as Record<string, unknown> | null;
const nsiteAutoPlayKey = routeState?.nsiteAutoPlay ? (routeState.nsiteAutoPlayTs as number) || 1 : 0;
// Clear the router state after consuming it so a page refresh doesn't re-trigger auto-play.
useEffect(() => {
if (routeState?.nsiteAutoPlay) {
navigate(location.pathname, { replace: true, state: {} });
}
}, [routeState?.nsiteAutoPlay]); // eslint-disable-line react-hooks/exhaustive-deps
// Refetch the author's profile whenever we navigate to a post by this author.
useEffect(() => {
queryClient.refetchQueries({ queryKey: ["author", event.pubkey] });
@@ -2147,7 +2138,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</Suspense>
) : isNsite ? (
<div className="mt-3">
<NsiteCard event={event} autoPlayKey={nsiteAutoPlayKey} />
<NsiteCard event={event} />
</div>
) : isZapstoreApp ? (
<div className="mt-3 rounded-xl border border-border overflow-hidden px-4 pt-4 pb-4">