Compare commits
19 Commits
main
...
iframe-sign
| Author | SHA1 | Date | |
|---|---|---|---|
| f1daf61a65 | |||
| 02cd63dbd9 | |||
| 56d65be19a | |||
| d23db1e5c2 | |||
| 0416b20a46 | |||
| eae9c1d00d | |||
| 72cbc871f5 | |||
| c848e8b51e | |||
| 30886bf9fa | |||
| 156c5f5388 | |||
| e7d35c71c6 | |||
| a074e7c730 | |||
| 9c70c2b42b | |||
| 9822fd2a0b | |||
| c1147063c6 | |||
| eacb0e4371 | |||
| 647b3d414d | |||
| ad7f053129 | |||
| 075025bceb |
@@ -283,16 +283,20 @@ 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
|
||||
- Add an entry in `shellTitleForKind()` for the loading state title
|
||||
- `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")
|
||||
- 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. **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
|
||||
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
|
||||
|
||||
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`.
|
||||
|
||||
@@ -303,7 +307,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. 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.
|
||||
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.
|
||||
|
||||
### NIP.md
|
||||
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
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;
|
||||
@@ -38,140 +26,37 @@ import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
|
||||
* Capacitor plugin that intercepts requests from sandbox iframes in the
|
||||
* main Capacitor WebView.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
@CapacitorPlugin(name = "SandboxPlugin")
|
||||
public class SandboxPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "SandboxPlugin";
|
||||
private final Map<String, SandboxInstance> sandboxes = new HashMap<>();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
@PluginMethod
|
||||
public void create(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
/** Pending requests waiting for JS to respond. */
|
||||
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
|
||||
|
||||
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();
|
||||
});
|
||||
@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));
|
||||
}
|
||||
|
||||
@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");
|
||||
@@ -183,12 +68,6 @@ 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);
|
||||
@@ -202,54 +81,34 @@ public class SandboxPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
sandbox.resolveRequest(requestId, status, statusText, headers, bodyBase64);
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void postMessage(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
JSObject message = call.getObject("message");
|
||||
if (message == null) {
|
||||
call.reject("Missing required parameter: message");
|
||||
return;
|
||||
}
|
||||
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void destroy(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.remove(sandboxId);
|
||||
if (sandbox != null) {
|
||||
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
|
||||
if (parent != null) {
|
||||
parent.removeView(sandbox.container);
|
||||
}
|
||||
sandbox.webView.destroy();
|
||||
}
|
||||
PendingRequest pending = pendingRequests.remove(requestId);
|
||||
if (pending == null) {
|
||||
call.resolve();
|
||||
});
|
||||
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 webResponse = new WebResourceResponse(
|
||||
contentType, encoding, status, statusText, headers, body
|
||||
);
|
||||
|
||||
pending.resolve(webResponse);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
|
||||
@@ -260,159 +119,48 @@ public class SandboxPlugin extends Plugin {
|
||||
notifyListeners("fetch", data);
|
||||
}
|
||||
|
||||
void emitScriptMessage(String sandboxId, JSObject message) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("message", message);
|
||||
notifyListeners("scriptMessage", data);
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
// Custom BridgeWebViewClient that intercepts sandbox iframe requests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single sandboxed WebView instance.
|
||||
* Extends Capacitor's BridgeWebViewClient to additionally intercept
|
||||
* requests from sandbox iframes (URLs matching *.sandbox.native).
|
||||
*/
|
||||
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;
|
||||
private static class SandboxBridgeWebViewClient extends BridgeWebViewClient {
|
||||
private final SandboxPlugin plugin;
|
||||
|
||||
SandboxInstance(String id, SandboxPlugin plugin) {
|
||||
this.id = id;
|
||||
SandboxBridgeWebViewClient(Bridge bridge, SandboxPlugin plugin) {
|
||||
super(bridge);
|
||||
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 url = request.getUrl().toString();
|
||||
String host = request.getUrl().getHost();
|
||||
|
||||
// Only intercept requests to the sandbox domain.
|
||||
if (!url.contains(".sandbox.native")) {
|
||||
return null;
|
||||
// Intercept requests to *.sandbox.native (from sandbox iframes).
|
||||
if (host != null && host.endsWith(".sandbox.native")) {
|
||||
return handleSandboxRequest(request, host);
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Create a pending request with a blocking latch.
|
||||
PendingRequest pending = new PendingRequest();
|
||||
sandbox.pendingRequests.put(requestId, pending);
|
||||
plugin.pendingRequests.put(requestId, pending);
|
||||
|
||||
// Rewrite URL to include the sandbox ID for the JS handler.
|
||||
// Serialise the request for the JS layer.
|
||||
String path = request.getUrl().getPath();
|
||||
if (path == null || path.isEmpty()) path = "/";
|
||||
String rewrittenURL = "https://" + sandbox.id + ".sandbox.native" + path;
|
||||
String rewrittenURL = "https://" + sandboxId + ".sandbox.native" + path;
|
||||
|
||||
// Serialise the request.
|
||||
JSObject serialisedRequest = new JSObject();
|
||||
serialisedRequest.put("url", rewrittenURL);
|
||||
serialisedRequest.put("method", request.getMethod());
|
||||
@@ -425,12 +173,11 @@ public class SandboxPlugin extends Plugin {
|
||||
serialisedRequest.put("body", JSONObject.NULL);
|
||||
|
||||
// Emit to JS.
|
||||
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
|
||||
plugin.emitFetchRequest(sandboxId, requestId, serialisedRequest);
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
WebResourceResponse response = pending.awaitResponse(60000);
|
||||
|
||||
if (response != null) {
|
||||
@@ -438,98 +185,21 @@ public class SandboxPlugin extends Plugin {
|
||||
}
|
||||
|
||||
// Timeout — return error response.
|
||||
sandbox.pendingRequests.remove(requestId);
|
||||
plugin.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) {}" +
|
||||
" }" +
|
||||
"};" +
|
||||
"})();";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
// Pending request helper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A pending request that blocks the WebViewClient IO thread until JS
|
||||
* responds with the complete resource.
|
||||
* A pending request that blocks the WebView IO thread until JS responds.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private volatile WebResourceResponse response;
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
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
|
||||
|
||||
+118
-429
@@ -2,432 +2,48 @@ import Foundation
|
||||
import Capacitor
|
||||
import WebKit
|
||||
|
||||
// MARK: - Plugin
|
||||
// MARK: - Shared Handler Singleton
|
||||
|
||||
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
/// 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 {
|
||||
private var pendingTasks: [String: WKURLSchemeTask] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
|
||||
self.sandboxId = sandboxId
|
||||
self.scheme = scheme
|
||||
self.plugin = plugin
|
||||
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
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -443,10 +59,9 @@ private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
pendingTasks[requestId] = urlSchemeTask
|
||||
lock.unlock()
|
||||
|
||||
// Serialise the request for the fetch event.
|
||||
// Rewrite the URL so it looks like a normal HTTP URL to the parent
|
||||
// (e.g. "sbx-abc123://app/index.html" -> "https://<sandboxId>.sandbox.native/index.html")
|
||||
// The JS side only cares about the pathname.
|
||||
// Extract the sandbox ID from the hostname: sbx://<sandbox-id>/path
|
||||
let sandboxId = url.host ?? "unknown"
|
||||
|
||||
var headers: [String: String] = [:]
|
||||
if let allHeaders = request.allHTTPHeaderFields {
|
||||
headers = allHeaders
|
||||
@@ -458,6 +73,8 @@ private class SandboxSchemeHandler: 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] = [
|
||||
@@ -475,7 +92,6 @@ private class SandboxSchemeHandler: 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 {
|
||||
@@ -484,7 +100,8 @@ private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Called by the plugin when JS responds to a fetch request.
|
||||
// MARK: Response Resolution
|
||||
|
||||
func resolveRequest(
|
||||
requestId: String,
|
||||
status: Int,
|
||||
@@ -499,15 +116,12 @@ private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
// Decode the base64 body.
|
||||
var bodyData: Data? = nil
|
||||
if let b64 = bodyBase64 {
|
||||
bodyData = Data(base64Encoded: b64)
|
||||
}
|
||||
|
||||
// Build the response.
|
||||
// Use the task's original URL for the response.
|
||||
let responseURL = task.request.url ?? URL(string: "\(scheme)://app/")!
|
||||
let responseURL = task.request.url ?? URL(string: "sbx://unknown/")!
|
||||
let response = HTTPURLResponse(
|
||||
url: responseURL,
|
||||
statusCode: status,
|
||||
@@ -524,7 +138,6 @@ private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all pending tasks (called on destroy).
|
||||
func cancelAll() {
|
||||
lock.lock()
|
||||
let tasks = pendingTasks
|
||||
@@ -539,3 +152,79 @@ private class SandboxSchemeHandler: 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,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.6.5",
|
||||
"version": "2.6.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.6.5",
|
||||
"version": "2.6.6",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
|
||||
@@ -26,6 +26,7 @@ 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';
|
||||
|
||||
@@ -1080,16 +1081,7 @@ 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);
|
||||
@@ -1105,7 +1097,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 WELL_KNOWN_KIND_LABELS[addr.kind] ?? `Kind ${addr.kind}`;
|
||||
return getKindLabel(addr.kind);
|
||||
}, [kindDef, addr.kind]);
|
||||
|
||||
const KindIcon = useMemo(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -138,10 +139,17 @@ 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}>
|
||||
<MainLayoutInner />
|
||||
<NsitePlayerContext.Provider value={nsitePlayer}>
|
||||
<MainLayoutInner />
|
||||
</NsitePlayerContext.Provider>
|
||||
</LayoutStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ 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';
|
||||
@@ -326,7 +327,13 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
|
||||
|
||||
const nip19Id = encodeEventNip19(event);
|
||||
const nostrUri = `nostr:${nip19Id}`;
|
||||
const isInSidebar = orderedItems.includes(nostrUri);
|
||||
|
||||
// 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 close = () => onOpenChange(false);
|
||||
|
||||
@@ -349,10 +356,10 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
if (isInSidebar) {
|
||||
removeFromSidebar(nostrUri);
|
||||
removeFromSidebar(sidebarUri);
|
||||
toast({ title: 'Removed from sidebar' });
|
||||
} else {
|
||||
addToSidebar(nostrUri);
|
||||
addToSidebar(sidebarUri);
|
||||
toast({ title: 'Added to sidebar' });
|
||||
}
|
||||
close();
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import type { NostrEvent } from "@nostrify/nostrify";
|
||||
import { ExternalLink, FileText, Globe, Play, Server } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ExternalLink, FileText, Globe, Pin, PinOff, Play, Server } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, 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;
|
||||
}
|
||||
|
||||
/** Build the nsite.lol gateway URL for an nsite event. */
|
||||
function getNsiteUrl(event: NostrEvent): string {
|
||||
return `https://${getNsiteSubdomain(event)}.nsite.lol`;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Renders an nsite deployment card with a rich link preview. */
|
||||
export function NsiteCard({ event }: NsiteCardProps) {
|
||||
export function NsiteCard({ event, autoPlayKey }: 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];
|
||||
@@ -30,14 +34,62 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
const serverTags = event.tags.filter(([n]) => n === "server");
|
||||
|
||||
const isNamed = event.kind === 35128 && !!dTag;
|
||||
const siteUrl = getNsiteUrl(event);
|
||||
const nsiteSubdomain = getNsiteSubdomain(event);
|
||||
const siteUrl = `https://${nsiteSubdomain}.nsite.lol`;
|
||||
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 [previewOpen, setPreviewOpen] = useState(false);
|
||||
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
|
||||
|
||||
if (isLoading) {
|
||||
return <NsiteCardSkeleton />;
|
||||
@@ -115,7 +167,7 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
|
||||
onClick={(e) => { e.stopPropagation(); handlePreviewOpenChange(true); }}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
Run
|
||||
@@ -145,6 +197,17 @@ export function NsiteCard({ event }: 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>
|
||||
|
||||
@@ -153,7 +216,7 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
appName={previewTitle || displayName || "nsite"}
|
||||
appPicture={undefined}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
onOpenChange={handlePreviewOpenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,21 @@ 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';
|
||||
@@ -197,13 +204,21 @@ 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[]>([]);
|
||||
@@ -224,11 +239,24 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
servers.current = resolveServers(event, appServers.length > 0 ? appServers : APP_BLOSSOM_SERVERS.servers);
|
||||
}, [event, config.blossomServerMetadata, config.useAppBlossomServers]);
|
||||
|
||||
/** Injected scripts: just the path normalisation snippet for SPA support. */
|
||||
const injectedScripts = useMemo<InjectedScript[]>(() => [{
|
||||
path: '__injected__/preview.js',
|
||||
content: getPreviewInjectedScript(),
|
||||
}], []);
|
||||
/** 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]);
|
||||
|
||||
/**
|
||||
* Called by SandboxFrame before the native WebView is created.
|
||||
@@ -307,46 +335,67 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
}}
|
||||
>
|
||||
{/* Nav bar */}
|
||||
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
|
||||
{/* 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>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{appName}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
{/* Permissions + Close */}
|
||||
{user && (
|
||||
<NsitePermissionManager siteId={nsiteSubdomain} siteName={appName} />
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Sandboxed iframe */}
|
||||
<div className="flex-1 min-h-0 bg-background">
|
||||
<div className="flex-1 min-h-0 bg-background relative">
|
||||
<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,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+132
-158
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import {
|
||||
@@ -25,7 +27,6 @@ import type {
|
||||
import {
|
||||
SandboxPlugin,
|
||||
type SandboxFetchEvent,
|
||||
type SandboxScriptMessageEvent,
|
||||
} from '@/lib/sandboxPlugin';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -324,6 +325,7 @@ const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${origin}/`}
|
||||
allow="clipboard-write"
|
||||
{...iframeProps}
|
||||
/>
|
||||
);
|
||||
@@ -331,17 +333,37 @@ const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native (Capacitor) implementation
|
||||
// Native (Capacitor) implementation — uses a real <iframe> served by native
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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, title },
|
||||
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, className, style, ...iframeProps },
|
||||
ref,
|
||||
) {
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const createdRef = useRef(false);
|
||||
const destroyedRef = useRef(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const readyRef = useRef(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const origin = useMemo(() => getNativeOrigin(id), [id]);
|
||||
|
||||
// Keep latest callbacks in refs.
|
||||
const resolveFileRef = useRef(resolveFile);
|
||||
@@ -357,17 +379,14 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Post a message into the native sandbox
|
||||
// Post a message to the iframe via postMessage
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
const postToSandbox = useCallback(
|
||||
const post = useCallback(
|
||||
(msg: Record<string, unknown>) => {
|
||||
if (!createdRef.current || destroyedRef.current) return;
|
||||
SandboxPlugin.postMessage({ id, message: msg }).catch((err) => {
|
||||
console.error('[SandboxFrame] postMessage failed:', err);
|
||||
});
|
||||
iframeRef.current?.contentWindow?.postMessage(msg, origin);
|
||||
},
|
||||
[id],
|
||||
[origin],
|
||||
);
|
||||
|
||||
// Expose imperative handle.
|
||||
@@ -375,38 +394,27 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
ref,
|
||||
() => ({
|
||||
postMessage: (msg: Record<string, unknown>) => {
|
||||
postToSandbox(msg);
|
||||
post(msg);
|
||||
},
|
||||
focus: () => {
|
||||
// No-op on native — the WebView is overlaid, not an iframe.
|
||||
iframeRef.current?.focus();
|
||||
},
|
||||
}),
|
||||
[postToSandbox],
|
||||
[post],
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Lifecycle: onReady -> create WebView -> listen for events -> destroy
|
||||
// Handle fetch events from the native scheme handler
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (createdRef.current) return;
|
||||
|
||||
const listeners: Array<{ remove: () => void }> = [];
|
||||
let cancelled = false;
|
||||
const listeners: Array<{ remove: () => void }> = [];
|
||||
|
||||
async function setup() {
|
||||
// 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).
|
||||
// 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.
|
||||
const fetchListener = await SandboxPlugin.addListener(
|
||||
'fetch',
|
||||
(event: SandboxFetchEvent) => {
|
||||
@@ -416,57 +424,46 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
);
|
||||
listeners.push(fetchListener);
|
||||
|
||||
const scriptListener = await SandboxPlugin.addListener(
|
||||
'scriptMessage',
|
||||
(event: SandboxScriptMessageEvent) => {
|
||||
if (event.id !== id) return;
|
||||
handleNativeScriptMessage(event);
|
||||
},
|
||||
);
|
||||
listeners.push(scriptListener);
|
||||
if (cancelled) return;
|
||||
|
||||
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.
|
||||
// Run onReady (e.g. Android pre-fetches all blobs here).
|
||||
try {
|
||||
await onReadyRef.current?.();
|
||||
} catch (err) {
|
||||
console.error('[SandboxFrame] onReady failed:', err);
|
||||
}
|
||||
|
||||
if (cancelled || destroyedRef.current) return;
|
||||
if (cancelled) return;
|
||||
|
||||
// Start loading the sandbox content — fetch events will now fire
|
||||
// and be handled by the listeners registered above.
|
||||
await SandboxPlugin.navigate({ id });
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Handle a fetch request from the native WebView
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleNativeFetch(event: SandboxFetchEvent) {
|
||||
const reqUrl = event.request.url;
|
||||
|
||||
@@ -474,9 +471,6 @@ 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] ?? '/';
|
||||
}
|
||||
@@ -488,7 +482,6 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
cspRef.current,
|
||||
(result) => {
|
||||
SandboxPlugin.respondToFetch({
|
||||
id,
|
||||
requestId: event.requestId,
|
||||
response: result as {
|
||||
status: number;
|
||||
@@ -502,7 +495,6 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
},
|
||||
(_code, message) => {
|
||||
SandboxPlugin.respondToFetch({
|
||||
id,
|
||||
requestId: event.requestId,
|
||||
response: {
|
||||
status: 500,
|
||||
@@ -517,104 +509,78 @@ 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, postToSandbox]);
|
||||
}, [id, origin]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 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.
|
||||
// Listen for postMessage from the iframe (RPC from injected scripts)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
const el = placeholderRef.current;
|
||||
if (!el) return;
|
||||
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;
|
||||
|
||||
function updateFrame() {
|
||||
if (!createdRef.current || destroyedRef.current) return;
|
||||
const rect = el!.getBoundingClientRect();
|
||||
SandboxPlugin.updateFrame({
|
||||
id,
|
||||
frame: {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
},
|
||||
}).catch(() => {
|
||||
// Ignore — WebView may not be created yet.
|
||||
});
|
||||
const 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 ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(updateFrame);
|
||||
ro.observe(el);
|
||||
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) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [id]);
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [origin, post]);
|
||||
|
||||
// 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
|
||||
ref={placeholderRef}
|
||||
className={className}
|
||||
style={style}
|
||||
title={title}
|
||||
data-sandbox-id={id}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -629,9 +595,17 @@ 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 native
|
||||
* WKWebView/WebView overlay with a custom URL scheme handler that intercepts
|
||||
* all requests and routes them through the same `resolveFile` callback.
|
||||
* 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.
|
||||
*
|
||||
* All file serving is delegated to the `resolveFile` callback.
|
||||
* Custom RPC methods are delegated to the optional `onRpc` callback.
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -152,6 +153,21 @@ 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;
|
||||
|
||||
@@ -224,7 +240,7 @@ export function SidebarMoreMenu({
|
||||
setLinkError('');
|
||||
}
|
||||
}}
|
||||
placeholder="URL, npub1..., iso3166:US, ..."
|
||||
placeholder="URL, npub1..., nsite://..., ..."
|
||||
className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isNostrUri, isExternalUri } from '@/lib/sidebarItems';
|
||||
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isNostrUri, isExternalUri, isNsiteUri } 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 ─────────────────────────────────────────────────────────────
|
||||
@@ -181,6 +182,19 @@ 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
|
||||
|
||||
@@ -127,43 +127,45 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Nav bar */}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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,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 } from "@/lib/sidebarItems";
|
||||
import { SIDEBAR_ITEMS, SIDEBAR_ITEM_IDS, SIDEBAR_DIVIDER_ID, isNostrUri, isExternalUri, isNsiteUri } 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)) {
|
||||
if (SIDEBAR_ITEM_IDS.has(item) || isNostrUri(item) || isExternalUri(item) || isNsiteUri(item)) {
|
||||
ordered.push(item);
|
||||
}
|
||||
// else: unknown entry — skip
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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) {}
|
||||
})();`;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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,6 +1,9 @@
|
||||
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;
|
||||
@@ -8,7 +11,61 @@ export function hexToBase36(hex: string): string {
|
||||
n = n * 16n + BigInt(parseInt(hex[i], 16));
|
||||
}
|
||||
const b36 = n.toString(36);
|
||||
return b36.padStart(50, '0');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+30
-70
@@ -1,13 +1,19 @@
|
||||
/**
|
||||
* SandboxPlugin — Capacitor plugin for native sandboxed WebViews.
|
||||
* SandboxPlugin — Capacitor plugin for native sandbox iframe support.
|
||||
*
|
||||
* 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 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.
|
||||
*
|
||||
* The plugin is registered as "SandboxPlugin" and is only usable on native
|
||||
* platforms. On web, SandboxFrame uses iframe.diy directly.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
@@ -17,24 +23,11 @@ import type { PluginListenerHandle } from '@capacitor/core';
|
||||
// Plugin method options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Options for creating a new sandbox WebView. */
|
||||
export interface SandboxCreateOptions {
|
||||
/** Unique identifier for this sandbox (the HMAC-derived subdomain ID). */
|
||||
id: string;
|
||||
/** Absolute position and size of the WebView within the app window. */
|
||||
frame: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
/** Options for updating the WebView frame (position/size). */
|
||||
export interface SandboxUpdateFrameOptions {
|
||||
id: string;
|
||||
frame: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
/** A serialised fetch response sent back to the native WebView. */
|
||||
/** A serialised fetch response sent back to the native scheme handler. */
|
||||
export interface SandboxRespondToFetchOptions {
|
||||
id: string;
|
||||
/** Unique request ID from the fetch event. */
|
||||
requestId: string;
|
||||
/** The serialised HTTP response. */
|
||||
response: {
|
||||
status: number;
|
||||
statusText: string;
|
||||
@@ -43,24 +36,13 @@ 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 WebView's URL scheme handler. */
|
||||
/** A fetch request forwarded from the native scheme handler. */
|
||||
export interface SandboxFetchEvent {
|
||||
/** The sandbox ID this request belongs to. */
|
||||
/** The sandbox ID (hostname) this request belongs to. */
|
||||
id: string;
|
||||
/** Unique request ID — pass back to `respondToFetch`. */
|
||||
requestId: string;
|
||||
@@ -73,53 +55,31 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Options for navigating the sandbox WebView to its entry point. */
|
||||
export interface SandboxNavigateOptions {
|
||||
id: string;
|
||||
/** Diagnostic state returned by the native plugin. */
|
||||
export interface SandboxDiagnostics {
|
||||
sandboxHandlerSet: boolean;
|
||||
pluginConnected: boolean;
|
||||
bridgeHasWebView: boolean;
|
||||
hasListenersFetch: boolean;
|
||||
pendingTaskCount: number;
|
||||
}
|
||||
|
||||
export interface SandboxPluginInterface {
|
||||
/** 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. */
|
||||
/** Send a fetch response back to the native scheme handler for a pending request. */
|
||||
respondToFetch(options: SandboxRespondToFetchOptions): Promise<void>;
|
||||
|
||||
/** Post a JSON-RPC message to injected scripts inside the sandbox. */
|
||||
postMessage(options: SandboxPostMessageOptions): Promise<void>;
|
||||
/** Return diagnostic state from the native side (iOS only). */
|
||||
diagnose(): Promise<SandboxDiagnostics>;
|
||||
|
||||
/** Destroy a sandbox WebView and clean up all resources. */
|
||||
destroy(options: SandboxDestroyOptions): Promise<void>;
|
||||
|
||||
/** Listen for fetch requests from the native WebView. */
|
||||
/** Listen for fetch requests from sandbox iframes intercepted by native code. */
|
||||
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>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -129,6 +89,6 @@ export interface SandboxPluginInterface {
|
||||
/**
|
||||
* The SandboxPlugin Capacitor plugin.
|
||||
* Only usable on native platforms (iOS/Android). On web, SandboxFrame
|
||||
* falls back to the iframe.diy service worker sandbox.
|
||||
* uses the iframe.diy fetch proxy sandbox.
|
||||
*/
|
||||
export const SandboxPlugin = registerPlugin<SandboxPluginInterface>('SandboxPlugin');
|
||||
|
||||
@@ -59,6 +59,16 @@ 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;
|
||||
@@ -278,6 +288,15 @@ 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}`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NostrEvent, NostrSigner } from '@nostrify/types';
|
||||
import { createElement } from 'react';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { NudgeToastContent } from '@/components/SignerToastContent';
|
||||
import { getKindLabel } from '@/lib/kindLabels';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -25,18 +26,15 @@ function isAndroid(): boolean {
|
||||
|
||||
type OpType = 'sign' | 'encrypt' | 'decrypt';
|
||||
|
||||
/** Human-readable labels for event kinds shown in nudge toasts. */
|
||||
const KIND_LABELS: Record<number, string> = {
|
||||
/**
|
||||
* 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> = {
|
||||
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',
|
||||
@@ -54,7 +52,11 @@ const KIND_LABELS: Record<number, string> = {
|
||||
};
|
||||
|
||||
function labelForOp(kind: number | undefined, opType: OpType): string {
|
||||
if (kind !== undefined && KIND_LABELS[kind]) return KIND_LABELS[kind];
|
||||
if (kind !== undefined) {
|
||||
if (NUDGE_OVERRIDES[kind]) return NUDGE_OVERRIDES[kind];
|
||||
const central = getKindLabel(kind, '');
|
||||
if (central) return central.toLowerCase();
|
||||
}
|
||||
if (opType === 'encrypt') return 'encryption';
|
||||
if (opType === 'decrypt') return 'decryption';
|
||||
return 'signing';
|
||||
|
||||
@@ -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, useNavigate } from "react-router-dom";
|
||||
import { Link, useLocation, 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,6 +92,7 @@ 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";
|
||||
|
||||
@@ -123,36 +124,27 @@ 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. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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";
|
||||
if (kind === 30617) return "Repository";
|
||||
if (kind === 1617) return "Patch";
|
||||
if (kind === 1618) return "Pull Request";
|
||||
if (kind === 30817) return "Custom NIP";
|
||||
// Composite labels that differ from the raw kind name
|
||||
if (kind === BADGE_DEFINITION_KIND) return "Badge Details";
|
||||
if (kind === BADGE_PROFILE_KIND_NEW || kind === BADGE_PROFILE_KIND_LEGACY) return "Badge Collection";
|
||||
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";
|
||||
// Fall back to the central registry
|
||||
const label = KIND_LABELS[kind];
|
||||
if (label) return label;
|
||||
return "Post Details";
|
||||
}
|
||||
|
||||
@@ -937,11 +929,28 @@ 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] });
|
||||
@@ -2138,7 +2147,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
</Suspense>
|
||||
) : isNsite ? (
|
||||
<div className="mt-3">
|
||||
<NsiteCard event={event} />
|
||||
<NsiteCard event={event} autoPlayKey={nsiteAutoPlayKey} />
|
||||
</div>
|
||||
) : isZapstoreApp ? (
|
||||
<div className="mt-3 rounded-xl border border-border overflow-hidden px-4 pt-4 pb-4">
|
||||
|
||||
Reference in New Issue
Block a user