Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd333b9584 | |||
| 3ac1dc6b0a | |||
| 025ecd8645 | |||
| 0fca39a1bd | |||
| 3152f7f0ec | |||
| 7cba044b9d | |||
| 4245b2aede | |||
| 3cdec3ceb6 | |||
| aa8f7539ae | |||
| c6b3cb8758 | |||
| 59f68efdc7 | |||
| dc81585f9a | |||
| 54e6c964db | |||
| dceda199c3 | |||
| 173f789242 | |||
| f4363dcbff |
+30
-1
@@ -219,7 +219,7 @@ publish-zapstore:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
SIGN_WITH: $ZAPSTORE_BUNKER_URL
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub"
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub,wss://relay.dreamith.to,wss://relay.primal.net"
|
||||
BLOSSOM_URL: "https://blossom.ditto.pub"
|
||||
script:
|
||||
- go install github.com/zapstore/zsp@latest
|
||||
@@ -235,3 +235,32 @@ publish-zapstore:
|
||||
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
|
||||
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
|
||||
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
|
||||
|
||||
publish-google-play:
|
||||
stage: publish
|
||||
image: ruby:3.3
|
||||
needs:
|
||||
- build-apk
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
- gem install fastlane --no-document
|
||||
|
||||
# Decode base64-encoded service account JSON to a temp file
|
||||
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json
|
||||
|
||||
# Upload the AAB to Google Play production track
|
||||
- >-
|
||||
fastlane supply
|
||||
--aab artifacts/Ditto.aab
|
||||
--package_name pub.ditto.app
|
||||
--track production
|
||||
--json_key /tmp/play-service-account.json
|
||||
--skip_upload_metadata
|
||||
--skip_upload_changelogs
|
||||
--skip_upload_images
|
||||
--skip_upload_screenshots
|
||||
--skip_upload_apk
|
||||
|
||||
# Clean up
|
||||
- rm -f /tmp/play-service-account.json
|
||||
|
||||
@@ -1484,7 +1484,7 @@ The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
|
||||
2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only)
|
||||
3. **build** - Builds a signed release APK (`build-apk` job, tags only)
|
||||
4. **release** - Creates a GitLab Release with the APK artifact (tags only)
|
||||
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
|
||||
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only) and AAB to Google Play (`publish-google-play` job, tags only)
|
||||
|
||||
### Creating a Release
|
||||
|
||||
@@ -1494,7 +1494,7 @@ Releases are triggered by pushing a version tag. Use the npm script:
|
||||
npm run release
|
||||
```
|
||||
|
||||
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, and `publish-zapstore` stages.
|
||||
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` stages.
|
||||
|
||||
### Zapstore Publishing
|
||||
|
||||
@@ -1586,4 +1586,29 @@ The `--use-fallback-relays` and `--use-fallback-servers` flags also include nsyt
|
||||
To rotate the nsite credential:
|
||||
1. Revoke the old bunker connection in your signer app
|
||||
2. Run `nsyte ci` again to generate a new `nbunksec1...` string
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
|
||||
|
||||
### Google Play Publishing
|
||||
|
||||
The project automatically publishes Android AABs (App Bundles) to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). The `publish-google-play` CI job runs after a successful AAB build and uploads directly to the production track.
|
||||
|
||||
**GitLab CI/CD Variables** (Settings > CI/CD > Variables):
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Full JSON contents of the Google Play API service account key file | Yes | Yes | No |
|
||||
|
||||
#### Initial Setup (one-time)
|
||||
|
||||
1. Create or reuse a project in the [Google Cloud Console](https://console.cloud.google.com/projectcreate)
|
||||
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project
|
||||
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it
|
||||
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`
|
||||
5. Add the full JSON contents of the key file as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
|
||||
|
||||
#### Key Points
|
||||
|
||||
- The job uploads the signed AAB (not APK) since Google Play requires App Bundles
|
||||
- Uploads go directly to the **production** track -- Google's review process still applies before the update reaches users
|
||||
- Metadata, screenshots, and changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.)
|
||||
- The same signing keystore used for Zapstore is used here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`)
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.5] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
|
||||
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
|
||||
|
||||
### Fixed
|
||||
- External API requests on Android no longer fail due to hostname restrictions
|
||||
- iOS App Store compliance issues resolved
|
||||
|
||||
## [2.6.4] - 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.6.4"
|
||||
versionName "2.6.5"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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;
|
||||
@@ -13,6 +15,8 @@ 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.JSObject;
|
||||
@@ -30,6 +34,8 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
|
||||
@@ -79,19 +85,41 @@ public class SandboxPlugin extends Plugin {
|
||||
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
|
||||
sandboxes.put(sandboxId, sandbox);
|
||||
|
||||
// Add the WebView on top of the Capacitor WebView.
|
||||
// The parent is a CoordinatorLayout — using the wrong LayoutParams
|
||||
// type causes a ClassCastException when it intercepts touch events.
|
||||
// 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.webView, params);
|
||||
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;
|
||||
}
|
||||
|
||||
// Load the initial page.
|
||||
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
@@ -131,7 +159,7 @@ public class SandboxPlugin extends Plugin {
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
sandbox.webView.setLayoutParams(params);
|
||||
sandbox.container.setLayoutParams(params);
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
@@ -214,9 +242,9 @@ public class SandboxPlugin extends Plugin {
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.remove(sandboxId);
|
||||
if (sandbox != null) {
|
||||
ViewGroup parent = (ViewGroup) sandbox.webView.getParent();
|
||||
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
|
||||
if (parent != null) {
|
||||
parent.removeView(sandbox.webView);
|
||||
parent.removeView(sandbox.container);
|
||||
}
|
||||
sandbox.webView.destroy();
|
||||
}
|
||||
@@ -244,13 +272,19 @@ public class SandboxPlugin extends Plugin {
|
||||
*/
|
||||
private static class SandboxInstance {
|
||||
final String id;
|
||||
/** Wrapper layout that holds the WebView and the loading overlay. */
|
||||
final FrameLayout container;
|
||||
final WebView webView;
|
||||
final SandboxPlugin plugin;
|
||||
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
|
||||
/** Native spinner overlay, shown while the sandbox content loads. */
|
||||
private ProgressBar spinner;
|
||||
|
||||
SandboxInstance(String id, SandboxPlugin plugin) {
|
||||
this.id = id;
|
||||
this.plugin = plugin;
|
||||
|
||||
this.container = new FrameLayout(plugin.getActivity());
|
||||
this.webView = new WebView(plugin.getActivity());
|
||||
|
||||
WebSettings settings = webView.getSettings();
|
||||
@@ -260,13 +294,53 @@ public class SandboxPlugin extends Plugin {
|
||||
settings.setAllowContentAccess(false);
|
||||
settings.setDatabaseEnabled(true);
|
||||
|
||||
webView.setBackgroundColor(Color.WHITE);
|
||||
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) {
|
||||
@@ -353,8 +427,11 @@ public class SandboxPlugin extends Plugin {
|
||||
// Emit to JS.
|
||||
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
|
||||
|
||||
// Block this thread until JS responds (with a timeout).
|
||||
WebResourceResponse response = pending.awaitResponse(10000);
|
||||
// Block until JS responds. Each asset is fetched from a Blossom
|
||||
// server over the network, so we need a generous timeout. The
|
||||
// WebView IO thread pool has ~6 threads; if all are blocked,
|
||||
// subsequent requests queue until a thread frees up.
|
||||
WebResourceResponse response = pending.awaitResponse(60000);
|
||||
|
||||
if (response != null) {
|
||||
return response;
|
||||
@@ -377,6 +454,11 @@ public class SandboxPlugin extends Plugin {
|
||||
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() {
|
||||
@@ -446,11 +528,12 @@ public class SandboxPlugin extends Plugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending request that blocks the WebViewClient thread until resolved.
|
||||
* A pending request that blocks the WebViewClient IO thread until JS
|
||||
* responds with the complete resource.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private WebResourceResponse response;
|
||||
private final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
|
||||
private volatile WebResourceResponse response;
|
||||
private final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
void resolve(WebResourceResponse response) {
|
||||
this.response = response;
|
||||
@@ -459,7 +542,7 @@ public class SandboxPlugin extends Plugin {
|
||||
|
||||
WebResourceResponse awaitResponse(long timeoutMs) {
|
||||
try {
|
||||
latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ const config: CapacitorConfig = {
|
||||
appName: 'Ditto',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
// Handle deep links from your domain
|
||||
hostname: 'ditto.pub',
|
||||
androidScheme: 'https',
|
||||
iosScheme: 'https'
|
||||
},
|
||||
@@ -21,9 +19,6 @@ const config: CapacitorConfig = {
|
||||
scheme: 'Ditto'
|
||||
},
|
||||
plugins: {
|
||||
Keyboard: {
|
||||
resizeOnFullScreen: true,
|
||||
},
|
||||
SystemBars: {
|
||||
// Inject --safe-area-inset-* CSS variables on Android to work around
|
||||
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<title>Ditto — Your content. Your vibe. Your rules.</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
|
||||
|
||||
<!-- Open Graph -->
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
|
||||
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -33,6 +34,7 @@
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPlugin.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40004000100000002 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -76,6 +78,7 @@
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
@@ -153,6 +156,7 @@
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -315,7 +319,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.4;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -339,7 +343,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.4;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -53,5 +53,7 @@
|
||||
<string>Ditto needs camera access to take photos and videos for your posts.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Ditto needs access to your microphone to record voice messages.</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -17,6 +17,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
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),
|
||||
@@ -58,16 +59,33 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
)
|
||||
self.sandboxes[sandboxId] = sandbox
|
||||
|
||||
// Add the WebView on top of the Capacitor WebView.
|
||||
// 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.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")
|
||||
@@ -87,7 +105,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.webView.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
sandbox.containerView.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
@@ -153,7 +171,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
|
||||
sandbox.webView.removeFromSuperview()
|
||||
sandbox.containerView.removeFromSuperview()
|
||||
sandbox.schemeHandler.cancelAll()
|
||||
}
|
||||
call.resolve()
|
||||
@@ -183,13 +201,19 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// MARK: - SandboxInstance
|
||||
|
||||
/// Manages a single sandboxed WKWebView instance.
|
||||
private class SandboxInstance: NSObject, WKScriptMessageHandler {
|
||||
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
|
||||
@@ -224,19 +248,54 @@ private class SandboxInstance: NSObject, WKScriptMessageHandler {
|
||||
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
|
||||
self.webView = WKWebView(frame: frame, configuration: config)
|
||||
// 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 = .white
|
||||
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 after super.init().
|
||||
// Register the message handler and navigation delegate after super.init().
|
||||
userContentController.add(self, name: "sandboxBridge")
|
||||
self.webView.navigationDelegate = self
|
||||
}
|
||||
|
||||
// Load the initial page via the custom scheme.
|
||||
let initialURL = URL(string: "\(self.customScheme)://app/index.html")!
|
||||
self.webView.load(URLRequest(url: initialURL))
|
||||
/// 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.
|
||||
@@ -270,6 +329,13 @@ private class SandboxInstance: NSObject, WKScriptMessageHandler {
|
||||
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:
|
||||
|
||||
Generated
+10
-10
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.5",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/keyboard": "^8.0.2",
|
||||
"@capacitor/keyboard": "^8.0.3",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capgo/capacitor-autofill-save-password": "^8.0.22",
|
||||
@@ -60,7 +60,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.0",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -429,9 +429,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/keyboard": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.2.tgz",
|
||||
"integrity": "sha512-he6xKmTBp5AhVrWJeEi6RYkJ25FjLLdNruBU2wafpITk3Nb7UdzOj96x3K6etFuEj8/rtn9WXBTs1o2XA86A1A==",
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz",
|
||||
"integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
@@ -2584,9 +2584,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.0.tgz",
|
||||
"integrity": "sha512-IQf74SSusSIyhI9FkUQSUTsX20yeww5xHIUeexvxcWXEpVhYJYCwduK2yRB75NvYgXjcqYeDUGA2RvzBhDc/eA==",
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.1.tgz",
|
||||
"integrity": "sha512-gQUct8A7KLKvoLtv4bHpVDfmvzJlIHjZZI6DMui8vrSuzm8IqMRdAYADbR3ry1mlIQp8/c4EeR24piBpHK0WUw==",
|
||||
"dependencies": {
|
||||
"@nostrify/nostrify": "0.51.1",
|
||||
"@nostrify/types": "0.36.9"
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.6.4",
|
||||
"version": "2.6.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -18,7 +18,7 @@
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/keyboard": "^8.0.2",
|
||||
"@capacitor/keyboard": "^8.0.3",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capgo/capacitor-autofill-save-password": "^8.0.22",
|
||||
@@ -67,7 +67,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.0",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.5] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
|
||||
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
|
||||
|
||||
### Fixed
|
||||
- External API requests on Android no longer fail due to hostname restrictions
|
||||
- iOS App Store compliance issues resolved
|
||||
|
||||
## [2.6.4] - 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -3,6 +3,7 @@ import data from '@emoji-mart/data';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
|
||||
import { usePortalDropdown } from '@/hooks/usePortalDropdown';
|
||||
|
||||
interface EmojiData {
|
||||
id: string;
|
||||
@@ -186,6 +187,14 @@ export function EmojiShortcodeAutocomplete({
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const { computePosition, renderPortal } = usePortalDropdown({
|
||||
textareaRef,
|
||||
isOpen,
|
||||
onClose: handleClose,
|
||||
dropdownHeight: 280, // must match max-h-[280px] below
|
||||
});
|
||||
|
||||
const results = useMemo(() => searchEmojis(query, customEmojis), [query, customEmojis]);
|
||||
|
||||
// Detect :shortcode query at cursor
|
||||
@@ -237,14 +246,11 @@ export function EmojiShortcodeAutocomplete({
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(0);
|
||||
|
||||
// Position the dropdown below the : character
|
||||
// Position the dropdown using fixed viewport coordinates so it isn't
|
||||
// clipped by ancestor overflow containers (e.g. the compose modal).
|
||||
const coords = getCaretCoordinates(textarea, colonPos);
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
|
||||
setDropdownPos({
|
||||
top: coords.top + lineHeight + 4,
|
||||
left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)),
|
||||
});
|
||||
}, [textareaRef]);
|
||||
setDropdownPos(computePosition(coords));
|
||||
}, [textareaRef, computePosition]);
|
||||
|
||||
// Listen for input/cursor changes on the textarea element
|
||||
useEffect(() => {
|
||||
@@ -357,10 +363,10 @@ export function EmojiShortcodeAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-[100] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[280px] overflow-y-auto py-1">
|
||||
@@ -382,7 +388,7 @@ export function EmojiShortcodeAutocomplete({
|
||||
className="size-5 object-contain shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl leading-none shrink-0">{emoji.native}</span>
|
||||
<span className="text-xl leading-none shrink-0 font-emoji">{emoji.native}</span>
|
||||
)}
|
||||
<span className="text-sm truncate">
|
||||
:{emoji.id.replace('custom:', '')}:
|
||||
@@ -392,4 +398,8 @@ export function EmojiShortcodeAutocomplete({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal to document.body so the dropdown escapes any ancestor overflow
|
||||
// clipping and CSS transform containing blocks (e.g. Radix Dialog).
|
||||
return renderPortal(dropdown, document.body);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useNip05Verify } from '@/hooks/useNip05Verify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePortalDropdown } from '@/hooks/usePortalDropdown';
|
||||
|
||||
interface MentionAutocompleteProps {
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
@@ -89,6 +90,14 @@ export function MentionAutocomplete({
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const { computePosition, renderPortal } = usePortalDropdown({
|
||||
textareaRef,
|
||||
isOpen,
|
||||
onClose: handleClose,
|
||||
dropdownHeight: 240, // must match max-h-[240px] below
|
||||
});
|
||||
|
||||
const { data: profiles, followedPubkeys } = useSearchProfiles(
|
||||
isOpen ? mentionQuery : '',
|
||||
);
|
||||
@@ -140,15 +149,11 @@ export function MentionAutocomplete({
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(0);
|
||||
|
||||
// Position the dropdown below the @ character, relative to the textarea's
|
||||
// offsetParent (the `relative` wrapper div) so it stays inside the modal.
|
||||
// Position the dropdown using fixed viewport coordinates so it isn't
|
||||
// clipped by ancestor overflow containers (e.g. the compose modal).
|
||||
const coords = getCaretCoordinates(textarea, atPos);
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
|
||||
setDropdownPos({
|
||||
top: coords.top + lineHeight + 4,
|
||||
left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)),
|
||||
});
|
||||
}, [textareaRef]);
|
||||
setDropdownPos(computePosition(coords));
|
||||
}, [textareaRef, computePosition]);
|
||||
|
||||
// Listen for input/cursor changes on the textarea element.
|
||||
// Re-attaches whenever the underlying DOM element changes (e.g. after
|
||||
@@ -254,10 +259,10 @@ export function MentionAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-[100] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[240px] overflow-y-auto py-1">
|
||||
@@ -273,6 +278,10 @@ export function MentionAutocomplete({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal to document.body so the dropdown escapes any ancestor overflow
|
||||
// clipping and CSS transform containing blocks (e.g. Radix Dialog).
|
||||
return renderPortal(dropdown, document.body);
|
||||
}
|
||||
|
||||
function MentionItem({
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { Capacitor } from '@capacitor/core';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SandboxFrame } from '@/components/SandboxFrame';
|
||||
@@ -68,25 +69,108 @@ function resolveServers(event: NostrEvent, appServers: string[]): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a blob from the given sha256 by trying each Blossom server in order.
|
||||
* Returns a Response from the first server that responds successfully, or
|
||||
* throws if all servers fail.
|
||||
* Module-level preferred server. Once a Blossom server successfully serves
|
||||
* a blob, it is promoted here so subsequent requests try it first — avoiding
|
||||
* the round-trip penalty of 404s on servers that don't have the content.
|
||||
*/
|
||||
let preferredServer: string | null = null;
|
||||
|
||||
/**
|
||||
* Fetch a blob from the given sha256 by trying Blossom servers.
|
||||
*
|
||||
* If a server previously succeeded (the "preferred" server), it is tried
|
||||
* first. On success the preferred server is reinforced; on failure we fall
|
||||
* through to the remaining servers in order. Whichever server ultimately
|
||||
* succeeds is promoted to preferred for the next call.
|
||||
*/
|
||||
async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Response> {
|
||||
let lastError: unknown;
|
||||
for (const server of servers) {
|
||||
|
||||
/** Try a single server. Returns the Response on success, or null. */
|
||||
async function tryServer(server: string): Promise<Response | null> {
|
||||
const base = server.replace(/\/+$/, '');
|
||||
const url = `${base}/${sha256}`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return res;
|
||||
if (res.ok) {
|
||||
preferredServer = server;
|
||||
return res;
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try the preferred server first if it's in the list.
|
||||
if (preferredServer && servers.includes(preferredServer)) {
|
||||
const res = await tryServer(preferredServer);
|
||||
if (res) return res;
|
||||
}
|
||||
|
||||
// Fall through to the full list, skipping the preferred (already tried).
|
||||
for (const server of servers) {
|
||||
if (server === preferredServer) continue;
|
||||
const res = await tryServer(server);
|
||||
if (res) return res;
|
||||
}
|
||||
|
||||
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
|
||||
}
|
||||
|
||||
/** Max concurrent Blossom fetches during pre-fetch. */
|
||||
const PREFETCH_CONCURRENCY = 12;
|
||||
|
||||
/**
|
||||
* Pre-fetch all unique blobs from the manifest into an in-memory cache.
|
||||
*
|
||||
* **Android only.** Android's WebView uses `shouldInterceptRequest` which
|
||||
* blocks a pool of ~6 IO threads via `CountDownLatch` until JS responds.
|
||||
* If each response requires a network round-trip to Blossom, the 6-at-a-time
|
||||
* serialisation makes loading 200+ files extremely slow. By downloading
|
||||
* every blob *before* the WebView starts loading, each bridge round-trip
|
||||
* drops from seconds (network) to ~1-5ms (memory).
|
||||
*
|
||||
* iOS does NOT need this — `WKURLSchemeHandler` is fully async and can
|
||||
* handle many concurrent requests without any thread pool bottleneck.
|
||||
*
|
||||
* Uses bounded concurrency to saturate the network without overwhelming it.
|
||||
*/
|
||||
async function prefetchAllBlobs(
|
||||
manifest: Map<string, string>,
|
||||
servers: string[],
|
||||
cache: Map<string, Uint8Array>,
|
||||
): Promise<void> {
|
||||
// Deduplicate — many paths may share the same hash (e.g. SPA fallbacks).
|
||||
const uniqueHashes = [...new Set(manifest.values())];
|
||||
// Skip hashes already in the cache (e.g. from a previous open).
|
||||
const toFetch = uniqueHashes.filter((h) => !cache.has(h));
|
||||
if (toFetch.length === 0) return;
|
||||
|
||||
let cursor = 0;
|
||||
const total = toFetch.length;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (cursor < total) {
|
||||
const idx = cursor++;
|
||||
const sha256 = toFetch[idx];
|
||||
try {
|
||||
const res = await fetchFromBlossom(sha256, servers);
|
||||
const buffer = await res.arrayBuffer();
|
||||
cache.set(sha256, new Uint8Array(buffer));
|
||||
} catch {
|
||||
// Non-fatal — resolveFile will fetch on demand for cache misses.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(PREFETCH_CONCURRENCY, total) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
interface NsitePreviewDialogProps {
|
||||
/** The nsite event (kind 15128 or 35128) containing path and server tags. */
|
||||
event: NostrEvent;
|
||||
@@ -124,6 +208,13 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
const manifest = useRef<Map<string, string>>(new Map());
|
||||
const servers = useRef<string[]>([]);
|
||||
|
||||
/**
|
||||
* In-memory blob cache: sha256 → raw bytes.
|
||||
* On Android, populated by a blocking pre-fetch in `onReady` so every
|
||||
* `resolveFile` call is an instant cache hit with no network wait.
|
||||
*/
|
||||
const blobCache = useRef<Map<string, Uint8Array>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
manifest.current = buildManifest(event);
|
||||
const appServers = getEffectiveBlossomServers(
|
||||
@@ -139,6 +230,26 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
content: getPreviewInjectedScript(),
|
||||
}], []);
|
||||
|
||||
/**
|
||||
* Called by SandboxFrame before the native WebView is created.
|
||||
*
|
||||
* On Android: blocks until all blobs are pre-fetched. Android's WebView
|
||||
* uses `shouldInterceptRequest` which blocks ~6 IO threads — if each
|
||||
* response requires a network fetch the whole thing is painfully slow.
|
||||
* The native ProgressBar spinner (render thread) stays visible and
|
||||
* animating during the download. Once the WebView starts, every
|
||||
* resolveFile call is an instant cache hit.
|
||||
*
|
||||
* On iOS: no-op. WKURLSchemeHandler is async and handles concurrent
|
||||
* requests without a thread pool bottleneck.
|
||||
*
|
||||
* On web: no-op. iframe.diy's service worker handles fetches efficiently.
|
||||
*/
|
||||
const onReady = useCallback(async () => {
|
||||
if (Capacitor.getPlatform() !== 'android') return;
|
||||
await prefetchAllBlobs(manifest.current, servers.current, blobCache.current);
|
||||
}, []);
|
||||
|
||||
/** Resolve a pathname to file content from the Blossom manifest. */
|
||||
const resolveFile = useCallback(async (pathname: string): Promise<FileResponse | null> => {
|
||||
// Look up the sha256 for this path in the manifest.
|
||||
@@ -153,11 +264,21 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
|
||||
if (!sha256) return null;
|
||||
|
||||
// Fetch the blob from Blossom, trying each server in order.
|
||||
// Serve from cache if available (pre-fetched on Android).
|
||||
const cached = blobCache.current.get(sha256);
|
||||
if (cached) {
|
||||
const contentType = getMimeType(servingPath);
|
||||
return { status: 200, contentType, body: cached };
|
||||
}
|
||||
|
||||
// Cache miss — fetch from Blossom (normal path on iOS/web).
|
||||
const res = await fetchFromBlossom(sha256, servers.current);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const body = new Uint8Array(buffer);
|
||||
|
||||
// Store in cache for future requests (e.g. SPA navigations).
|
||||
blobCache.current.set(sha256, body);
|
||||
|
||||
// Always determine content type from the file extension.
|
||||
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
|
||||
// files), which causes browsers to reject module scripts. The file path from
|
||||
@@ -221,6 +342,7 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
key={`${previewSubdomain}-${open}`}
|
||||
id={previewSubdomain}
|
||||
resolveFile={resolveFile}
|
||||
onReady={onReady}
|
||||
injectedScripts={injectedScripts}
|
||||
className="w-full h-full border-0"
|
||||
title={`${appName} preview`}
|
||||
|
||||
@@ -395,18 +395,6 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
let cancelled = false;
|
||||
|
||||
async function setup() {
|
||||
// Run onReady first so the consumer can prepare (e.g. download and
|
||||
// unzip a .xdc archive) before the native WebView starts loading
|
||||
// resources. This mirrors the web behaviour where onReady runs
|
||||
// before `init` is sent.
|
||||
try {
|
||||
await onReadyRef.current?.();
|
||||
} catch (err) {
|
||||
console.error('[SandboxFrame] onReady failed:', err);
|
||||
}
|
||||
|
||||
if (cancelled || destroyedRef.current) return;
|
||||
|
||||
// Measure the placeholder position.
|
||||
const el = placeholderRef.current;
|
||||
if (!el) return;
|
||||
@@ -439,8 +427,8 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
|
||||
if (cancelled || destroyedRef.current) return;
|
||||
|
||||
// Create the native WebView. Fetch events from the initial load
|
||||
// will be handled by the listeners registered above.
|
||||
// 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: {
|
||||
@@ -452,12 +440,27 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
});
|
||||
|
||||
if (cancelled || destroyedRef.current) {
|
||||
// Component unmounted while we were awaiting — clean up immediately.
|
||||
SandboxPlugin.destroy({ id }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
createdRef.current = true;
|
||||
|
||||
// Run onReady while the spinner is visible and animating.
|
||||
// On Android this pre-fetches all blobs so every resolveFile call
|
||||
// after navigation is an instant cache hit.
|
||||
// On iOS/web this is typically a no-op or instant.
|
||||
try {
|
||||
await onReadyRef.current?.();
|
||||
} catch (err) {
|
||||
console.error('[SandboxFrame] onReady failed:', err);
|
||||
}
|
||||
|
||||
if (cancelled || destroyedRef.current) return;
|
||||
|
||||
// Start loading the sandbox content — fetch events will now fire
|
||||
// and be handled by the listeners registered above.
|
||||
await SandboxPlugin.navigate({ id });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
generateNostrConnectURI,
|
||||
type NostrConnectParams,
|
||||
} from '@/hooks/useLoginActions';
|
||||
import { androidResume } from '@/lib/androidResume';
|
||||
import { getNsecCredential } from '@/lib/credentialManager';
|
||||
import { DialogTitle } from '@radix-ui/react-dialog';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
@@ -79,22 +78,18 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
}, [login, config.appName]);
|
||||
|
||||
// Start listening for connection (async) - runs after params are set.
|
||||
//
|
||||
// On Android, switching to Amber freezes the WebSocket so the NIP-46
|
||||
// response is silently dropped. When Ditto returns to the foreground we
|
||||
// abort the stale subscription and start a fresh one — the relay still
|
||||
// has the response event so `limit: 1` picks it up immediately.
|
||||
useEffect(() => {
|
||||
if (!nostrConnectParams || isWaitingForConnect) return;
|
||||
|
||||
let cancelled = false;
|
||||
let stopWatching: (() => void) | undefined;
|
||||
|
||||
const attemptConnect = async (signal: AbortSignal) => {
|
||||
const startListening = async () => {
|
||||
setIsWaitingForConnect(true);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
await login.nostrconnect(nostrConnectParams, signal);
|
||||
await login.nostrconnect(nostrConnectParams, abortControllerRef.current.signal);
|
||||
if (!cancelled) {
|
||||
stopWatching?.();
|
||||
onLogin();
|
||||
onClose();
|
||||
}
|
||||
@@ -102,42 +97,9 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
if (cancelled) return;
|
||||
// AbortError means we intentionally aborted (dialog closed or retry)
|
||||
if (error instanceof Error && error.name === 'AbortError') return;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const startListening = async () => {
|
||||
setIsWaitingForConnect(true);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// On Android, watch for foreground resume and retry the subscription.
|
||||
({ destroy: stopWatching } = androidResume({
|
||||
threshold: 0,
|
||||
onResume: () => {
|
||||
if (cancelled) return;
|
||||
console.log('[LoginDialog] foreground resume — retrying nostrconnect');
|
||||
// Abort the current (stale) subscription
|
||||
abortControllerRef.current?.abort();
|
||||
// Start a fresh subscription
|
||||
abortControllerRef.current = new AbortController();
|
||||
attemptConnect(abortControllerRef.current.signal).catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Nostrconnect retry failed:', error);
|
||||
setConnectError(error instanceof Error ? error.message : String(error));
|
||||
setIsWaitingForConnect(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
await attemptConnect(abortControllerRef.current.signal);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('Nostrconnect failed:', error);
|
||||
setConnectError(error instanceof Error ? error.message : String(error));
|
||||
setIsWaitingForConnect(false);
|
||||
}
|
||||
console.error('Nostrconnect failed:', error);
|
||||
setConnectError(error instanceof Error ? error.message : String(error));
|
||||
setIsWaitingForConnect(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,7 +107,6 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopWatching?.();
|
||||
};
|
||||
}, [nostrConnectParams, login, onLogin, onClose, isWaitingForConnect]);
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useCallback, type RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface DropdownPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
interface UsePortalDropdownOptions {
|
||||
/** Ref to the textarea the dropdown is anchored to. */
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
/** Whether the dropdown is currently visible. */
|
||||
isOpen: boolean;
|
||||
/** Callback to close the dropdown (e.g. on scroll/resize). */
|
||||
onClose: () => void;
|
||||
/** Max height of the dropdown in px (must match the CSS max-h value). */
|
||||
dropdownHeight: number;
|
||||
/** Width of the dropdown in px (must match the CSS width value). */
|
||||
dropdownWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes fixed viewport coordinates for an autocomplete dropdown anchored
|
||||
* to a caret position inside a textarea. The dropdown is positioned below
|
||||
* the caret line, or flipped above if it would overflow the viewport bottom.
|
||||
*
|
||||
* Also dismisses the dropdown on scroll or resize, since fixed positioning
|
||||
* would cause misalignment.
|
||||
*
|
||||
* Use `renderPortal` to render the dropdown as a portal to `document.body`
|
||||
* so it escapes ancestor overflow clipping and CSS transform containing
|
||||
* blocks (e.g. Radix Dialog).
|
||||
*/
|
||||
export function usePortalDropdown({
|
||||
textareaRef,
|
||||
isOpen,
|
||||
onClose,
|
||||
dropdownHeight,
|
||||
dropdownWidth = 280,
|
||||
}: UsePortalDropdownOptions) {
|
||||
|
||||
/** Compute fixed viewport position for the dropdown given a caret index. */
|
||||
const computePosition = useCallback(
|
||||
(caretCoords: { top: number; left: number }): DropdownPosition => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return { top: 0, left: 0 };
|
||||
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const top = rect.top + caretCoords.top - textarea.scrollTop + lineHeight + 4;
|
||||
const left = rect.left + Math.max(0, Math.min(caretCoords.left, textarea.clientWidth - dropdownWidth));
|
||||
|
||||
// If the dropdown would overflow the bottom of the viewport, flip above
|
||||
const flippedTop = rect.top + caretCoords.top - textarea.scrollTop - dropdownHeight - 4;
|
||||
const useFlipped = top + dropdownHeight > window.innerHeight && flippedTop > 0;
|
||||
|
||||
return {
|
||||
top: useFlipped ? flippedTop : top,
|
||||
left: Math.max(8, Math.min(left, window.innerWidth - dropdownWidth - 8)),
|
||||
};
|
||||
},
|
||||
[textareaRef, dropdownHeight, dropdownWidth],
|
||||
);
|
||||
|
||||
// Dismiss the dropdown when any ancestor scrolls or the window resizes,
|
||||
// since fixed positioning would cause the dropdown to become misaligned.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleDismiss = () => onClose();
|
||||
window.addEventListener('scroll', handleDismiss, true);
|
||||
window.addEventListener('resize', handleDismiss);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleDismiss, true);
|
||||
window.removeEventListener('resize', handleDismiss);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return { computePosition, renderPortal: createPortal };
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Detects when a web app returns to the foreground after being backgrounded,
|
||||
* primarily to work around Android's WebSocket zombie connection problem.
|
||||
*
|
||||
* Android aggressively throttles backgrounded tabs, causing WebSocket connections
|
||||
* to silently miss events without triggering close/error handlers. This utility
|
||||
* detects the resume and reports how long the app was in the background, so
|
||||
* callers can force reconnection or re-query missed data.
|
||||
*
|
||||
* Framework-agnostic — no React dependency. Can be used in libraries.
|
||||
*/
|
||||
|
||||
export interface AndroidResumeOptions {
|
||||
/** Minimum background duration (ms) before triggering. Default: 0 */
|
||||
threshold?: number;
|
||||
/** Called when the app returns to foreground after exceeding the threshold. */
|
||||
onResume?: (backgroundDurationMs: number) => void;
|
||||
/**
|
||||
* If true, only activates on Android user agents.
|
||||
* Set to false to test on desktop. Default: true
|
||||
*/
|
||||
androidOnly?: boolean;
|
||||
}
|
||||
|
||||
function isAndroid(): boolean {
|
||||
return typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export function androidResume(options: AndroidResumeOptions = {}): { destroy: () => void } {
|
||||
const { threshold = 0, onResume, androidOnly = true } = options;
|
||||
const noop = { destroy: () => {} };
|
||||
|
||||
// No-op in non-browser environments (e.g. Node.js, Deno without DOM).
|
||||
if (typeof document === 'undefined') return noop;
|
||||
|
||||
if (androidOnly && !isAndroid()) return noop;
|
||||
|
||||
let hiddenAt: number | null = null;
|
||||
|
||||
const handler = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
hiddenAt = Date.now();
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
if (hiddenAt === null) return;
|
||||
const duration = Date.now() - hiddenAt;
|
||||
hiddenAt = null;
|
||||
if (duration >= threshold) {
|
||||
onResume?.(duration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handler);
|
||||
return {
|
||||
destroy: () => {
|
||||
document.removeEventListener('visibilitychange', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -85,10 +85,18 @@ export interface SandboxScriptMessageEvent {
|
||||
// Plugin interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Options for navigating the sandbox WebView to its entry point. */
|
||||
export interface SandboxNavigateOptions {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SandboxPluginInterface {
|
||||
/** Create a new sandbox WebView with a unique custom URL scheme. */
|
||||
/** 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>;
|
||||
|
||||
|
||||
+50
-102
@@ -1,7 +1,6 @@
|
||||
import type { NostrEvent, NostrSigner } from '@nostrify/types';
|
||||
import { createElement } from 'react';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { androidResume } from '@/lib/androidResume';
|
||||
import { NudgeToastContent } from '@/components/SignerToastContent';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -16,9 +15,6 @@ const NUDGE_DELAY_MS = 4_000;
|
||||
const HARD_TIMEOUT_MS = 45_000;
|
||||
|
||||
|
||||
/** Max number of automatic retries on Android foreground resume. */
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -70,9 +66,8 @@ function labelForOp(kind: number | undefined, opType: OpType): string {
|
||||
|
||||
const CANCEL = Symbol('cancel');
|
||||
const TIMEOUT = Symbol('timeout');
|
||||
const RESUME = Symbol('resume');
|
||||
|
||||
type Signal = typeof CANCEL | typeof TIMEOUT | typeof RESUME;
|
||||
type Signal = typeof CANCEL | typeof TIMEOUT;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toast deduplication — prevent a storm of identical nudge toasts
|
||||
@@ -98,10 +93,9 @@ function showNudgeToast(opts: {
|
||||
kind: number | undefined;
|
||||
opType: OpType;
|
||||
isBunkerConnected: (() => boolean) | undefined;
|
||||
afterForegroundResume: boolean;
|
||||
onCancel: () => void;
|
||||
}): { dismiss: () => void } {
|
||||
const { kind, opType, isBunkerConnected, afterForegroundResume, onCancel } = opts;
|
||||
const { kind, opType, isBunkerConnected, onCancel } = opts;
|
||||
const android = isAndroid();
|
||||
const relayOk = isBunkerConnected ? isBunkerConnected() : true;
|
||||
const subject = labelForOp(kind, opType);
|
||||
@@ -120,9 +114,6 @@ function showNudgeToast(opts: {
|
||||
if (!relayOk) {
|
||||
title = 'Signer relay unreachable';
|
||||
descriptionText = 'Check your connection and try again.';
|
||||
} else if (android && afterForegroundResume) {
|
||||
title = `Approve ${subject} — try again`;
|
||||
descriptionText = 'Use the button below. Switching apps manually can interrupt the connection.';
|
||||
} else if (android) {
|
||||
title = `Approve ${subject}`;
|
||||
descriptionText = 'Set to auto-approve for a smoother experience.';
|
||||
@@ -184,11 +175,6 @@ interface RunResult<T> {
|
||||
* Runs `op` with:
|
||||
* - A nudge toast after NUDGE_DELAY_MS if still pending.
|
||||
* - A hard timeout at HARD_TIMEOUT_MS.
|
||||
* - On Android, automatic retry when the app returns to the foreground
|
||||
* (WebSocket connections are frozen while backgrounded, so NIP-46 responses
|
||||
* are missed).
|
||||
*
|
||||
* Uses an iterative retry loop instead of recursion.
|
||||
*/
|
||||
async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<RunResult<T>> {
|
||||
const { kind, opType, isBunkerConnected } = opts;
|
||||
@@ -200,96 +186,61 @@ async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<Run
|
||||
| { tag: 'signal'; signal: Signal };
|
||||
|
||||
let nudgeFired = false;
|
||||
let afterForegroundResume = false;
|
||||
|
||||
// Previous op promises that are still in-flight. On Android foreground
|
||||
// resume we issue a fresh `op()` but keep racing previous ones so a late
|
||||
// response from an earlier attempt is still accepted (avoids duplicate
|
||||
// signer prompts).
|
||||
const pendingOps: Promise<Outcome>[] = [];
|
||||
// Signal channels — each resolves with a sentinel when its condition fires.
|
||||
const cancelSignal = deferred<typeof CANCEL>();
|
||||
const timeoutSignal = deferred<typeof TIMEOUT>();
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
// Signal channels — each resolves with a sentinel when its condition fires.
|
||||
const cancelSignal = deferred<typeof CANCEL>();
|
||||
const timeoutSignal = deferred<typeof TIMEOUT>();
|
||||
const resumeSignal = deferred<typeof RESUME>();
|
||||
|
||||
// --- Nudge timer ---
|
||||
let dismissNudge: (() => void) | undefined;
|
||||
const delay = NUDGE_DELAY_MS;
|
||||
const nudgeTimer = setTimeout(() => {
|
||||
nudgeFired = true;
|
||||
const handle = showNudgeToast({
|
||||
kind, opType, isBunkerConnected, afterForegroundResume,
|
||||
onCancel: () => cancelSignal.resolve(CANCEL),
|
||||
});
|
||||
dismissNudge = handle.dismiss;
|
||||
}, delay);
|
||||
|
||||
// --- Hard timeout ---
|
||||
const hardTimer = setTimeout(() => timeoutSignal.resolve(TIMEOUT), HARD_TIMEOUT_MS);
|
||||
|
||||
// --- Android foreground resume watcher ---
|
||||
const { destroy: stopWatching } = androidResume({
|
||||
threshold: 0,
|
||||
onResume: () => {
|
||||
toast({ title: 'Checking for signer response\u2026', duration: 4000 });
|
||||
resumeSignal.resolve(RESUME);
|
||||
},
|
||||
// --- Nudge timer ---
|
||||
let dismissNudge: (() => void) | undefined;
|
||||
const nudgeTimer = setTimeout(() => {
|
||||
nudgeFired = true;
|
||||
const handle = showNudgeToast({
|
||||
kind, opType, isBunkerConnected,
|
||||
onCancel: () => cancelSignal.resolve(CANCEL),
|
||||
});
|
||||
dismissNudge = handle.dismiss;
|
||||
}, NUDGE_DELAY_MS);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(nudgeTimer);
|
||||
clearTimeout(hardTimer);
|
||||
stopWatching();
|
||||
dismissNudge?.();
|
||||
}
|
||||
// --- Hard timeout ---
|
||||
const hardTimer = setTimeout(() => timeoutSignal.resolve(TIMEOUT), HARD_TIMEOUT_MS);
|
||||
|
||||
// Start a new op and add it to the pending set.
|
||||
const newOp: Promise<Outcome> = op().then(
|
||||
(value): Outcome => ({ tag: 'value', value }),
|
||||
(error): Outcome => ({ tag: 'error', error }),
|
||||
);
|
||||
pendingOps.push(newOp);
|
||||
|
||||
const signalOutcome: Promise<Outcome> = Promise.race([
|
||||
cancelSignal.promise,
|
||||
timeoutSignal.promise,
|
||||
resumeSignal.promise,
|
||||
]).then((signal): Outcome => ({ tag: 'signal', signal }));
|
||||
|
||||
// Race all pending ops (current + any still in-flight from prior
|
||||
// attempts) against the signal channels.
|
||||
const outcome = await Promise.race([...pendingOps, signalOutcome]);
|
||||
cleanup();
|
||||
|
||||
// --- Handle outcome ---
|
||||
|
||||
if (outcome.tag === 'value') {
|
||||
if (nudgeFired) showSuccessToast(opType);
|
||||
return { value: outcome.value, nudgeFired };
|
||||
}
|
||||
|
||||
if (outcome.tag === 'error') {
|
||||
throw outcome.error;
|
||||
}
|
||||
|
||||
// outcome.tag === 'signal'
|
||||
switch (outcome.signal) {
|
||||
case CANCEL:
|
||||
throw new Error('Signing cancelled by user');
|
||||
|
||||
case TIMEOUT:
|
||||
throw new Error('Signer timed out');
|
||||
|
||||
case RESUME:
|
||||
afterForegroundResume = true;
|
||||
console.log('[signerWithNudge] retrying after foreground resume');
|
||||
continue;
|
||||
}
|
||||
function cleanup() {
|
||||
clearTimeout(nudgeTimer);
|
||||
clearTimeout(hardTimer);
|
||||
dismissNudge?.();
|
||||
}
|
||||
|
||||
throw new Error('Signer timed out after retries');
|
||||
const opOutcome: Promise<Outcome> = op().then(
|
||||
(value): Outcome => ({ tag: 'value', value }),
|
||||
(error): Outcome => ({ tag: 'error', error }),
|
||||
);
|
||||
|
||||
const signalOutcome: Promise<Outcome> = Promise.race([
|
||||
cancelSignal.promise,
|
||||
timeoutSignal.promise,
|
||||
]).then((signal): Outcome => ({ tag: 'signal', signal }));
|
||||
|
||||
const outcome = await Promise.race([opOutcome, signalOutcome]);
|
||||
cleanup();
|
||||
|
||||
if (outcome.tag === 'value') {
|
||||
if (nudgeFired) showSuccessToast(opType);
|
||||
return { value: outcome.value, nudgeFired };
|
||||
}
|
||||
|
||||
if (outcome.tag === 'error') {
|
||||
throw outcome.error;
|
||||
}
|
||||
|
||||
// outcome.tag === 'signal'
|
||||
switch (outcome.signal) {
|
||||
case CANCEL:
|
||||
throw new Error('Signing cancelled by user');
|
||||
|
||||
case TIMEOUT:
|
||||
throw new Error('Signer timed out');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -301,9 +252,6 @@ async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<Run
|
||||
*
|
||||
* - Shows a nudge toast after 4s if a signing or encryption op is still
|
||||
* pending, so the user knows to check their signer app.
|
||||
* - On Android, automatically retries when the app returns to the foreground,
|
||||
* recovering from missed NIP-46 responses dropped while the WebSocket was
|
||||
* frozen in the background.
|
||||
* - When a nip44 encrypt is immediately followed by a signEvent (e.g. saving
|
||||
* encrypted settings), shows a phase-transition toast so the user knows to
|
||||
* approve the second request.
|
||||
|
||||
+7
-3
@@ -21,12 +21,16 @@ import '@fontsource-variable/inter';
|
||||
// Uses a MutationObserver so it reacts to all subsequent theme changes
|
||||
// (class changes for builtin themes, style-content changes for custom themes).
|
||||
import { Capacitor, SystemBars, SystemBarsStyle } from '@capacitor/core';
|
||||
import { Keyboard } from '@capacitor/keyboard';
|
||||
import { getBackgroundThemeMode } from '@/lib/colorUtils';
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// Hide the iOS keyboard accessory bar (prev/next/done toolbar above the keyboard)
|
||||
Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => {});
|
||||
// Hide the iOS keyboard accessory bar (prev/next/done toolbar above the keyboard).
|
||||
// Only runs on iOS — setAccessoryBarVisible is unimplemented on Android.
|
||||
if (Capacitor.getPlatform() === 'ios') {
|
||||
import('@capacitor/keyboard').then(({ Keyboard }) => {
|
||||
Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => {});
|
||||
}).catch(() => {});
|
||||
}
|
||||
/**
|
||||
* Sync the native system bar icon style with the active CSS theme.
|
||||
*
|
||||
|
||||
@@ -32,6 +32,7 @@ export default {
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
|
||||
emoji: ['Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', 'Twemoji Mozilla', 'Android Emoji', 'EmojiSymbols', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
|
||||
Reference in New Issue
Block a user