diff options
202 files changed, 8894 insertions, 1150 deletions
diff --git a/android/.gitignore b/android/.gitignore index 3c2470f..baaffaa 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,3 +5,4 @@ keystore.properties *.keystore *.jks local.properties +app/google-services.json diff --git a/android/app/build.gradle b/android/app/build.gradle index 0119e93..8dcfc04 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -22,6 +22,24 @@ tasks.register('copyWeb', Copy) { into layout.buildDirectory.dir('www') } +// Firebase settings for the fcm build, read from app/google-services.json +// (downloaded from the Firebase console; gitignored). Without it the fcm +// build still builds, and pushes through UnifiedPush like the webview one. +def firebase = [appId: '', apiKey: '', projectId: '', senderId: ''] +def gsFile = file('google-services.json') +if (gsFile.exists()) { + def gs = new groovy.json.JsonSlurper().parse(gsFile) + def client = gs.client.find { it.client_info?.android_client_info?.package_name == 'io.coinos.beta' } + if (client) { + firebase = [ + appId: client.client_info.mobilesdk_app_id, + apiKey: client.api_key[0].current_key, + projectId: gs.project_info.project_id, + senderId: gs.project_info.project_number, + ] + } +} + android { namespace 'io.coinos.app' compileSdk 35 @@ -66,6 +84,21 @@ android { resValue 'string', 'app_name', 'Coinos (UnifiedPush)' buildConfigField 'boolean', 'BUNDLED', 'false' } + // fcm — the webview shell plus Firebase Cloud Messaging: push without a + // distributor app on phones with Google Play Services (see + // src/fcm/java/io/coinos/app/Fcm.java). Its own app id, so it + // installs beside the TWA for a side-by-side trial. + fcm { + dimension 'shell' + applicationId 'io.coinos.beta' + versionNameSuffix '-beta' + resValue 'string', 'app_name', 'Coinos Beta' + buildConfigField 'boolean', 'BUNDLED', 'false' + buildConfigField 'String', 'FCM_APP_ID', "\"${firebase.appId}\"" + buildConfigField 'String', 'FCM_API_KEY', "\"${firebase.apiKey}\"" + buildConfigField 'String', 'FCM_PROJECT_ID', "\"${firebase.projectId}\"" + buildConfigField 'String', 'FCM_SENDER_ID', "\"${firebase.senderId}\"" + } fdroid { dimension 'shell' applicationId 'io.coinos.fdroid' @@ -74,9 +107,19 @@ android { } } sourceSets { + // the webview shell has no Firebase: its Fcm is a stub + webview { + java.srcDirs += 'src/nofcm/java' + } + // the webview shell with Firebase (the manifest is webview's plus FCM) + fcm { + java.srcDirs = ['src/webview/java', 'src/fcm/java'] + res.srcDirs = ['src/webview/res'] + manifest.srcFile 'src/fcm/AndroidManifest.xml' + } // the same shell as webview, plus the bundled web app fdroid { - java.srcDirs = ['src/webview/java'] + java.srcDirs = ['src/webview/java', 'src/nofcm/java'] res.srcDirs = ['src/webview/res'] manifest.srcFile 'src/webview/AndroidManifest.xml' assets.srcDirs += layout.buildDirectory.dir('www').get().asFile @@ -102,6 +145,12 @@ android { dependencies { // only the TWA shell needs Chrome's helper; the WebView build carries none twaImplementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0' + // WebAuthn (passkeys) inside the WebView builds — androidx, no Google services + webviewImplementation 'androidx.webkit:webkit:1.14.0' + fcmImplementation 'androidx.webkit:webkit:1.14.0' + fdroidImplementation 'androidx.webkit:webkit:1.14.0' + // only the fcm build carries Google's push client + fcmImplementation 'com.google.firebase:firebase-messaging:24.1.0' } preBuild.dependsOn copyWeb diff --git a/android/app/src/fcm/AndroidManifest.xml b/android/app/src/fcm/AndroidManifest.xml new file mode 100644 index 0000000..640056c --- /dev/null +++ b/android/app/src/fcm/AndroidManifest.xml @@ -0,0 +1,72 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The fcm build: the WebView build (src/webview/AndroidManifest.xml — keep + the two in step) plus Firebase Cloud Messaging for push on phones with + Google Play Services. See Fcm.java. --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <uses-permission android:name="android.permission.INTERNET" /> + <!-- the wake job asks to run when there's a network; the scheduler won't + take that constraint without this --> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <uses-permission android:name="android.permission.CAMERA" /> + <uses-feature android:name="android.hardware.camera" android:required="false" /> + <application + android:name="io.coinos.app.CoinosApp" + android:label="@string/app_name" + android:icon="@mipmap/ic_launcher" + android:theme="@style/AppTheme" + android:supportsRtl="true" + android:usesCleartextTraffic="false"> + <!-- the app's half of the association with v3.coinos.io (the site's half + is its assetlinks.json): passkeys for the site work in this app only + when both name each other --> + <meta-data + android:name="asset_statements" + android:resource="@string/asset_statements" /> + <activity + android:name="io.coinos.app.MainActivity" + android:exported="true" + android:launchMode="singleTask" + android:configChanges="orientation|screenSize|keyboardHidden|uiMode" + android:windowSoftInputMode="adjustResize"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + <!-- the payment schemes the TWA build claims, claimed here too --> + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.BROWSABLE" /> + <data android:scheme="lightning" /> + <data android:scheme="bitcoin" /> + <data android:scheme="nostr" /> + <data android:scheme="lnurl" /> + <data android:scheme="lnurlw" /> + <data android:scheme="lnurlp" /> + </intent-filter> + <intent-filter> + <action android:name="android.nfc.action.NDEF_DISCOVERED" /> + <category android:name="android.intent.category.DEFAULT" /> + <data android:scheme="lightning" /> + <data android:scheme="bitcoin" /> + <data android:scheme="nostr" /> + <data android:scheme="lnurl" /> + <data android:scheme="lnurlw" /> + <data android:scheme="lnurlp" /> + </intent-filter> + </activity> + <!-- pushes through Firebase --> + <service + android:name="io.coinos.app.FcmService" + android:exported="false"> + <intent-filter> + <action android:name="com.google.firebase.MESSAGING_EVENT" /> + </intent-filter> + </service> + <!-- the off-screen wallet a push wakes --> + <service + android:name="io.coinos.app.WakeJob" + android:exported="false" + android:permission="android.permission.BIND_JOB_SERVICE" /> + </application> +</manifest> diff --git a/android/app/src/fcm/java/io/coinos/app/CoinosApp.java b/android/app/src/fcm/java/io/coinos/app/CoinosApp.java new file mode 100644 index 0000000..855db91 --- /dev/null +++ b/android/app/src/fcm/java/io/coinos/app/CoinosApp.java @@ -0,0 +1,12 @@ +package io.coinos.app; + +import android.app.Application; + +/** Sets Firebase up before anything in the process (a push included) needs it. */ +public class CoinosApp extends Application { + @Override + public void onCreate() { + super.onCreate(); + Fcm.init(this); + } +} diff --git a/android/app/src/fcm/java/io/coinos/app/Fcm.java b/android/app/src/fcm/java/io/coinos/app/Fcm.java new file mode 100644 index 0000000..02ce12c --- /dev/null +++ b/android/app/src/fcm/java/io/coinos/app/Fcm.java @@ -0,0 +1,64 @@ +package io.coinos.app; + +import android.content.Context; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; + +/** + * Firebase Cloud Messaging, for a phone with Google Play Services: the same + * channel web push rides in Chrome, so the wallet is reached with no + * distributor app to install. Its token goes to the web app as the push + * endpoint "fcm:<token>", down the road a UnifiedPush endpoint takes (?up=, + * or straight into the page's storage), and the notifier sends an fcm: + * endpoint through Firebase. A phone without Play Services, or a build made + * without android/app/google-services.json, falls back to UnifiedPush. + * + * <p>Firebase is set up here from the values build.gradle read out of that + * file, rather than by Google's Gradle plugin. + */ +final class Fcm { + private Fcm() {} + static final String PREFIX = "fcm:"; + + static boolean configured() { return !BuildConfig.FCM_APP_ID.isEmpty(); } + + static boolean available(Context c) { + if (!configured()) return false; + return Push.playServices(c); + } + + /** Called from CoinosApp, before anything (FcmService included) asks for Firebase. */ + static void init(Context c) { + if (!configured()) return; + try { + if (!FirebaseApp.getApps(c).isEmpty()) return; + FirebaseApp.initializeApp(c, new FirebaseOptions.Builder() + .setApplicationId(BuildConfig.FCM_APP_ID) + .setApiKey(BuildConfig.FCM_API_KEY) + .setProjectId(BuildConfig.FCM_PROJECT_ID) + .setGcmSenderId(BuildConfig.FCM_SENDER_ID) + .build()); + } catch (Throwable ignored) {} + } + + /** + * Ask for the token. It arrives later; onEndpoint runs (on any thread) once + * it's stored as this app's push endpoint. + * @return whether Firebase will deliver this app's pushes + */ + static boolean register(Context c, Runnable onEndpoint) { + if (!available(c)) return false; + init(c); + try { + FirebaseMessaging.getInstance().getToken().addOnSuccessListener((token) -> { + if (token == null || token.isEmpty()) return; + String ep = PREFIX + token; + if (!ep.equals(Push.endpoint(c))) Push.setEndpoint(c, ep); + if (onEndpoint != null) onEndpoint.run(); + }); + return true; + } catch (Throwable t) { return false; } + } +} diff --git a/android/app/src/fcm/java/io/coinos/app/FcmService.java b/android/app/src/fcm/java/io/coinos/app/FcmService.java new file mode 100644 index 0000000..bc3e514 --- /dev/null +++ b/android/app/src/fcm/java/io/coinos/app/FcmService.java @@ -0,0 +1,23 @@ +package io.coinos.app; + +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +/** Firebase's half of PushReceiver: the notifier's JSON arrives as data "body". */ +public class FcmService extends FirebaseMessagingService { + @Override + public void onNewToken(String token) { + // a rotated token is a new endpoint; the page gets it on its next load + if (token == null || token.isEmpty()) return; + // the first token also comes through Fcm.register, which may already + // have handed it to the page — only a different one starts over + String ep = Fcm.PREFIX + token; + if (!ep.equals(Push.endpoint(this))) Push.setEndpoint(this, ep); + } + + @Override + public void onMessageReceived(RemoteMessage msg) { + String body = msg.getData() == null ? null : msg.getData().get("body"); + if (body != null) PushReceiver.onPush(this, body); + } +} diff --git a/android/app/src/main/java/io/coinos/app/PushReceiver.java b/android/app/src/main/java/io/coinos/app/PushReceiver.java index ce3e3f0..b14de53 100644 --- a/android/app/src/main/java/io/coinos/app/PushReceiver.java +++ b/android/app/src/main/java/io/coinos/app/PushReceiver.java @@ -49,14 +49,7 @@ public class PushReceiver extends BroadcastReceiver { case Push.ACTION_MESSAGE: { byte[] bytes = intent.getByteArrayExtra(Push.EXTRA_BYTES_MESSAGE); String body = bytes != null ? new String(bytes) : intent.getStringExtra(Push.EXTRA_MESSAGE); - // On the WebView build the wallet lives in THIS app's storage, so it - // can be woken and told to deal with whatever arrived. On the TWA - // build the wallet is Chrome's and out of reach, so the notification - // is the whole of what we can do. - boolean woke = Wake.start(context, body); - // A payment or a message is news whether or not we handled it; a - // wallet-connect request is not, unless nobody is going to answer it. - if (!woke || !isSilent(body)) notify(context, body); + onPush(context, body); Push.ack(context, intent.getStringExtra(Push.EXTRA_ID)); break; } @@ -65,8 +58,23 @@ public class PushReceiver extends BroadcastReceiver { } } + /** + * A push, however it came (a UnifiedPush distributor, or Firebase in the + * fcm build — FcmService): the same JSON either way. + */ + static void onPush(Context context, String body) { + // On the WebView build the wallet lives in THIS app's storage, so it + // can be woken and told to deal with whatever arrived. On the TWA + // build the wallet is Chrome's and out of reach, so the notification + // is the whole of what we can do. + boolean woke = Wake.start(context, body); + // A payment or a message is news whether or not we handled it; a + // wallet-connect request is not, unless nobody is going to answer it. + if (!woke || !isSilent(body)) notify(context, body); + } + /** Requests the woken wallet answers by itself need no notification. */ - private boolean isSilent(String payload) { + private static boolean isSilent(String payload) { try { JSONObject j = new JSONObject(payload == null ? "{}" : payload); String reason = j.optString("reason", j.optString("type", "")); @@ -79,7 +87,7 @@ public class PushReceiver extends BroadcastReceiver { * describes; anything we don't recognise still gets a notification, because * silence is the one answer that's always wrong here. */ - private void notify(Context c, String payload) { + private static void notify(Context c, String payload) { String title = c.getString(R.string.app_name); String text = c.getString(R.string.push_generic); String deepLink = null; diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8132a82..77fa055 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -12,7 +12,7 @@ <!-- must match the site's /.well-known/assetlinks.json for URL-bar-less TWA --> <string name="asset_statements"> [{ - \"relation\": [\"delegate_permission/common.handle_all_urls\"], + \"relation\": [\"delegate_permission/common.handle_all_urls\", \"delegate_permission/common.get_login_creds\"], \"target\": { \"namespace\": \"web\", \"site\": \"https://v3.coinos.io\" } }] </string> diff --git a/android/app/src/nofcm/java/io/coinos/app/Fcm.java b/android/app/src/nofcm/java/io/coinos/app/Fcm.java new file mode 100644 index 0000000..bb68bbc --- /dev/null +++ b/android/app/src/nofcm/java/io/coinos/app/Fcm.java @@ -0,0 +1,14 @@ +package io.coinos.app; + +import android.content.Context; + +/** + * The WebView and F-Droid builds carry no Firebase: push there is UnifiedPush + * only. The fcm build's version of this class (src/fcm) is the real one. + */ +final class Fcm { + private Fcm() {} + static boolean available(Context c) { return false; } + /** @return whether Firebase will deliver this app's pushes (never, here) */ + static boolean register(Context c, Runnable onEndpoint) { return false; } +} diff --git a/android/app/src/webview/AndroidManifest.xml b/android/app/src/webview/AndroidManifest.xml index 0942377..85a6f95 100644 --- a/android/app/src/webview/AndroidManifest.xml +++ b/android/app/src/webview/AndroidManifest.xml @@ -14,6 +14,12 @@ android:theme="@style/AppTheme" android:supportsRtl="true" android:usesCleartextTraffic="false"> + <!-- the app's half of the association with v3.coinos.io (the site's half + is its assetlinks.json): passkeys for the site work in this app only + when both name each other --> + <meta-data + android:name="asset_statements" + android:resource="@string/asset_statements" /> <activity android:name="io.coinos.app.MainActivity" android:exported="true" diff --git a/android/app/src/webview/java/io/coinos/app/MainActivity.java b/android/app/src/webview/java/io/coinos/app/MainActivity.java index d88a969..806fd8f 100644 --- a/android/app/src/webview/java/io/coinos/app/MainActivity.java +++ b/android/app/src/webview/java/io/coinos/app/MainActivity.java @@ -38,6 +38,7 @@ public class MainActivity extends Activity { static final String BASE = "https://v3.coinos.io/"; private WebView web; + private android.widget.FrameLayout root; private ValueCallback<Uri[]> filePicker; private static final int REQ_FILE = 10, REQ_PERMS = 11; @@ -45,8 +46,27 @@ public class MainActivity extends Activity { protected void onCreate(Bundle saved) { super.onCreate(saved); web = new WebView(this); - setContentView(web, new ViewGroup.LayoutParams( + // Android 15 draws every app edge to edge, so the page ran under the + // status bar and the navigation buttons sat on top of its bottom nav. + // The page goes in a frame padded by the system bars (and the keyboard, + // which then resizes the page as adjustResize did before); the frame's + // background is the theme's bar colour. + root = new android.widget.FrameLayout(this); + root.addView(web, new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + // 3-button navigation gets a light scrim of the system's own over + // whatever colour is behind it; the frame's colour is the one wanted + if (Build.VERSION.SDK_INT >= 29) getWindow().setNavigationBarContrastEnforced(false); + if (Build.VERSION.SDK_INT >= 30) { + root.setOnApplyWindowInsetsListener((v, insets) -> { + android.graphics.Insets b = insets.getInsets( + android.view.WindowInsets.Type.systemBars() | android.view.WindowInsets.Type.ime() + | android.view.WindowInsets.Type.displayCutout()); + v.setPadding(b.left, b.top, b.right, b.bottom); + return android.view.WindowInsets.CONSUMED; + }); + } + setContentView(root); // A debug build can be inspected from a desktop (chrome://inspect); a // release build of a wallet must not be — anything with ADB access could @@ -63,6 +83,16 @@ public class MainActivity extends Activity { s.setLoadWithOverviewMode(true); s.setUseWideViewPort(true); CookieManager.getInstance().setAcceptCookie(true); + // Passkeys: a WebView offers the page no WebAuthn unless the app asks for + // it, so passkey sign-in and the fingerprint lock were missing here. The + // site's assetlinks.json vouches for this app (get_login_creds), which + // is what lets it use v3.coinos.io's passkeys. + try { + if (androidx.webkit.WebViewFeature.isFeatureSupported(androidx.webkit.WebViewFeature.WEB_AUTHENTICATION)) { + androidx.webkit.WebSettingsCompat.setWebAuthenticationSupport(s, + androidx.webkit.WebSettingsCompat.WEB_AUTHENTICATION_SUPPORT_FOR_APP); + } + } catch (Throwable ignored) {} web.setWebViewClient(new WebViewClient() { // The bundled build answers the site's own files from assets; the @@ -77,6 +107,15 @@ public class MainActivity extends Activity { } @Override + public void onPageFinished(WebView v, String url) { + endpointToPage(); + // the bars take the page's colour, now and whenever its theme changes + v.evaluateJavascript("(function(){var m=document.querySelector('meta[name=theme-color]');" + + "if(!m||!window.CoinosHost||!CoinosHost.bars)return;var f=function(){CoinosHost.bars(m.content||'')};" + + "f();new MutationObserver(f).observe(m,{attributes:true,attributeFilter:['content']})})()", null); + } + + @Override public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest r) { Uri u = r.getUrl(); String host = u.getHost(); @@ -118,9 +157,11 @@ public class MainActivity extends Activity { } }); - // Notifications only ever come from UnifiedPush, so the permission is - // asked for only when a distributor is installed to deliver them. - if (Build.VERSION.SDK_INT >= 33 && !Push.distributors(this).isEmpty() + // Notifications come from Firebase (the fcm build, with Play Services) + // or a UnifiedPush distributor, so the permission is asked for only when + // one of them is there to deliver them. + boolean fcm = Fcm.available(this); + if (Build.VERSION.SDK_INT >= 33 && (fcm || !Push.distributors(this).isEmpty()) && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { try { requestPermissions(new String[] { Manifest.permission.POST_NOTIFICATIONS }, REQ_PERMS); } catch (Exception ignored) {} } @@ -128,10 +169,25 @@ public class MainActivity extends Activity { // can deliver a push — the advice it gives is useless otherwise ("use // Firefox" makes no sense inside a WebView that IS the app). web.addJavascriptInterface(new Host(), "CoinosHost"); - Push.register(this); + // Firebase when it can; otherwise a distributor. A Firebase token + // arrives after the page has started loading, so it goes straight into + // the page rather than waiting for the next launch's ?up=. + if (!Fcm.register(this, () -> runOnUiThread(this::endpointToPage))) Push.register(this); web.loadUrl(urlFor(getIntent())); } + /** + * The push endpoint, put where the page keeps it (messages.js and nwc.js + * read 'coinos-unifiedpush') — once the page is ours to write into. + */ + private void endpointToPage() { + String endpoint = Push.endpoint(this); + String url = web == null ? null : web.getUrl(); + if (endpoint == null || Push.endpointDelivered(this) || url == null || !url.startsWith(BASE)) return; + web.evaluateJavascript("try{localStorage.setItem('coinos-unifiedpush'," + org.json.JSONObject.quote(endpoint) + ")}catch(e){}", null); + Push.markEndpointDelivered(this); + } + /** What the page is allowed to ask about its surroundings. */ private class Host { @android.webkit.JavascriptInterface @@ -139,10 +195,27 @@ public class MainActivity extends Activity { String dist = Push.distributor(MainActivity.this); List<String> all = Push.distributors(MainActivity.this); String endpoint = Push.endpoint(MainActivity.this); - return "{\"app\":\"graphene\",\"distributors\":" + all.size() + return "{\"app\":\"" + (Fcm.available(MainActivity.this) ? "fcm" : "graphene") + "\",\"distributors\":" + all.size() + ",\"distributor\":" + (dist == null ? "null" : "\"" + dist + "\"") + ",\"endpoint\":" + (endpoint != null) + "}"; } + /** The page's theme colour, for the bar areas around it (see onCreate). */ + @android.webkit.JavascriptInterface + public void bars(String css) { + final int color; + try { color = android.graphics.Color.parseColor(css.trim()); } catch (Exception e) { return; } + runOnUiThread(() -> { + if (root != null) root.setBackgroundColor(color); + if (Build.VERSION.SDK_INT >= 30 && getWindow().getInsetsController() != null) { + // dark icons on a light colour, light icons on a dark one + double lum = (0.299 * android.graphics.Color.red(color) + 0.587 * android.graphics.Color.green(color) + + 0.114 * android.graphics.Color.blue(color)) / 255; + int light = android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + | android.view.WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS; + getWindow().getInsetsController().setSystemBarsAppearance(lum > 0.5 ? light : 0, light); + } + }); + } /** The wake job's bridge has this too; here it's a no-op. */ @android.webkit.JavascriptInterface public void done() {} @@ -33,15 +33,16 @@ const MANIFEST = { ], }; -// Service worker: precache the app shell, network-first for the page (so a new -// deploy shows up), cache-first for the icons/manifest, and never touch the +// Service worker: precache the app shell, cache-first for opening the app at / +// (a new deploy opens on the launch after it installs; a refresh goes to the +// network), network-first for other pages, cache-first for assets, and never touch the // cross-origin explorer/Nostr requests. CACHE carries a build token so each // deploy supersedes the previous cache. {{VERSION}} is filled in at build time. const SW = `const CACHE = 'cold-{{VERSION}}'; // jsqr.js / nip46.js are precached so the lazy QR decoder and remote-signer // module are available offline too. app-{{VERSION}}.js is the main bundle — -// content-addressed, so a new deploy precaches a new name and the old cache -// is dropped wholesale. +// content-addressed, so a new deploy precaches a new name; the previous +// build's cache is kept one deploy longer (see activate). const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png', 'jsqr.js', 'nip46.js', 'verify-worker.js', 'PublicSans.woff2', 'PublicSans-Italic.woff2', 'app-{{VERSION}}.js'{{EXTRA_SHELL}}]; self.addEventListener('install', (e) => { @@ -58,13 +59,25 @@ self.addEventListener('message', (e) => { } }); +// Activating keeps the previous build's cache beside the new one: a page +// launched from the old shell (navigations to / are cache-first, below) keeps +// running the old build until its next launch, and its lazily imported chunks +// must still be found. Older builds' caches go. Navigation preload starts a +// navigation's network request while the worker itself is still starting. +const META = 'coinos-sw-meta'; self.addEventListener('activate', (e) => { - e.waitUntil( - caches - .keys() - .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) - .then(() => self.clients.claim()) - ); + e.waitUntil((async () => { + try { if (self.registration.navigationPreload) await self.registration.navigationPreload.enable(); } catch (_) {} + const meta = await caches.open(META); + let order = []; + try { const r = await meta.match('cache-order'); if (r) order = await r.json(); } catch (_) {} + const keep = [CACHE, ...order.filter((k) => k !== CACHE)].slice(0, 2); + await meta.put('cache-order', new Response(JSON.stringify(keep))); + const keys = await caches.keys(); + // only builds' caches: the thumbnail and call caches outlive a deploy + await Promise.all(keys.filter((k) => k.startsWith('cold-') && !keep.includes(k)).map((k) => caches.delete(k))); + await self.clients.claim(); + })()); }); // NWC push handling lives in src/sw-nwc.js (bundled and appended below when @@ -94,18 +107,50 @@ self.addEventListener('fetch', (e) => { if (u.pathname === '/docs' || u.pathname.startsWith('/docs/')) return; if (req.mode === 'navigate') { - // A note page comes back with its thread rendered into it (the server's - // note-page); that copy must not become the offline shell for every path. - const notePage = /^\\/(note|nevent)1/i.test(u.pathname); + // Opening the app (/, the installed app's and the Android app's start + // page) paints this build's shell from the cache, with no network wait: + // network-first made every launch wait on a round trip, and the first + // launch after each deploy download the whole new build (~650 KB + // compressed) before showing anything — seconds on a phone's data. The + // browser checks sw.js on the same navigation; a new build installs in + // the background (shell, entry and chunks together) and the next launch + // opens it. A refresh (pull-to-refresh, reload) still goes to the network. + // Other paths (note and profile pages come rendered by the server) stay + // network-first, falling back to the shell offline. + const root = u.pathname === '/'; + const refresh = req.cache === 'reload' || req.cache === 'no-cache'; + // The preload is answered with an empty 204 when it's for a launch (no + // Cache-Control on the request; a refresh sends max-age=0), so opening the + // app spends no bytes on a page it paints from the cache (cs nginx). + const preload = e.preloadResponse ? e.preloadResponse.catch(() => null) : Promise.resolve(null); + e.waitUntil(preload); // an unused preload must still settle + const net = () => preload.then((r) => (r && r.status !== 204 ? r : fetch(req))); + const shell = () => caches.open(CACHE).then((c) => c.match('./')).then((hit) => hit || caches.match('./')); + if (root && !refresh) { + e.respondWith( + caches.open(CACHE).then((c) => c.match('./')) + .then((hit) => hit || net()) + .catch(() => shell()) + ); + return; + } e.respondWith( - fetch(req) + net() .then((res) => { - if (notePage) return res; - const copy = res.clone(); - caches.open(CACHE).then((c) => c.put('./', copy)); + // Only this build's own page becomes its shell: a server-rendered + // note or profile page, or a newer build's page fetched before its + // worker installed, must not be what the next launch opens. + if (root && res.ok) { + const copy = res.clone(); + e.waitUntil(copy.text().then((t) => { + if (t.includes('app-{{VERSION}}.js')) { + return caches.open(CACHE).then((c) => c.put('./', new Response(t, { headers: copy.headers }))); + } + }).catch(() => {})); + } return res; }) - .catch(() => caches.match('./')) + .catch(() => shell()) ); return; } @@ -139,9 +184,8 @@ const PWA_HEAD = `<link rel="manifest" href="/manifest.webmanifest"> // Register with updateViaCache:'none' so the browser always re-fetches sw.js // (never from HTTP cache) and detects a new build immediately. // -// When a new worker takes control we may need to reload, because the app is -// inlined in the page: a page serving the old build would keep running it. But -// navigations are network-first, so the refresh that triggered the update +// When a new worker takes control, a page serving the old build keeps running +// it. A refresh goes to the network, so the refresh that triggered the update // usually delivered the NEW page already — reloading then is pure // interruption, a second unasked-for refresh a beat after your own. // @@ -155,7 +199,17 @@ const SW_REGISTER = `if(!e.data||e.data.type!=='sw-version')return;` + // _v.charAt(0)==='{' means the token never got substituted; treating that as // a mismatch would reload forever. - `if(_had&&!_ref&&e.data.version&&_v.charAt(0)!=='{'&&e.data.version!==_v){_ref=true;location.reload()}});` + + // + // Opening the app paints the cached shell, so a build that installs while + // it's open finds it running the previous one. The new build then waits for + // the next launch — unless nothing has been touched yet: then it reloads as + // the page is put away (or now, if it already is), with nothing to lose. + // Never under someone's hands: a send half-typed while they fetch an + // invoice from another app must still be there when they come back. + `if(_had&&!_ref&&e.data.version&&_v.charAt(0)!=='{'&&e.data.version!==_v){_ref=true;_stale=true;_go()}});` + + `var _stale=false,_used=false;function _go(){if(_stale&&!_used&&document.visibilityState==='hidden')location.reload()}` + + `['pointerdown','keydown'].forEach(function(t){addEventListener(t,function(){_used=true},{capture:true,passive:true})});` + + `document.addEventListener('visibilitychange',_go);` + `navigator.serviceWorker.addEventListener('controllerchange',function(){_ask(navigator.serviceWorker.controller)});` + `addEventListener('load',function(){navigator.serviceWorker.register('/sw.js',{updateViaCache:'none'}).catch(function(){})})}</script>`; @@ -396,7 +450,7 @@ function bootShell(logo, staging) { : '<span class="beta-badge">v3 beta</span>'; return `<div class="col" style="gap:16px"><div class="row between"><div class="brand ${staging ? 'staging' : 'beta'}">${badge}<div class="logo-full" aria-label="coinos" role="img">${logo}</div></div></div><div class="card col" id="boot-shell" style="gap:12px;display:none"><div class="row gap6" style="align-items:center"><div class="chat-avatar profile-avatar fallback loading"></div><div class="chat-title" id="boot-shell-name"></div></div></div></div>`; } -const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za-z0-9._-]{1,64})\\/?$/);if(m&&!/^(note|nevent|naddr)1/i.test(m[1])){document.getElementById('boot-shell').style.display='';document.getElementById('boot-shell-name').textContent=decodeURIComponent(m[1]);}}catch(e){}</script>`; +const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za-z0-9._-]{1,64})\\/?$/);if(m&&!/^(chat|pos|feed)$/.test(m[1])&&!/^(note|nevent|naddr)1/i.test(m[1])){document.getElementById('boot-shell').style.display='';document.getElementById('boot-shell-name').textContent=decodeURIComponent(m[1]);}}catch(e){}</script>`; // COINOS_NO_SW=1: the page registers no service worker. The Android bundled // build serves this page from its own assets, where a worker would only @@ -554,7 +608,7 @@ if (import.meta.main) { // The Android builds. coinos.apk is the TWA (Chrome renders the site); // graphene.apk runs the wallet in its own WebView so a phone without // Google Play Services can be woken — see android/. - for (const apk of ['coinos.apk', 'graphene.apk']) { + for (const apk of ['coinos.apk', 'graphene.apk', 'coinos-beta.apk']) { if (await Bun.file('static/' + apk).exists()) { await Bun.write('dist/' + apk, Bun.file('static/' + apk)); } diff --git a/fdroid/io.coinos.fdroid.yml b/fdroid/io.coinos.fdroid.yml index 03a7373..26bac72 100644 --- a/fdroid/io.coinos.fdroid.yml +++ b/fdroid/io.coinos.fdroid.yml @@ -5,6 +5,17 @@ # publish the coinos-signed release once its own build matches (Binaries + # AllowedAPKSigningKeys). Summary/description/icon/changelogs live in # fastlane/ in this repo — F-Droid pulls them from there. +AntiFeatures: + NonFreeNet: + en-US: Posts load pictures, videos and link previews from third-party media hosts + (nostr.build, primal.net and others, YouTube thumbnails from i.ytimg.com), name@domain + payment addresses are looked up through Cloudflare's and Google's DNS-over-HTTPS + resolvers, and block explorer data comes from mempool.space. + TetheredNet: + en-US: Spending and Lightning payments go through the coinos Ark server (ark.coinos.io), + @coinos.io payment addresses through the coinos name registrar, and chat and + notifications through the coinos relays. The server code is published at git.coinos.io, + but this build is fixed to the coinos instances. Categories: - Wallet License: MIT @@ -11,6 +11,7 @@ import './rich-text.js'; // <coinos-text>, the composers' field import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js'; import { assetUrl } from './asset-url.js'; import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js'; +import { warmSeed, forgetSeeds } from './seed.js'; import { nip98Header } from './nip98.js'; import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; @@ -20,6 +21,7 @@ import { STAGING } from './build-flags.js'; import { buildFeatures, loadDeferredFeatures } from './features/index.js'; import { cacheReady, cacheStore } from './cache-store.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; +import { initPullRefresh } from './pull-refresh.js'; import { rateNow, rateAt, refreshRates, haveRate, getCurrency, setCurrency, currencies, fmtFiat, fmtFiatBare } from './rates.js'; import { fmtBtc, @@ -75,7 +77,7 @@ const ui = { confirmClear: false, // "Clear all" confirmation shown editId: null, // account being renamed editLabel: '', - createStep: 'gen', // 'gen' | 'confirm' + createStep: 'intro', // 'intro' (the heads-up) | 'gen' (the words) | 'confirm' (the one-word check) draftMnemonic: '', confirm: [], // [{ index, value }] confirmPass: '', // re-entered passphrase (confirm field, shown once one is typed) @@ -365,6 +367,14 @@ function morphKeyed(parent, cur, next) { } const root = document.getElementById('app'); +// The footer while setting up: the language, nothing else. +function setupFooter() { + const lang = languagePicker(); + lang.style.width = 'auto'; // as wide as the language's name, not the page + lang.style.flex = '0 0 auto'; + return h('div', { class: 'footer small muted center' }, + h('div', { class: 'row gap6', style: 'justify-content:center' }, lang)); +} function footer() { return h( 'div', @@ -585,6 +595,14 @@ let _publicChatMode = false; // welcome screen is the one screen that must never be guessed at. The boot // shell holds the frame for those few ms instead; see the dispatch below. let _bootDeciding = true; +// True until boot's restore has run. The feed is a public page: opened by +// URL it paints before the wallet restores, and it painted as a visitor's +// (no post button, chips or nav) until the restore landed a beat later — +// synchronous work (the vault's scrypt, the wallet's state, every feature's +// init), ~0.5 s on a laptop and more on a phone (Adam, 2026-10-31). When +// boot can already tell a wallet is about to open (restorePending), the +// page paints as that wallet's from its first frame. +let _restorePending = true; // Boot phases for the crash-loop guard (build.js BOOT_GUARD counts page // starts): the app notes the phase it reaches, and clears the count once it // has lived eight seconds or leaves cleanly. A page iOS kills while opening @@ -597,7 +615,7 @@ addEventListener('pagehide', bootSettled); // Belt and braces: a boot path that returns early (or throws) must not leave // the welcome screen held back forever. Nothing legitimate is still deciding // four seconds in — the store wait alone caps at 800ms. -setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 4000); +setTimeout(() => { _restorePending = false; if (_bootDeciding) { _bootDeciding = false; render(); } }, 4000); // A picture, full screen. Posts show images cropped to a few hundred pixels // so a feed stays a feed; this is where you actually look at one. Tap // anywhere (or Escape, or Back) to close — no chrome, nothing to aim at. @@ -640,12 +658,22 @@ function attachZoom(box, img, { wheel = true } = {}) { const mx = Math.max(0, (img.offsetWidth * s - window.innerWidth) / 2), my = Math.max(0, (img.offsetHeight * s - window.innerHeight) / 2); x = Math.min(mx, Math.max(-mx, x)); y = Math.min(my, Math.max(-my, y)); }; + // Chrome keeps a will-change: transform layer rastered at 1× and scales + // the bitmap: zoomed in, the raster's tiles show as a faint grid of + // seams across the picture ("very faint grid lines", Adam, 2026-11-02). + // The hint is on while a gesture moves the picture (no re-raster per + // frame) and off once it settles zoomed, so the picture is drawn again + // at its real scale: sharp, and seamless. + let settle = 0; const apply = (animate) => { + clearTimeout(settle); img.style.transition = animate ? 'transform .2s ease' : 'none'; img.style.transform = s === 1 && !x && !y ? '' : `translate(${x}px, ${y}px) scale(${s})`; img.style.cursor = s > 1 ? 'grab' : ''; // zoomed, the finger pans the picture; at 1× the touch is the page's (a pager's swipe) img.style.touchAction = s > 1 ? 'none' : ''; + img.style.willChange = 'transform'; + settle = setTimeout(() => { img.style.willChange = s > 1 ? 'auto' : ''; }, animate ? 250 : 120); }; // scale to ns keeping the picture point under (cx, cy) where it is const zoomAt = (ns, cx, cy) => { @@ -859,13 +887,24 @@ function renderInner() { // The bottom nav rides every page of a signed-in wallet and a visitor's // public pages — never over sign-in, onboarding or the lock prompt. const navPage = ui.pubProf || (activeAccount() && ['wallet', 'accounts', 'accountSettings'].includes(ui.screen) && !(ui.onb || onbInProgress())); - const bottomNav = navPage && !lockAsk && !_bootDeciding ? featureHook('bottomNav') : null; + // (a wallet about to restore gets its nav with the page, not after) + const bottomNav = navPage && !lockAsk && (!_bootDeciding || restorePending()) ? featureHook('bottomNav') : null; if (bottomNav) bottomNav.prepend(h('div', { class: 'app-nav-wide app-nav-brand' }, brandLogo())); - // the wallet beside the feed and chats on a wide screen (its own page needs no twin) - const side = bottomNav && featScreen && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null; + // the wallet beside the feed and chats on a wide screen (its own page needs + // no twin; a wallet still restoring has no numbers to show yet) + const side = bottomNav && featScreen && !restorePending() && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null; // a call, over whatever page is up (the messages feature's) const callOv = activeAccount() ? featureHook('callOverlay') : null; - morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]); + // What a feature floats over its page (the feed's post button and + // new-posts pill): fixed-position nodes, so they live beside the page, + // not inside it — the page's entrance animation transforms it, and a + // transform makes fixed descendants position within the page. + const floaters = h('div', { class: 'page-floaters', style: 'display:contents' }, ...featureAll('pageFloaters')); + // The front door and every setup step keep only the language picker under + // them: no credits, theme toggle, currency or build stamp (Adam, + // 2026-11-06). Setup = the unlock screen, or a page the wizard drew (.onb). + const setup = ui.screen === 'unlock' || !!(screen && (screen.classList?.contains('onb') || screen.querySelector?.('.onb'))); + morphChildren(root, [screen, floaters, setup ? setupFooter() : footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]); promoteLazySrc(root); // sources on the nodes that actually made it into the page try { document.documentElement.classList.toggle('no-scroll', !!(ui.lightbox || ui.mediaPager)); } catch {} if (fpath) { @@ -1117,9 +1156,16 @@ function toast(msg, ms = 1600) { document.body.append(t); } t.textContent = msg; - requestAnimationFrame(() => t.classList.add('show')); clearTimeout(toastTimer); - toastTimer = setTimeout(() => t.classList.remove('show'), ms); + // The hide timer starts when the toast shows: a hidden tab pauses + // requestAnimationFrame but not setTimeout, so a toast raised in the + // background (a zap failing after you switched apps) used to be hidden + // first and shown on return, then stay up for good. + requestAnimationFrame(() => { + t.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.classList.remove('show'), ms); + }); } async function copy(text) { @@ -1292,19 +1338,36 @@ function unitTag(cls = '') { // ================================================================ UNLOCK // The create/import card on its own — the unlock screen wraps it in the // classic chrome, the onboarding wizard's "Get started" step shows it bare. +// The front door's seed pages (first run, or "Sign into another account"): +// no tabs, and the page's own Back steps through the create flow. +function seedPages() { + return ui.screen === 'unlock' && !!((ui.onb && ui.onb.step === 'seed') || (ui.fromWallet && ui.addFlow)); +} +// Back within the create flow: the check → the passphrase → the words → the +// heads-up. False when there's no earlier step (the caller leaves the flow). +const CREATE_PREV = { gen: 'intro', pass: 'gen', confirm: 'pass' }; +function createStepBack() { + if (ui.unlockTab !== 'create' || ui.entropyPage || !CREATE_PREV[ui.createStep]) return false; + ui.createStep = CREATE_PREV[ui.createStep]; + ui.unlockError = ''; + render(); + return true; +} + function unlockCard() { // The entropy page takes the card over entirely — seed words, tabs and // other controls step aside until Submit or Back. if (ui.entropyPage) return h('div', { class: 'card col' }, entropyPage()); // Inside the first-run wizard the words are already on screen: no tabs, // just a line underneath for whoever brought their own seed (and one back). - const wizard = ui.screen === 'unlock' && ui.onb && ui.onb.step === 'seed'; - const swap = wizard ? h('button', { + const wizard = seedPages(); + // only before the words are up: once they're shown, it was already offered + const swap = wizard && (ui.unlockTab === 'import' || ui.createStep === 'intro') ? h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { clearSeedDrafts(); ui.unlockError = ''; if (ui.unlockTab === 'create') ui.unlockTab = 'import'; - else { ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftRandom = ui.draftMnemonic = newMnemonic(); } + else { ui.unlockTab = 'create'; ui.createStep = 'intro'; ui.draftRandom = ui.draftMnemonic = newMnemonic(); } render(); }, }, ui.unlockTab === 'create' ? t('onbHaveSeed') : t('onbCreateInstead')) : null; @@ -1323,7 +1386,77 @@ function unlockCard() { ); } +// "Sign in to another account", laid out as the front door is (Adam, +// 2026-11-07): two choices, then the sign-in list with the seed phrase +// first. Everything here adds an account beside the open ones; Back returns +// to the wallet. +function addAccountScreen() { + const page = (kids) => h('div', { class: 'col onb', style: 'gap:20px' }, ...kids); + const title = (txt) => h('h2', { class: 'onb-title' }, txt); + const toWallet = () => { + ui.fromWallet = false; ui.addFlow = false; ui.addStep = null; ui.nostrLoginOpen = false; + ui.screen = 'wallet'; ui.unlockError = ''; ui.nostrLoginError = ''; render(); + }; + if (ui.addStep === 'doors') { + if (ui.nostrLoginOpen) { + return page([ + h('div', { class: 'onb-mark', html: NOSTR_MARK }), + title(t('onbSigninTitle')), + featureHook('unlockExtra') || h('div', { class: 'notice err' }, 'nostr login unavailable'), + h('button', { class: 'linklike small', onClick: () => { ui.nostrLoginOpen = false; ui.nostrLoginError = ''; render(); } }, t('back')), + ]); + } + return page([ + h('div', { class: 'onb-hero' }, brandHeader(false)), + title(t('onbSigninTitle')), + h('div', { class: 'col', style: 'gap:10px' }, + h('button', { id: 'add-restore', class: 'btn-block', style: 'padding:14px', onClick: () => { + clearSeedDrafts(); + ui.unlockTab = 'import'; ui.unlockError = ''; ui.addStep = null; + render(); + } }, t('onbRestoreDoor')), + featureHook('frontDoorSignin', { short: true })), + h('button', { class: 'linklike small', onClick: () => { ui.nostrLoginError = ''; ui.addStep = 'welcome'; render(); } }, t('back')), + ]); + } + return page([ + h('div', { class: 'onb-hero' }, brandHeader(false)), + title(t('addAccountTitle')), + h('div', { class: 'col', style: 'gap:10px' }, + h('button', { + id: 'add-create', + class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => { + clearSeedDrafts(); + ui.unlockTab = 'create'; ui.unlockError = ''; ui.createStep = 'intro'; ui.confirm = []; + ui.draftRandom = ui.draftMnemonic = newMnemonic(); + ui.addStep = null; + render(); + }, + }, t('onbStart')), + h('button', { + id: 'add-signin', + class: 'btn-block', style: 'font-size:17px;padding:14px', + onClick: () => { ui.nostrLoginError = ''; ui.addStep = 'doors'; render(); }, + }, t('onbSignInExisting'))), + accounts.length ? h('button', { class: 'linklike small', onClick: toWallet }, t('back')) : null, + ]); +} + function unlockScreen() { + if (ui.fromWallet && ui.addFlow) { + if (ui.addStep && !ui.entropyPage) return addAccountScreen(); + // a choice made there: the same seed pages as the front door, whose + // Back steps back to where it was made + return h('div', { class: 'col onb', style: 'gap:20px' }, + h('h2', { class: 'onb-title' }, t(ui.unlockTab === 'import' ? 'onbRestoreTitle' : 'onbSeedTitle')), + unlockCard(), + ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { + if (createStepBack()) return; + const from = ui.unlockTab === 'import' ? 'doors' : 'welcome'; + clearSeedDrafts(); ui.draftMnemonic = ''; ui.createStep = 'intro'; ui.confirm = []; ui.unlockError = ''; + ui.addStep = from; render(); + } }, t('back'))); + } return h( 'div', { class: 'col', style: 'gap:16px' }, @@ -1430,6 +1563,27 @@ function tabBtn(label, active, onClick) { } function createPane() { + // A heads-up before the words appear: they are secret, and the screen is + // about to show them (Adam, 2026-11-07). No warning icon — a plain note. + if (ui.createStep === 'intro' || !ui.createStep) { + const n = ui.draftMnemonic ? ui.draftMnemonic.split(' ').length : 12; + return h( + 'div', + { class: 'col seed-intro' }, + h('p', { style: 'margin:0' }, t('seedIntroBody', { n })), + h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')), + h('button', { + id: 'seed-intro-next', + class: 'btn-primary btn-block', style: 'padding:14px', + onClick: () => { + if (!ui.draftMnemonic) ui.draftRandom = ui.draftMnemonic = newMnemonic(); + ui.unlockError = ''; + ui.createStep = 'gen'; + render(); + }, + }, t('seedIntroShow')), + ); + } if (ui.createStep === 'gen') { // The wizard shows words, never a Generate button: a refresh restores // the step but not the draft (drafts live in memory only), so draw a @@ -1457,7 +1611,6 @@ function createPane() { return h( 'div', { class: 'col' }, - h('div', { class: 'warn-box' }, t('writeDownWarn')), h( 'div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, @@ -1468,74 +1621,78 @@ function createPane() { h('button', { class: 'btn-block', style: 'display:flex;align-items:center;justify-content:center;gap:8px', onClick: () => copy(ui.draftMnemonic) }, h('span', { style: 'display:flex', html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>' }), t('copy')), - optionsPanel(), h( 'button', { + id: 'seed-next', class: 'btn-primary btn-block', - onClick: () => { - // catch a mistyped passphrase here, while the field to fix it is - // still on screen — the verify step only samples the words - if (ui.passphrase && ui.confirmPass !== ui.passphrase) { - ui.unlockError = t('passphraseMismatch'); render(); return; - } - ui.confirm = pickConfirm(words); - ui.unlockError = ''; - ui.createStep = 'confirm'; - render(); - }, + onClick: () => { ui.unlockError = ''; ui.createStep = 'pass'; render(); }, }, - t('verifyBackup') - ), - h( - 'button', - { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, - t('skipVerification') + t('next') ) ); } - // confirm step (optional — reachable via "Verify backup") + // The passphrase, on a page of its own (Adam, 2026-11-07): what it is, + // that it's optional but recommended, and the field — a small "Add + // passphrase" button under the words was easy to miss. + if (ui.createStep === 'pass') { + const words = ui.draftMnemonic.split(' '); + const next = () => { + // a typo here IS a different wallet: caught while the fields are up + if (ui.passphrase && ui.confirmPass !== ui.passphrase) { + ui.unlockError = t('passphraseMismatch'); render(); return; + } + ui.confirm = pickConfirm(words); + ui.unlockError = ''; + ui.createStep = 'confirm'; + render(); + }; + return h( + 'div', + { class: 'col' }, + h('h3', { style: 'margin:0' }, t('passStepTitle')), + h('p', { style: 'margin:0' }, t('passStepBody', { n: words.length })), + h('p', { class: 'muted', style: 'margin:0' }, t('passStepNote')), + ...passFields(), + h('button', { id: 'pass-next', class: 'btn-primary btn-block', onClick: next }, t(ui.passphrase ? 'next' : 'passStepNone')), + ); + } + // The check: which word is #5? One tap from a few of the phrase's own + // words, or Skip (Adam, 2026-11-07) — no typing. + const q = ui.confirm; + if (!q || !q.options) { ui.createStep = 'pass'; return createPane(); } return h( 'div', { class: 'col' }, - h('p', { class: 'muted' }, t('confirmBackupIntro')), - ...ui.confirm.map((c, i) => - h( - 'label', - { class: 'field' }, - h('span', { class: 'lab' }, t('wordN', { n: c.index + 1 })), - h('input', { - type: 'text', - class: 'mono-input', - autocapitalize: 'none', - autocomplete: 'off', - spellcheck: 'false', - value: c.value, - onInput: (e) => (ui.confirm[i].value = e.target.value.trim()), - }) - ) - ), - h('div', { class: 'row gap6' }, - h('button', { class: 'btn-ghost', onClick: () => { ui.createStep = 'gen'; render(); } }, t('back')), + h('p', { style: 'margin:0' }, t('quizPrompt', { n: q.index + 1 })), + h('div', { class: 'quiz-opts' }, q.options.map((w) => h('button', { - class: 'btn-primary grow', + type: 'button', + class: 'quiz-opt' + (q.wrong && q.wrong.includes(w) ? ' wrong' : ''), onClick: () => { const words = ui.draftMnemonic.split(' '); - const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]); - if (!ok) { ui.unlockError = t('wordsMismatch'); render(); return; } - openWallet(ui.draftMnemonic, { generated: true }); + if (w === words[q.index]) { ui.unlockError = ''; openWallet(ui.draftMnemonic, { generated: true }); return; } + (q.wrong ||= []).includes(w) || q.wrong.push(w); + render(); }, - }, t('openWallet')) - ), - h('button', { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, t('skipVerification')) + }, w))), + q.wrong && q.wrong.length ? h('div', { class: 'notice err' }, t('quizWrong')) : null, + h('button', { id: 'seed-skip', class: 'btn-ghost btn-block', onClick: () => { ui.unlockError = ''; openWallet(ui.draftMnemonic, { generated: true }); } }, t('skip')), + // outside the seed pages (no Back of their own), a way back to the words + seedPages() ? null : h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.createStep = 'pass'; ui.unlockError = ''; render(); } }, t('back')), ); } -function pickConfirm(words) { - const idx = new Set(); - while (idx.size < 3) idx.add(Math.floor(Math.random() * words.length)); - return [...idx].sort((a, b) => a - b).map((index) => ({ index, value: '' })); +// The 5th word and three others from the same phrase, shuffled: knowing the +// words isn't enough, you need them in order. +function pickConfirm(words, index = Math.min(4, words.length - 1)) { + const right = words[index]; + const others = [...new Set(words.filter((w) => w !== right))]; + for (let i = others.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [others[i], others[j]] = [others[j], others[i]]; } + const options = [right, ...others.slice(0, 3)]; + for (let i = options.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [options[i], options[j]] = [options[j], options[i]]; } + return { index, options, wrong: [] }; } // The BYO-entropy page, shared by Create (re-derives the draft phrase) and @@ -1585,7 +1742,7 @@ function entropyPage() { // tabs, backing out of the entropy page, going home. Whatever you come back // to is empty. (An opened wallet clears these too, in openWallet/lock.) function clearSeedDrafts() { - ui.importText = ''; ui._entropyFilled = null; + ui.importText = ''; ui._entropyFilled = null; ui.passOpen = false; ui.entropyEntry = ''; ui.ownEntropy = ''; ui.importEntropy = ''; } @@ -1623,18 +1780,34 @@ function importPane() { } function optionsPanel() { + // Most people want no passphrase, and an empty field labelled "Passphrase" + // under the seed reads as a required password. It waits behind a button, + // and once opened says it's optional (Adam, 2026-11-06). A passphrase + // already typed keeps it open. + if (!ui.passOpen && !ui.passphrase) { + return [h('button', { + class: 'btn-block', type: 'button', + onClick: (e) => { e.preventDefault(); ui.passOpen = true; render(); }, + }, t('addPassphrase'))]; + } + return passFields({ label: true }); +} + +// The passphrase field (+ its confirmation once something is typed). The +// create flow's own page explains it above; the import pane labels it here. +function passFields({ label = false } = {}) { const pass = h( 'label', { class: 'field' }, h('span', { class: 'lab' }, - t('passphrase'), + t(label ? 'passphraseOptional' : 'passphrase'), // People assume a password logs in or encrypts; a BIP39 passphrase does // neither, and getting that wrong loses money — worth a sentence here. h('button', { class: 'linklike small', type: 'button', style: 'margin-left:8px', onClick: (e) => { e.preventDefault(); ui.passInfoOpen = !ui.passInfoOpen; render(); }, }, t('passWhatsThis'))), - ui.passInfoOpen ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passInfo')) : null, + ui.passInfoOpen ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passInfo')) : label ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passOptionalHint')) : null, h( 'div', { class: 'input-group' }, @@ -1663,7 +1836,7 @@ function optionsPanel() { class: 'mono-input', autocomplete: 'off', value: ui.confirmPass, - onInput: (e) => (ui.confirmPass = e.target.value), + onInput: (e) => { ui.confirmPass = e.target.value; if (ui.unlockError && ui.confirmPass === ui.passphrase) { ui.unlockError = ''; render(); } }, }) ) : null; @@ -1874,8 +2047,9 @@ async function activateAccount(acc, opts = {}) { function signInAnother() { clearSeedDrafts(); ui.fromWallet = true; ui.unlockError = ''; ui.screen = 'unlock'; - // Starts on Create new (its Generate button), not the seed-import box. - ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.confirm = []; + ui.addFlow = true; ui.addStep = 'welcome'; ui.nostrLoginOpen = false; ui.nostrLoginError = ''; + // Opens on the front door's two choices (addAccountScreen). + ui.unlockTab = 'create'; ui.createStep = 'intro'; ui.draftMnemonic = ''; ui.confirm = []; ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ui.chatOpen = false; render(); } @@ -2122,6 +2296,42 @@ function switchAccount(id) { // Restore accounts after a refresh (sessionStorage); on a fresh session, prompt // to unlock the encrypted vault if there is one, else seed from watch-only // accounts. Returns true if it handled the entry (opened or showed a prompt). +// Whether restoreAccountsState() is about to open a wallet, read from what +// it reads but without its work: a session list in this tab, a vault the +// last unlock opened without a password, or watch-only accounts. The vault +// alone can't say whether a blank password opens it short of the scrypt +// that is the cost being avoided, so the last seal or unlock leaves a note. +const VAULT_BLANK_KEY = 'btc-wallet-vault-blank'; +const noteVaultBlank = (password) => { try { if (password === '') localStorage.setItem(VAULT_BLANK_KEY, '1'); else localStorage.removeItem(VAULT_BLANK_KEY); } catch {} }; +const vaultOpensBlank = () => { try { return localStorage.getItem(VAULT_BLANK_KEY) === '1'; } catch { return false; } }; +function bootWillRestore() { + if (!unlockDeadlinePassed()) { + try { const s = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); if (s && Array.isArray(s.accounts) && s.accounts.length) return true; } catch {} + } + if (hasVault()) return vaultOpensBlank(); + return loadWatchAccounts().length > 0; +} +const restorePending = () => _restorePending && bootWillRestore(); + +// The phrases the restore below is about to open, stretched into their seeds +// natively (seed.js) while the stores open: the restore and the wallet then +// read them instead of stretching each in JavaScript, once per use. +async function warmBootSeeds() { + let list = []; + try { + if (!unlockDeadlinePassed()) { + const s = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); + if (s && Array.isArray(s.accounts)) list = s.accounts; + } + } catch {} + if (!list.length && vaultOpensBlank()) { + try { list = decryptVault(loadVaultBlob(), ''); } catch {} + } + await Promise.all((Array.isArray(list) ? list : []) + .filter((a) => a && a.mnemonic && !a.xprv) + .map((a) => warmSeed(a.mnemonic, a.passphrase || ''))); +} + function restoreAccountsState() { let sess = null; try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {} @@ -2173,6 +2383,7 @@ const passkeySecret = (pw) => typeof pw === 'string' && pw.startsWith('passkey:' const vaultPasskey = () => (loadVaultBlob() || {}).passkey || null; function sealVault(list, password) { const b = encryptVault(list, password); + noteVaultBlank(password); if (passkeySecret(password) && vaultPasskeyId) b.passkey = vaultPasskeyId; return b; } @@ -2328,7 +2539,7 @@ function submitPw() { let list; try { list = decryptVault(loadVaultBlob(), p.v0 || ''); } catch { p.error = t('pwWrong'); render(); return; } if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; } - try { localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, p.v1 || ''))); } catch {} + try { localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, p.v1 || ''))); noteVaultBlank(p.v1 || ''); } catch {} if (vaultPassword != null) vaultPassword = p.v1 || ''; // keep the unlocked session in sync ui.pw = null; render(); @@ -2469,6 +2680,7 @@ async function submitPwWithPasskey() { function attemptVaultUnlock(password) { let list; try { list = decryptVault(loadVaultBlob(), password); } catch { return false; } + noteVaultBlank(password); vaultPassword = password; vaultPasskeyId = passkeySecret(password) ? vaultPasskey() : null; // Start from the durable directory (watch-only views of every known wallet), @@ -2686,6 +2898,7 @@ function lock({ offerPassword = false } = {}) { _featuresInited = false; for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} } clearAccounts(); + forgetSeeds(); vaultPassword = null; vaultPasskeyId = null; wallet.load({ mnemonic: '', passphrase: '', netName: getNetwork(), offline: false }); @@ -2709,14 +2922,14 @@ function lock({ offerPassword = false } = {}) { ui.confirmRemove = null; ui.editId = null; ui.editLabel = ''; - ui.createStep = 'gen'; + ui.createStep = 'intro'; ui.draftMnemonic = ''; ui.importText = ''; ui.entropyPage = null; ui.entropyEntry = ''; ui.ownEntropy = ''; ui.importEntropy = ''; - ui.passphrase = ''; + ui.passphrase = ''; ui.passOpen = false; ui.confirmPass = ''; ui.confirm = []; ui.revealShown = false; @@ -2865,6 +3078,8 @@ function headerSearch() { type: 'text', class: 'header-search', placeholder: t('searchUsers') + '…', 'aria-label': t('searchUsers'), value: (ui.userSearch && ui.userSearch.q) || '', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', onInput: (e) => { clearFeatureNavKeepSearch(); featureHook('userSearchQuery', e.target.value); }, + // Enter without picking a suggestion: full-text search over notes + onKeydown: (e) => { if (e.key === 'Enter' && !e.isComposing && e.target.value.trim()) { e.preventDefault(); featureHook('userSearchSubmit', e.target.value); e.target.blur(); } }, }); } function clearFeatureNavKeepSearch() { @@ -2996,7 +3211,9 @@ function settingsTab() { switch (ui.settingsPage) { // the recovery phrase first: it is the one thing on this page that // matters if the phone is lost tonight - case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard()] : []); + case 'wallet': + if (ui.delAcct && a) return deleteAccountView(a); + return page(a ? [recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []); case 'payments': return page(featureAll('settingsCards').reverse()); case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now @@ -3291,7 +3508,7 @@ function goHome() { ui.sendError = ''; ui.fromWallet = false; } else if ((ui.onb && ui.onb.step !== 'welcome') - || ui.draftMnemonic || ui.createStep !== 'gen' || ui.importText) { + || ui.draftMnemonic || !['intro', 'gen'].includes(ui.createStep) || ui.importText) { // Mid-create with no wallet minted yet (wizard step, a generated seed on // screen, the confirm quiz, a pasted import): the logo backs out to the // start page this began from — not the vault prompt, which reads as a @@ -3301,7 +3518,7 @@ function goHome() { ui.nostrLoginOpen = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; - ui.createStep = 'gen'; + ui.createStep = 'intro'; ui.draftMnemonic = ''; clearSeedDrafts(); ui.entropyPage = null; @@ -3320,7 +3537,7 @@ function goHome() { } else { ui.screen = 'unlock'; ui.unlockTab = 'create'; - ui.createStep = 'gen'; + ui.createStep = 'intro'; ui.draftMnemonic = ''; ui.confirm = []; ui.unlockError = ''; @@ -3400,8 +3617,8 @@ function accountsScreen() { if (aw.from === 'new') { ui.newWalletKind = kind; ui.addWallet = null; - ui.draftMnemonic = ''; ui.createStep = 'gen'; ui.confirm = []; ui.passphrase = ''; ui.confirmPass = ''; - ui.showPass = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; + ui.draftMnemonic = ''; ui.createStep = 'intro'; ui.confirm = []; ui.passphrase = ''; ui.confirmPass = ''; ui.passOpen = false; + ui.showPass = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; ui.addFlow = false; render(); } else { createDerivedWallet(aw.from, kind); @@ -3564,6 +3781,76 @@ function openAccountSettings(id) { // Per-wallet settings cards, shared by the per-account screen (Accounts → ⚙) // and the main Settings tab (which inlines them for the active wallet). +// Delete account: the server forgets this wallet (its name is freed, its +// records go) and the wallet leaves this device. "someone's asking for a way +// to delete their v3 account. we allowed that in v2" (Adam, 2026-11-10). The +// coins are the user's own and no server can delete them: they stay +// spendable from the recovery phrase, which is why the page says how much is +// still here before anything happens. +// The saved copy on this device has to go too, and the vault can only be +// rewritten with its password in hand: a blank one is tried here, a real one +// is known once the app has been unlocked with it this session. +function vaultWritable() { + if (vaultPassword != null || !hasVault()) return true; + try { const list = decryptVault(loadVaultBlob(), ''); vaultPassword = ''; mergeVaultList(list); return true; } catch { return false; } +} +function deleteAccountCard(a) { + if (a.type === 'watch' || wallet.watchOnly || !wallet.nostrSign) return null; + return h('div', { class: 'card col', 'data-key': 'delete-account' }, + h('h3', {}, t('delAcctTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('delAcctBlurb')), + h('button', { class: 'btn-danger btn-block', onClick: () => { + if (!vaultWritable()) { toast(t('delAcctUnlockFirst')); return; } + const d = ui.delAcct = { accId: a.id, info: null, sure: false, busy: false, error: '' }; + render(); + Promise.resolve(featureHook('accountDelete', true)).then((info) => { if (ui.delAcct === d) { d.info = info || {}; render(); } }) + .catch((e) => { if (ui.delAcct === d) { d.info = {}; d.error = e.message; render(); } }); + } }, t('delAcctTitle'))); +} +function deleteAccountView(a) { + const d = ui.delAcct; + const heldSat = (wallet.spendable || 0) + (featureHook('arkHeldSat') || 0); + const info = d.info; + const names = (info && info.names) || []; + const owedSat = info ? (info.pendingSat || 0) + (info.withdrawSat || 0) : 0; + const blocked = !!(info && (info.blocked || owedSat > 0 || info.paying)); + const back = () => { ui.delAcct = null; render(); }; + const go = async () => { + if (d.busy || !d.sure || blocked) return; + d.busy = true; d.error = ''; render(); + try { + const r = await featureHook('accountDelete', false); + if (r && r.blocked) { d.info = { ...d.info, ...r, blocked: true }; d.sure = false; return; } + ui.delAcct = null; ui.settingsPage = null; + wipeAccountCache(a); + // out of the saved vault first, or the next unlock brings it back + if (a.persisted && vaultWritable()) { a.persisted = false; writeVault(); } + removeAccount(a.id); + toast(t('delAcctDone')); + } catch (e) { d.error = e.message; } + finally { d.busy = false; render(); } + }; + return h('div', { class: 'col', style: 'gap:16px' }, + h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', {}, t('delAcctTitle')), + !info ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('delAcctChecking'))) : null, + h('ul', { class: 'small', style: 'margin:0;padding-left:18px;display:flex;flex-direction:column;gap:6px' }, + names.length ? h('li', {}, t('delAcctName', { name: names.join(', ') })) : null, + h('li', {}, t('delAcctRecords')), + h('li', {}, t('delAcctDevice', { name: seedName(a) })), + h('li', { class: 'muted' }, t('delAcctStays'))), + blocked ? h('div', { class: 'warn-box' }, t('delAcctOwed', { sat: fmtAmount(owedSat) })) : null, + heldSat > 0 ? h('div', { class: 'warn-box' }, t('delAcctFunds', { sat: fmtAmount(heldSat) })) : null, + d.error ? h('div', { class: 'warn-box' }, d.error) : null, + h('label', { class: 'row gap6 small', style: 'align-items:flex-start;cursor:pointer' }, + h('input', { type: 'checkbox', checked: d.sure, disabled: blocked || d.busy, style: 'margin-top:2px', onChange: (e) => { d.sure = e.target.checked; render(); } }), + h('span', {}, t(heldSat > 0 ? 'delAcctSureFunds' : 'delAcctSure'))), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost grow', disabled: d.busy, onClick: back }, t('back')), + h('button', { class: 'btn-danger grow', disabled: !info || !d.sure || blocked || d.busy, onClick: go }, + d.busy ? h('span', { class: 'spinner sm' }) : t('delAcctGo'))))); +} + function recoveryCard(a) { const isWatch = a.type === 'watch' || (!a.mnemonic && !a.xprv); const shown = ui.revealShown; @@ -3619,6 +3906,23 @@ function recoveryCard(a) { ); } +// The balance cards' gears, reachable from Settings → Wallet too: "I simply +// cannot find this option" (Nils, 2026-11-12) about the Spending card's gear. +function balanceSettingsCard(a) { + if (a.id !== activeId) return null; + const views = a.kind ? [a.kind] : spendingActive() ? ['spending', 'savings'] : ['savings']; + const row = (view) => h('button', { + class: 'btn-ghost btn-block balance-settings-row', 'data-view': view, + style: 'display:flex;align-items:center;gap:10px;text-align:left;padding:10px 4px', + onClick: () => openWalletSettings(view), + }, + h('span', { style: 'display:flex;color:var(--muted)', html: GEAR_ICON(18) }), + h('span', { class: 'col', style: 'flex:1;gap:2px' }, + h('span', { style: 'font-weight:600' }, viewLabel(a, view)), + h('span', { class: 'small muted' }, t(view === 'spending' ? 'spendingSettingsDesc' : 'savingsSettingsDesc'))), + h('span', { class: 'muted' }, '›')); + return h('div', { class: 'card col', style: 'gap:4px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row)); +} function walletNameCard(a) { const saveName = () => { const v = (ui.editLabel || '').trim(); @@ -3939,7 +4243,11 @@ async function publicMediaUpload(file) { if (error instanceof MediaUploadError && error.code === 'unauthorized') throw new Error(t('mediaUploadUnauthorized')); if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig')); if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported')); - throw new Error(t('msgUploadFailed')); + // what went wrong, said: "check your connection" alone hid a signer + // that never answered and hosts that refused the file + const why = String((error instanceof MediaUploadError ? error.details : '') || error?.message || '').slice(0, 200); + console.warn('media upload failed', error, why); + throw new Error(why && why !== 'failed' ? `${t('msgUploadFailed')} (${why})` : t('msgUploadFailed')); } finally { ui.mediaProgress = null; } @@ -4092,14 +4400,14 @@ function onboardScreen() { const startCreate = () => { ui.unlockTab = 'create'; ui.unlockError = ''; - ui.createStep = 'gen'; + ui.createStep = 'intro'; if (!ui.draftMnemonic) ui.draftRandom = ui.draftMnemonic = newMnemonic(); o.step = 'seed'; render(); }; // All the sign-in doors (Google, passkey, Nostr) — the feature supplies // them; the single-button fallback keeps builds without it working. - const signinDoors = () => featureHook('frontDoorSignin') + const signinDoors = (opts) => featureHook('frontDoorSignin', opts) || h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => { o.step = 'signin'; ui.nostrLoginOpen = true; @@ -4108,29 +4416,57 @@ function onboardScreen() { h('span', { style: 'display:flex;flex-shrink:0', html: NOSTR_MARK }), t('nlSignIn'))); if (o.step === 'welcome') { + // Two ways in, nothing else: a new wallet, or one you already have. The + // front page had grown a door per sign-in method (Google, passkey, Nostr, + // twelve words, coinos.io) and read as a form; they now wait one step + // further in, behind the choice that leads to them. return page([ h('div', { class: 'onb-hero' }, brandHeader(false)), title(t('onbWelcomeTitle')), - // Making your own keys is the path; a sign-in is the alternative, - // below the line. - h('button', { - // a stable hook for tests, so this button survives its own copy - id: 'onb-create', - class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate, - }, t('onbStart')), - h('div', { class: 'onb-or' }, h('span', {}, t('onbOrSignIn'))), - signinDoors(), - // The move from the original coinos.io used to wait behind the - // Spending offer, where nobody looking for it found it. Its own door - // on the front page: it stashes the intent the /migrate link stashes - // (the move needs a wallet and a payment address first) and explains - // that before any twelve words appear. - h('button', { id: 'onb-migrate', class: 'btn-block', style: 'padding:14px', onClick: () => { - try { localStorage.setItem(MIGRATE_LINK_KEY, String(Date.now())); } catch {} - ui.onbError = ''; - o.step = 'migrate'; - render(); - } }, t('onbMigrateDoor')), + // spaced as the sign-in list is (10px) + h('div', { class: 'col', style: 'gap:10px' }, + h('button', { + // a stable hook for tests, so this button survives its own copy + id: 'onb-create', + class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate, + }, t('onbStart')), + h('button', { + id: 'onb-signin', + class: 'btn-block', style: 'font-size:17px;padding:14px', + onClick: () => { ui.onbError = ''; o.step = 'doors'; render(); }, + }, t('onbSignInExisting'))), + ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, + ]); + } + if (o.step === 'doors') { + return page([ + h('div', { class: 'onb-hero' }, brandHeader(false)), + title(t('onbSigninTitle')), + // The seed phrase first: it IS the wallet, and works anywhere. It + // used to hide as a line under the freshly minted words behind + // "Create a new wallet", where a person signing in on their second + // phone found it only by chance (user feedback, 2026-11-02). + // one evenly spaced list: seed phrase, passkey, Google, Nostr, coinos.io + h('div', { class: 'col', style: 'gap:10px' }, + h('button', { id: 'onb-restore', class: 'btn-block', style: 'padding:14px', onClick: () => { + clearSeedDrafts(); + ui.unlockTab = 'import'; + ui.unlockError = ''; + ui.onbError = ''; + o.step = 'seed'; + render(); + } }, t('onbRestoreDoor')), + signinDoors({ short: true }), + // The move from the original coinos.io: it stashes the intent the + // /migrate link stashes (the move needs a wallet and a payment address + // first) and explains that before any twelve words appear. + h('button', { id: 'onb-migrate', class: 'btn-block', style: 'padding:14px', onClick: () => { + try { localStorage.setItem(MIGRATE_LINK_KEY, String(Date.now())); } catch {} + ui.onbError = ''; + o.step = 'migrate'; + render(); + } }, t('onbMigrateDoor'))), + h('button', { class: 'linklike small', onClick: () => { ui.onbError = ''; o.step = 'welcome'; render(); } }, t('back')), ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, ]); } @@ -4146,8 +4482,8 @@ function onboardScreen() { class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate, }, t('onbMigrateCreate')), h('div', { class: 'onb-or' }, h('span', {}, t('onbOrSignIn'))), - signinDoors(), - h('button', { class: 'linklike small', onClick: () => { endMigrateLink(); ui.onbError = ''; o.step = 'welcome'; render(); } }, t('back')), + h('div', { class: 'col', style: 'gap:10px' }, signinDoors()), + h('button', { class: 'linklike small', onClick: () => { endMigrateLink(); ui.onbError = ''; o.step = 'doors'; render(); } }, t('back')), ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, ]); } @@ -4166,27 +4502,36 @@ function onboardScreen() { ui.tab = 'receive'; render(); }; + // Later means later, not never: seedSeen stays false, so the wallet + // keeps a quiet reminder until the phrase has actually been seen. + // Except on the way to a coinos.io move: the balance about to arrive + // must have its twelve words written down first, so Later isn't offered. + const later = migrateLinkPending() ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { + if (canOfferSpending()) { o.step = 'spend'; o.enterAddr = undefined; render(); return; } + ui.onb = null; + try { localStorage.removeItem(ONB_STEP_KEY); } catch {} + ui.account = 'savings'; + try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + ui.tab = 'receive'; + render(); + } }, t('onbBackupLater')); + // a heads-up first: the next screen shows the secret (Adam, 2026-11-07) + if (!o.backupShown) { + return page([ + title(t('onbBackupTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t('onbBackupBody')), + h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')), + h('button', { id: 'onb-backup-show', class: 'btn-primary btn-block', style: 'padding:14px', onClick: () => { o.backupShown = true; render(); } }, t('onbBackupShow')), + later, + ]); + } return page([ title(t('onbBackupTitle')), - h('p', { class: 'muted', style: 'margin:0' }, t('onbBackupBody')), - h('div', { class: 'warn-box' }, t('writeDownWarn')), h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w)))), h('div', { class: 'row gap6' }, copyBtn(acc.mnemonic, t('copyPhrase'))), h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: done }, t('onbBackupDone')), - // Later means later, not never: seedSeen stays false, so the wallet - // keeps a quiet reminder until the phrase has actually been seen. - // Except on the way to a coinos.io move: the balance about to arrive - // must have its twelve words written down first, so Later isn't offered. - migrateLinkPending() ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { - if (canOfferSpending()) { o.step = 'spend'; o.enterAddr = undefined; render(); return; } - ui.onb = null; - try { localStorage.removeItem(ONB_STEP_KEY); } catch {} - ui.account = 'savings'; - try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} - ui.tab = 'receive'; - render(); - } }, t('onbBackupLater')), + later, ]); } if (o.step === 'seed') { @@ -4194,10 +4539,10 @@ function onboardScreen() { // means seeing (or bringing) your seed phrase, not a wallet minted // behind your back. Opening one ends the wizard into the wallet + tour. return page([ - title(t('onbSeedTitle')), + title(t(ui.unlockTab === 'import' ? 'onbRestoreTitle' : 'onbSeedTitle')), unlockCard(), // the entropy page brings its own Back — one is enough - ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { ui.unlockError = ''; o.step = 'welcome'; render(); } }, t('back')), + ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { if (createStepBack()) return; ui.unlockError = ''; o.step = ui.unlockTab === 'import' ? 'doors' : 'welcome'; render(); } }, t('back')), ]); } if (o.step === 'signin') { @@ -4205,21 +4550,23 @@ function onboardScreen() { h('div', { class: 'onb-mark', html: NOSTR_MARK }), title(t('onbSigninTitle')), featureHook('unlockExtra') || h('div', { class: 'notice err' }, 'nostr login unavailable'), - h('button', { class: 'linklike small', onClick: () => { o.step = 'welcome'; render(); } }, t('back')), + h('button', { class: 'linklike small', onClick: () => { o.step = 'doors'; render(); } }, t('back')), ]); } if (o.step === 'spend') { return page([ - title(t('onbSpendTitle')), - h('p', { class: 'muted', style: 'margin:0' }, t('onbSpendBody')), + title(t(o.fromGift ? 'onbGiftNameTitle' : 'onbSpendTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : 'onbSpendBody')), featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))), // Optional means optional: one tap to the wallet, no questions — on - // Savings, the account that exists without any setup. + // Savings, the account that exists without any setup (or, after a + // gift, the account the gift arrived in). h('button', { class: 'btn-ghost btn-block', onClick: () => { + const home = o.home || 'savings'; ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} - ui.account = 'savings'; - try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + ui.account = home; + try { localStorage.setItem(ACCOUNT_KEY, home); } catch {} ui.tab = 'receive'; render(); } }, t('onbNotNow')), @@ -4439,6 +4786,15 @@ function claimMigratedName() { attempt(); } +// After a gift is claimed into a fresh wallet (and its seed written down): +// the wizard's name → look → done steps, the rest of a first run. `home` is +// the account a skip lands on — the one the gift arrived in. +function offerProfileSetup({ home = 'savings' } = {}) { + if (!canOfferSpending()) return false; + ui.onb = { step: 'spend', enterAddr: undefined, fromGift: true, home }; + return true; +} + function leaveWizard() { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} @@ -6454,6 +6810,8 @@ const ctx = { // support) — lazy so it resolves against the final FEATURES list, and a // build without the target feature simply gets null back hook: (name, ...args) => featureHook(name, ...args), + // boot is about to open a wallet (a page may paint as that wallet's already) + restorePending, // full sign-out (distinct from the padlock's lock-in-place): clear the // session and leave — offering a password if none protects the vault yet logout: () => { @@ -6480,6 +6838,7 @@ const ctx = { || (wallet.nostrPubkey && wallet.nostrPubkey()) || (activeAccount() || {}).nostrPk || null, getAccount: () => accountSel(), setAccount: (a, dir) => setAccountSel(a, dir), + offerProfileSetup, pickConfirm, // Open (or create) a wallet from a mnemonic — used by nostr login. openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts), // Profile pictures retain the metadata-scrubbing image uploader. Public @@ -6895,14 +7254,16 @@ loadLocale(getLang()).finally(async () => { const storeReady = featureHook('arkStoreReady'); // the IndexedDB caches (profiles, reports, …) open alongside: a wallet's // caches are read as soon as it opens - await Promise.all([storeReady, cacheReady()].filter(Boolean)).catch(() => {}); + await Promise.all([storeReady, cacheReady(), warmBootSeeds()].filter(Boolean)).catch(() => {}); // Ask the browser not to evict this site's storage under disk pressure. // Chrome answers without a prompt (yes for an installed or busy site). try { navigator.storage?.persist?.().catch(() => {}); } catch {} _bootDeciding = false; // from here on the screen is a decision, not a guess const opened = restoreAccountsState(); - if (!opened) render(); + _restorePending = false; + if (!opened || !activeAccount()) render(); bootPhase('ui'); + initPullRefresh(); // the app and the Beta have no browser pull-to-refresh setTimeout(bootSettled, 8000); if (WAKE_PAYLOAD != null) { // Nothing opened — a locked vault, or no wallet on this device. There is diff --git a/src/ark/manager.js b/src/ark/manager.js index caece44..8aa8707 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -92,6 +92,16 @@ const actionTs = (a) => Number(String(a.id || '').split('-').pop()) || 0; // claim recorded before it stamped them — the same amount within minutes). // Derived at read time, never deleted: a synced copy from another device // can't bring a twin back. +// Two receive rows that are one payment delivered as several coins (a gift +// held in two swept at once, a dust-padded split): the same mailbox message +// (`batch`), or for rows recorded before the stamp, the same delivery instant. +// A receive the registrar's receipts tagged ⚡ is its own payment. +export function sameDelivery(a, b) { + return !!a && !!b && a.type === 'receive' && b.type === 'receive' + && a.status === 'complete' && b.status === 'complete' && !a.viaLn && !b.viaLn + && (a.batch || b.batch ? a.batch === b.batch : !!a.ts && a.ts === b.ts); +} + export function lnFoldedIds(movements) { const out = new Set(); const recs = (movements || []).filter((m) => m.type === 'receive' && m.status === 'complete' && m.vtxoId); @@ -226,7 +236,7 @@ export function renewPolicy(tier, network) { } export class ArkManager { - constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null }) { + constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null, lnOldestFirst }) { this.account = account; // HDKey node; ark keys derived beneath it // The server's last published info, kept by the app: lets the wallet open // when the server can't be reached (see init) @@ -248,6 +258,9 @@ export class ArkManager { : network === 'mutinynet' ? 'https://names.halwallet.app/lnquote?network=mutinynet' : null; this.onUpdate = onUpdate || (() => {}); + // () => true when this wallet renews its own coins (custody tier self or + // exit): a Lightning send then spends the coins nearest expiry first + this.lnOldestFirst = lnOldestFirst || (() => false); this.state = null; this.info = null; this._lnDriving = new Set(); // re-entrancy guard: sync poll vs UI fast-poll vs mailbox push @@ -297,6 +310,9 @@ export class ArkManager { seen.add(m.vtxoId); return true; }); + // (the handshake goes out beside the info call, not after it: a wake of + // the phone's worker pays for every round trip in the zap it answers) + const hsP = handshake(this.arkUrl).catch(() => null); try { this.info = await getArkInfo(this.arkUrl); this.offline = false; @@ -310,7 +326,7 @@ export class ArkManager { this.info = this.infoHint; this.offline = true; } - const hs = await handshake(this.arkUrl).catch(() => null); + const hs = await hsP; this.psa = hs?.psa; this.serverPub = hex.decode(this.info.serverPubkey); @@ -374,6 +390,20 @@ export class ArkManager { return Date.now() - age * 600_000; } + // The tip for a payment about to be built: one asked for moments ago (a + // worker warms it while the manager starts) is as good as asking again. + warmTip() { + const p = this.chain.tipHeight().then((h) => { this._tipH = h; this._tipAt = Date.now(); this._tipNow = Date.now(); return h; }); + this._tipWarm = p; + p.catch(() => {}).finally(() => { if (this._tipWarm === p) this._tipWarm = null; }); + return p; + } + async _tipFresh() { + if (this._tipNow && Date.now() - this._tipNow < 5000 && this._tipH) return this._tipH; + if (this._tipWarm) { try { return await this._tipWarm; } catch { /* ask again */ } } + return this.chain.tipHeight(); + } + async _tipMemo() { if (this._tipAt && Date.now() - this._tipAt < 60_000) return this._tipH; try { @@ -805,9 +835,13 @@ export class ArkManager { // same message concurrently (validation awaits in between), and only the // copy that actually added the vtxo may record the receive movement. if (!this._addVtxo(v, v._raw.bytes, 0)) continue; + // `batch` names the mailbox message: one payment can arrive as several + // coins (a sweep of a gift held in two, a dust-padded split), and the + // history folds a message's coins back into one row this._movement({ type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(v, tip)), + ...(m.checkpoint ? { batch: String(m.checkpoint) } : {}), }); changed = true; } @@ -1129,10 +1163,10 @@ export class ArkManager { const past = list.filter((v) => this._pastExpiry(v, tip)).sort(cmp); return [...live, ...past]; } - // largest-first until `need` is covered; the coins still inside their + // largest-first (or in `order`) until `need` is covered; the coins inside their // timelock alone if they can carry it, else everything - _accumulate(spendable, tip, need) { - const desc = (a, b) => b.amountSat - a.amountSat; + _accumulate(spendable, tip, need, order) { + const desc = order || ((a, b) => b.amountSat - a.amountSat); const live = spendable.filter((v) => !this._pastExpiry(v, tip)).sort(desc); for (const pool of [live, [...spendable].sort(desc)]) { const picked = []; @@ -1162,13 +1196,48 @@ export class ArkManager { return picked; } + // Inputs for a Lightning send of `amountSat` plus `routing`, and the fee + // they come to: { inputs, feeSat }. Throws when the coins can't cover it. + _selectLnInputs(spendable, tip, amountSat, routing) { + let inputs = null, feeSat = 0; + // A wallet that renews its own coins pays for every renewal, so it + // spends the coins nearest expiry first (Stacker News, 2026-10-29): the + // ASP takes them, so nobody downstream inherits the short timelock, as + // an Ark-to-Ark send's recipient would. Otherwise (and on a tie) the + // smallest coin that covers it, keeping the balance in few coins. + const oldest = !!this.lnOldestFirst(); + const exp = (v) => v.expiryHeight || Infinity; + const bySize = (a, b) => a.amountSat - b.amountSat; + const byAge = (a, b) => (exp(a) - exp(b)) || bySize(a, b); + for (const v of this._spendOrder(spendable, tip, oldest ? byAge : bySize)) { + const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing; + if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; } + } + if (!inputs) { + const need = (set, sum) => { + feeSat = lnSendFee(amountSat, this.info.lnSendFees, set, tip) + routing; + return sum >= amountSat + feeSat; + }; + let picked = oldest ? this._accumulate(spendable, tip, need, byAge) : null; + // too many old crumbs for one payment: fall back to largest-first + if (!picked || picked.length > MAX_SEND_INPUTS) picked = this._accumulate(spendable, tip, need); + if (picked) inputs = picked; + if (!inputs) throw new Error(this._insufficientMsg(tip)); + if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment'); + } + return { inputs, feeSat }; + } + _insufficientMsg(tip) { const expiredSat = this.state.vtxos .filter((v) => v.state === 'spendable' && this._expired(v, tip)) .reduce((n, v) => n + v.amountSat, 0); if (expiredSat) return `insufficient ark balance (${expiredSat} sat expired and unusable)`; const cappedSat = this.state.vtxos.filter((v) => this._free(v) && this._capped(v)).reduce((n, v) => n + v.amountSat, 0); - if (cappedSat) return `insufficient ark balance (${cappedSat} sat have a history too long to spend and are waiting to be renewed)`; + // under dust a round won't take it either: only new money pays from here + if (cappedSat) return cappedSat < 330 + ? `insufficient ark balance (${cappedSat} sat have a history too long to spend and are too few to renew; receive more to keep paying)` + : `insufficient ark balance (${cappedSat} sat have a history too long to spend and are waiting to be renewed)`; // coins a renewal took stay unspendable until its round confirms on-chain const renewing = lnShortfall(this.state.vtxos, this.state.actions, Infinity).renewingSat; return renewing @@ -1369,10 +1438,17 @@ export class ArkManager { async awaitLnPay(id, timeoutMs = 90_000) { const a = this.lnAction(id); if (!a || a.step !== 'initiated') return this.driveLn(id); - await Promise.race([ + const res = await Promise.race([ checkLightningPayment(this.arkUrl, hex.decode(a.paymentHash), true).catch(() => null), new Promise((r) => setTimeout(r, timeoutMs)), ]); + // the held call's own answer settles it: asking once more for the same + // preimage was a round trip between the payment and its reply + if (res && res.status === 'success' && res.preimage && a.step === 'initiated' + && hex.encode(sha256(hex.decode(res.preimage))) === a.paymentHash) { + this._settleLnPay(a, res.preimage); + return a; + } return this.driveLn(id); } @@ -1496,32 +1572,30 @@ export class ArkManager { if (expectNet && dec.network && dec.network !== expectNet) throw new Error(`invoice is for ${dec.network}, wallet is on ${expectNet}`); const amountSat = dec.amountSat ?? userAmountSat; if (!amountSat || amountSat <= 0) throw new Error('invoice has no amount'); - if (this.state.actions.some((a) => - a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed')) { - throw new Error('this invoice was already paid or is being paid'); + const prior = this.state.actions.find((a) => + a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed'); + if (prior) { + // two messages, so a caller can tell a settled payment from one still + // on its way (an NWC client once read "already paid or is being paid" + // as paid, hours before the money moved) + throw new Error(prior.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress'); } - const tip = await this.chain.tipHeight(); - const routing = routingFeeSat != null ? routingFeeSat : await this.lnRouteFee(invoice, amountSat); + const [tip, routing] = await Promise.all([ + this._tipFresh(), + routingFeeSat != null ? routingFeeSat : this.lnRouteFee(invoice, amountSat), + ]); + // the two awaits above let a second call for the same invoice in + const twin = this.state.actions.find((a) => + a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed'); + if (twin) throw new Error(twin.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress'); // smallest single vtxo that covers amount + routing + its expiry-dependent // fee — else gather largest-first: each input funds its own HTLC vtxo with // the same payment hash, and the server sums them into one payment. const busy = this._remoteBusy(); + // a coin at the depth cap can't be an input: the ASP refuses the cosign const spendable = this.state.vtxos - .filter((v) => this._free(v, busy) && !this._expired(v, tip)); - let inputs = null, feeSat = 0; - for (const v of this._spendOrder(spendable, tip, (a, b) => a.amountSat - b.amountSat)) { - const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing; - if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; } - } - if (!inputs) { - const picked = this._accumulate(spendable, tip, (set, sum) => { - feeSat = lnSendFee(amountSat, this.info.lnSendFees, set, tip) + routing; - return sum >= amountSat + feeSat; - }); - if (picked) inputs = picked; - if (!inputs) throw new Error(this._insufficientMsg(tip)); - if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment'); - } + .filter((v) => this._free(v, busy) && !this._expired(v, tip) && !this._capped(v)); + const { inputs, feeSat } = this._selectLnInputs(spendable, tip, amountSat, routing); // per-input HTLC share: first n-1 consumed whole, the last carries change let remainingLock = amountSat + feeSat; const parts = inputs.map((v) => { @@ -1677,10 +1751,22 @@ export class ArkManager { }); action.step = 'initiated'; this._save(); + // Just handed to the server: nothing to learn from asking this + // instant (it answered "pending", and the chain tip was fetched to + // go with it — two round trips before the caller's held call could + // even start). The caller waits on it (awaitLnPay) or polls. + return; } catch (e) { + // the HTLC already went to this payment: another context (a second + // wake of the phone's worker, seconds apart, over its own copy of the + // state) initiated it first. That payment is this one — follow it. + // (PullCap 2026-10-24 23:21: a paid send was marked 'revoking' here.) + if (e instanceof GrpcError && /state: ln-spent/i.test(e.message)) { + action.step = 'initiated'; + this._save(); // INVALID_ARGUMENT: the server itself deems the invoice unpayable — // retrying can never succeed, revoke the HTLC instead - if (e instanceof GrpcError && e.grpcStatus === 3) { + } else if (e instanceof GrpcError && e.grpcStatus === 3) { action.error = e.message; action.step = 'revoking'; this._save(); @@ -1707,7 +1793,22 @@ export class ArkManager { this._save(); } } - if (action.step === 'revoking') await this._revokeLnPay(action); + if (action.step === 'revoking') { + // Ask first: a send can land in 'revoking' over a stale copy of the + // state while the payment went through (or is still going) — a + // settled payment is settled here too, and one in flight waits. + const res = await checkLightningPayment(this.arkUrl, hex.decode(action.paymentHash), false).catch(() => null); + if (res && res.status === 'success' && res.preimage + && hex.encode(sha256(hex.decode(res.preimage))) === action.paymentHash) { + this._settleLnPay(action, res.preimage); + return; + } + if (res && res.status === 'pending' && action.htlcExpiry) { + const tip = await this.chain.tipHeight().catch(() => 0); + if (tip && tip <= action.htlcExpiry) { action.step = 'initiated'; delete action.error; this._save(); return; } + } + await this._revokeLnPay(action); + } } _settleLnPay(action, preimageHex) { @@ -1804,9 +1905,11 @@ export class ArkManager { }); } catch (e) { // "already been paid" for a settled one; "UNIQUE constraint failed: - // invoices.payment_hash" for one merely issued before — both mean - // this index is spoken for - const used = /already been paid|already exists|already in use|duplicate|UNIQUE constraint|payment_hash/i.test(e?.message || ''); + // invoices.payment_hash" for one merely issued before; "the invoice + // for payment hash … was canceled" for one a cancel retired (a + // withdraw-link claim died on that, 2026-11-08) — all mean this + // index is spoken for + const used = /already been paid|already exists|already in use|duplicate|UNIQUE constraint|payment_hash|payment hash [0-9a-f]+ was cancel+ed/i.test(e?.message || ''); if (!used || tries >= 200) throw e; this._save(); // remember the skipped index even if the next try fails continue; @@ -2671,8 +2774,11 @@ export class ArkManager { if (!tip) return 0; const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds)); const cap = this.info.maxVtxoAmountSat || Infinity; + // a round output under dust (330 sat) is refused, so a coin that small + // can't be renewed on its own — asking only spends the wake's budget const coins = deep - .filter((v) => !covered.has(v.id) && v.expiryHeight > tip + 2 && v.amountSat <= cap) + .filter((v) => !covered.has(v.id) && v.expiryHeight > tip + 2 && v.amountSat <= cap + && v.amountSat - this.refreshFee([v], tip + 2) >= 330) .sort((a, b) => this.exitDepth(b) - this.exitDepth(a)) .slice(0, maxCoins); let n = 0; diff --git a/src/ark/store.js b/src/ark/store.js index b1caa94..24d8f55 100644 --- a/src/ark/store.js +++ b/src/ark/store.js @@ -139,6 +139,7 @@ export class ArkStore { } } this.ready = true; + this._parsed = null; // peeks now read the opened store if (migrated.length) { this.flush().then(() => { for (const k of migrated) { try { ls.removeItem(k); } catch {} } }).catch(() => {}); } @@ -153,13 +154,32 @@ export class ArkStore { const v = this.mem.get(key); return v ? clone(v) : null; } + // The stored object itself, not a copy — for readers that only look (the + // render path). get() deep-copies, and a big wallet's state is ~1 MB: on a + // Pixel 9 each copy took ~220 ms and the first render asked five to seven + // times before the manager connected, 1.4 s of every launch. Never mutate + // what this returns. Before the store opens (or on localStorage) the parse + // is kept until the key is next written. + peek(key) { + if (!this.ready || this.lsMode) { + const hit = this._parsed && this._parsed.get(key); + if (hit !== undefined) return hit; + let v = null; + try { const raw = this.ls().getItem(key); v = raw ? JSON.parse(raw) : null; } catch { v = null; } + (this._parsed ||= new Map()).set(key, v); + return v; + } + return this.mem.get(key) || null; + } set(key, obj) { + if (this._parsed) this._parsed.delete(key); if (!this.ready || this.lsMode) { try { this.ls().setItem(key, JSON.stringify(obj)); } catch {} return; } this.mem.set(key, obj); this.dirty.add(key); this._schedule(); } remove(key) { + if (this._parsed) this._parsed.delete(key); if (!this.ready || this.lsMode) { try { this.ls().removeItem(key); } catch {} return; } this.mem.delete(key); this.dirty.add(key); diff --git a/src/concord.js b/src/concord.js index 63a83b9..521bfc8 100644 --- a/src/concord.js +++ b/src/concord.js @@ -187,6 +187,11 @@ export const foldControl = (entries, { ownerHex, cid }) => { // entries: [{ rumor, author }] — group by (vsk, eid), take highest intact version let byEntity = new Map(); for (let e of entries) { + // Only the owner's editions are honored below, so only they compete for + // a head: anyone holding the control stream's key (every member of a + // public community) could otherwise sign a higher version and blank the + // owner's ban list, name or channels. + if (e.author !== ownerHex) continue; let tag = (k) => e.rumor.tags?.find((t) => t[0] === k)?.[1]; let vsk = tag("vsk"), eid = tag("eid"), ev = +tag("ev"); if (vsk === undefined || !eid || !(ev >= 1)) continue; @@ -225,6 +230,26 @@ export const foldControl = (entries, { ownerHex, cid }) => { return state; }; +// The owner's ban list as it stands: the head edition (its version, and the +// hash a successor chains to) and the keys on it. Same winner as the fold. +export const banlistHead = (entries, { ownerHex, cid }) => { + let eid = banlistEid(cid), cur = null; + for (let e of entries) { + if (e.author !== ownerHex || e.rumor.kind !== 3308) continue; + let tag = (k) => e.rumor.tags?.find((t) => t[0] === k)?.[1]; + let ev = +tag("ev"); + if (tag("vsk") !== "4" || tag("eid") !== eid || !(ev >= 1)) continue; + if (!cur || ev > cur.ev || (ev === cur.ev && e.rumor.id < cur.id)) cur = { ev, id: e.rumor.id, ep: tag("ep") || null, content: e.rumor.content }; + } + let list = []; + try { let c = cur && JSON.parse(cur.content); if (Array.isArray(c)) list = c.filter((x) => /^[0-9a-f]{64}$/.test(x)); } catch {} + return { eid, version: cur ? cur.ev : 0, hash: cur ? editionHash(eid, cur.ev, cur.ep, cur.content) : null, list }; +}; + +// The next ban-list edition: `list` replaces the one that stands. +export const makeBanlist = (head, list, nowMs) => + makeEdition({ vsk: 4, eid: head.eid, version: head.version + 1, prev: head.hash || undefined, content: JSON.stringify(list) }, nowMs); + // ---- CORD-02 §5 guestbook fold ---- // entries: [{ rumor, author }], returns Map(npub -> { state: 'join'|'leave'|'kick', t }) export const foldGuestbook = (entries, { nowMs, banned = new Set(), ownerHex } = {}) => { diff --git a/src/cordn-client.js b/src/cordn-client.js index d09488d..e0be08b 100644 --- a/src/cordn-client.js +++ b/src/cordn-client.js @@ -67,14 +67,16 @@ export function cordnClient({ scope, pubkey, identity, on, coordinator = C.DEFAU // never used unasked. if (id.signer.interactive && !interactive) return null; if (!id.signer.decryptFrom) return null; - return { raw: null, sign: (e) => id.signer.signEvent(e), decrypt: (peer, ct) => id.signer.decryptFrom(peer, ct) }; + return { raw: null, obj: id.signer, sign: (e) => id.signer.signEvent(e), decrypt: (peer, ct) => id.signer.decryptFrom(peer, ct) }; } // The transport, once a signer can back it. Rebuilt if the signer changes. async function transport(opts) { const s = await signer(opts); if (!s) return null; - const key = s.raw ? Array.from(s.raw.slice(0, 8)).join(',') : 'adapter'; + // a remote signer that reconnected is a new object: the old one is + // closed ("not open anymore"), so the transport must follow it + const key = s.raw ? Array.from(s.raw.slice(0, 8)).join(',') : s.obj; if (cvm && cvmSigner === key) return cvm; if (cvm) cvm.stop(); cvmSigner = key; diff --git a/src/features/ark.js b/src/features/ark.js index 739dd5e..1b8c591 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -8,7 +8,7 @@ import * as btc from '@scure/btc-signer'; import { HDKey } from '@scure/bip32'; import { hex, base32nopad, bech32 } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; -import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js'; +import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, sameDelivery, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js'; export { remoteRenewalsInFlight }; // Coins this device may offer: spendable, and not held by a renewal another @@ -77,11 +77,15 @@ if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushAll(); }); } +// A read-only look at the stored state (stub wallets in tests have only loadArkState). +const peekArk = (wallet, url) => (wallet.peekArkState ? wallet.peekArkState(url) : wallet.loadArkState(url)); + // Wallet storage/key helpers for this feature, installed onto the core // wallet instance so a build without the feature ships none of it. export function installArkWallet(wallet) { if (wallet.loadArkState) return; // already installed const store = arkStore(); // IndexedDB-backed, memory-fronted; opens now + const peekOwnerOk = new WeakSet(); // stored states whose owner check passed Object.assign(wallet, { _arkStore: store, // ---- Ark support ------------------------------------------------------- @@ -133,6 +137,21 @@ export function installArkWallet(wallet) { return st; } catch { return null; } }, + // loadArkState for readers that only look (see ArkStore.peek): the stored + // object, no copy — never mutate it. Misfiled state goes through + // loadArkState's repair; each object's owner is decoded once. + peekArkState(url) { + try { + const st = store.peek(this._arkKey(url)); + if (!st) return null; + if (!peekOwnerOk.has(st)) { + const owner = this._arkVtxoOwner(st); + if (owner && st.serverPubkey && owner !== st.serverPubkey) return this.loadArkState(url); + peekOwnerOk.add(st); + } + return st; + } catch { return null; } + }, // Offer up pre-namespacing state, but only to the ASP that actually // cosigned it. Its owner is read from the state's own stamp, or failing // that decoded straight out of a vtxo — the vtxo wire format carries the @@ -491,6 +510,23 @@ export function arkFeature(ctx) { localStorage.setItem(ARK_INFO_KEY + url, JSON.stringify(tip ? { info, tip, at: Date.now() } : { info, tip: (prev && prev.tip) || 0, at: (prev && prev.at) || Date.now() })); } catch {} } + // Whether Savings can fund a move into Spending at all: the server's board + // minimum (1,000 sats on coinos) plus the board transaction's chain fee. + // A Savings balance under that — 546 sats, say — must not be offered a + // move the board panel then refuses with "Needs at least 1,000 sats"; the + // way into Spending for it is a Lightning receive, which lands there + // directly. The minimum comes from the live manager or the last copy of + // the server's info seen, so the answer is right before connect too. + function savingsBoardMin() { + const info = (ark && ark.info) || ((cachedArkInfo((getArkConfig() || {}).ark) || {}).info) || null; + return info ? (info.minBoardAmountSat || 0) : 1000; + } + function savingsCanBoard() { + if (!arkAvailable()) return false; + const min = savingsBoardMin(); + if (min >= 2_100_000_000_000_000 || wallet.spendable < min) return false; // paused, or short + try { return previewBoardFunding(wallet, ark, min).fundingSat >= min; } catch { return false; } // the fee on top + } // the tip, live or estimated from the last one seen (a block per ten minutes) function arkTipNow(mgr) { if (mgr && mgr._tipH) return mgr._tipH; @@ -537,7 +573,7 @@ export function arkFeature(ctx) { function arkWanted() { const cfg = getArkConfig(); - const st = cfg && wallet.loadArkState(cfg.ark); + const st = cfg && peekArk(wallet, cfg.ark); return !!(st && ((st.vtxos || []).length || (st.movements || []).length || (st.actions || []).some((a) => !['done', 'failed'].includes(a.step)))); } @@ -646,6 +682,7 @@ export function arkFeature(ctx) { arkPersistFlushers.add(flushPersist); const mgr = new ArkManager({ account: wallet.account(), + lnOldestFirst: () => getCustody() !== 'custodial', storage: { load: () => wallet.loadArkState(cfg.ark), adopt: (serverPubkey) => wallet.adoptArkState(serverPubkey, cfg.ark), @@ -779,7 +816,7 @@ export function arkFeature(ctx) { // (send/exit) still requires the seed and is gated separately. const cfg = getArkConfig(); if (!cfg) return null; - return wallet.loadArkState(cfg.ark); + return peekArk(wallet, cfg.ark); } // Which receives actually arrived over Lightning? A payment to the user's @@ -929,7 +966,7 @@ export function arkFeature(ctx) { h('button', { class: 'btn-ghost', onClick: () => { ui.arkSend = null; ui.sendError = ''; render(); } }, t('back')), ui.busy ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) - : a.amountSat > (arkBalance()?.spendableSat || 0) && wallet.spendable > 0 && !wallet.watchOnly + : a.amountSat > (arkBalance()?.spendableSat || 0) && savingsCanBoard() ? h('button', { class: 'btn-primary grow', onClick: () => { const need = a.amountSat; ui.arkSend = null; ui.sendError = ''; @@ -995,10 +1032,49 @@ export function arkFeature(ctx) { // connections/offer belong to the nwc feature; when it isn't the caller // (an auto-withdraw setting changed, say) carry forward what's mirrored // rather than blanking someone's wallet connections. - async function writeBgMirror(connections, offer, prefs) { + // The mirror carries hundreds of pre-derived keys (BG_KEY_LOOKAHEAD) and + // is rewritten after every state change: derive each index once per + // account, not once per write. + const bgKeyMemo = new WeakMap(); // account node -> Map('chain:i' -> hex) + function bgKeyFor(account) { + let m = bgKeyMemo.get(account); + if (!m) { m = new Map(); bgKeyMemo.set(account, m); } + const chains = new Map(); + return (chain, i) => { + const k = chain + ':' + i; + let v = m.get(k); + if (!v) { + let node = chains.get(chain); + if (!node) { node = account.deriveChild(chain); chains.set(chain, node); } + v = hex.encode(node.deriveChild(i).privateKey); + m.set(k, v); + } + return v; + }; + } + // The worker can now pay while this app is alive behind another one (a + // phone with Coinos in the background: the page is frozen or throttled, + // the worker is not). Its change coin and its spent coins then exist only + // in the mirror — so the mirror is never written over the worker's hand: + // one writer at a time (the lock the worker holds for a whole push), and + // whatever the worker did since we last looked is folded in first. + let bgAbsorbedAt = 0; // the worker stamp (rec.workerAt) this app has folded in + const withBgLock = (fn) => (typeof navigator !== 'undefined' && navigator.locks + ? navigator.locks.request('nwc-bg-work', fn) : fn()); + const bgBusy = (mgr) => !!(mgr && mgr._lnDriving && mgr._lnDriving.size); + async function writeBgMirror(connections, offer, prefs) { return withBgLock(() => writeBgMirrorLocked(connections, offer, prefs)); } + async function writeBgMirrorLocked(connections, offer, prefs) { const cfg = getArkConfig(); if (!cfg || !ark || !ark.info || !ark.state) return; - const prev = await loadBg(wallet._cacheKey()); + let prev = await loadBg(wallet._cacheKey()); + if (prev && prev.v >= 3 && prev.mgr && (prev.workerAt || 0) > bgAbsorbedAt) { + // a payment of ours in flight holds the very records a merge replaces: + // leave the worker's state standing and write at the next call + if (bgBusy(ark)) return; + await mergeBgWorkerState(ark).catch((e) => console.warn('ark: worker merge failed', e && e.message)); + if ((prev.workerAt || 0) > bgAbsorbedAt) return; // not folded in: don't write over it + prev = await loadBg(wallet._cacheKey()); + } if (connections === undefined) connections = (prev && prev.connections) || []; if (offer === undefined) offer = (prev && prev.offer) || null; if (prefs === undefined) prefs = (prev && prev.prefs) || undefined; @@ -1006,12 +1082,12 @@ export function arkFeature(ctx) { ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey, honorsExpired: !!cfg.honorsExpired }, mgrState: ark.state, custody: getCustody(), - keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey), + keyFor: bgKeyFor(ark.account), connections, offer, autowithdraw: await awMirror(prev), }); - if (prev) rec.spends = prev.spends; // the worker's log survives rewrites + if (prev) { rec.spends = prev.spends; rec.pendingPays = prev.pendingPays; if (prev.workerAt) rec.workerAt = prev.workerAt; } // the worker's log survives rewrites if (prefs) rec.prefs = prefs; // notification choices the worker honours await saveBg(wallet._cacheKey(), rec); await disarmSiblingRecords(wallet._cacheKey(), rec).catch(() => {}); @@ -1031,6 +1107,7 @@ export function arkFeature(ctx) { } if (!rec.mgr) return; bootPhase('ark-merge'); + const stamp = rec.workerAt || 0; const merged = mergeArkStates(mgr.state, rec.mgr); // the worker's view of OUR vtxos is newer for anything it spent const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state])); @@ -1040,6 +1117,7 @@ export function arkFeature(ctx) { merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); mgr.state = merged; mgr._save(); + if (mgr === ark) bgAbsorbedAt = Math.max(bgAbsorbedAt, stamp); bootPhase('ark-merged'); await mgr.reconcile().catch(() => {}); bootPhase('ark-reconciled'); @@ -1499,9 +1577,12 @@ export function arkFeature(ctx) { h('span', { class: g.claimed ? 'tag conf' : 'tag' }, g.claimed ? t('giftClaimedTag') : g.revoked ? t('giftRevokedTag') : t('giftUnclaimedTag'))); })(), - (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })(), - m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null), - h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), + (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })()), + // the status tag sits by the time, not the name: in the narrow wallet + // column the title row has room for the label and the chip, not a + // third thing — the chip was breaking mid-name to make space + h('div', { class: 'row gap6 small faint' }, timeAgo(m.ts / 1000), + m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null)), h('div', { style: 'text-align:right' }, // in fiat this is what the payment was worth when it was made h('div', { class: failed ? 'muted' : incoming ? 'amount-pos' : 'amount-neg' }, fmtAmount(m.amountSat, m.ts / 1000))) @@ -1670,6 +1751,7 @@ export function arkFeature(ctx) { })(), m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null, m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null, + m.type === 'receive' ? coinListRows(t('arkVtxoId'), m.vtxoIds) : null, // the coins this payment consumed — a send can gather up to 24 inputs coinListRows(t('arkVtxoInputs'), m.inputIds), // An offboard's fee is two different pockets — the miners' cut for the @@ -1718,7 +1800,7 @@ export function arkFeature(ctx) { const g = giftForMovement(m); const open = g && !g.claimed && !g.revoked; const chips = [ - open ? copyBtn(`${location.origin}/g/${encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex))}`, t('giftCopyLinkAgain')) : null, + open ? copyBtn(`${location.origin}/g/${encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex))}${g.note ? '#m=' + encodeURIComponent(g.note) : ''}`, t('giftCopyLinkAgain')) : null, m.preimage ? copyBtn(m.preimage, t('copyPreimage')) : null, m.txid ? copyBtn(m.txid, t('copyTxid')) : null, m.txid ? h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool')) : null, @@ -1928,11 +2010,16 @@ export function arkFeature(ctx) { } // The ask itself: switch and continue, move Savings over first, or not. + // When Savings can't fund a move either (under the board minimum, as a + // 546-sat Savings against coinos's 1,000 is), the door offered is the one + // that works: a Lightning receive, which lands in Spending directly. function arkLnFromSavingsView() { const p = ui.arkLnFromSavings; const have = arkBalance()?.spendableSat || 0; const need = p.amountSat || 0; const enough = !need || have >= need; + const canMove = savingsCanBoard(); + const close = () => { ui.arkLnFromSavings = null; ui.sendError = ''; ui.send = blankSend(); }; const proceed = () => { ui.arkLnFromSavings = null; ctx.setAccount('spending', 'right'); @@ -1940,20 +2027,27 @@ export function arkFeature(ctx) { if (startArkLnPay(p.invoice, p.meta)) return; ui.arkLnFromSavingsOk = false; // Spending can't carry it: the same offer the Spending side makes - if (need && wallet.spendable > 1000 && ctx.hook('arkOfferBoard', need)) return; + if (need && canMove && ctx.hook('arkOfferBoard', need)) return; ui.sendError = t('arkLnExceedsSpending', { need: fmtAmount(need) + ' ' + unitLabel(), have: fmtAmount(have) + ' ' + unitLabel() }); render(); }; + const moveFirst = () => { ui.arkLnFromSavings = null; ctx.hook('arkOfferBoard', need || 0); }; + const receiveInstead = () => { close(); ctx.setAccount('spending', 'right'); render(); }; + const sats = (n) => fmtAmount(n) + ' ' + unitLabel(); return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', { style: 'margin:0' }, t('arkLnFromSavingsTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, t('arkLnFromSavingsBody', { have: fmtAmount(have) + ' ' + unitLabel() })), - need ? h('div', { class: 'row between' }, h('span', { class: 'small muted' }, t('lnPayAmount')), h('span', { class: 'small' }, fmtAmount(need) + ' ' + unitLabel())) : null, - !enough ? h('div', { class: 'notice info small' }, t('arkLnFromSavingsShort')) : null, - h('button', { class: 'btn-primary btn-block', onClick: proceed }, enough ? t('arkLnUseSpending') : t('arkLnMoveFirst')), - enough && wallet.spendable > 1000 - ? h('button', { class: 'btn-block', onClick: () => { ui.arkLnFromSavings = null; ctx.hook('arkOfferBoard', need || 0); } }, t('arkLnMoveFirst')) + h('p', { class: 'small muted', style: 'margin:0' }, t('arkLnFromSavingsBody', { have: sats(have) })), + need ? h('div', { class: 'row between' }, h('span', { class: 'small muted' }, t('lnPayAmount')), h('span', { class: 'small' }, sats(need))) : null, + enough ? null + : canMove ? h('div', { class: 'notice info small' }, t('arkLnFromSavingsShort')) + : h('div', { class: 'notice info small' }, t('arkLnSavingsTooSmall', { have: sats(wallet.spendable || 0), min: sats(savingsBoardMin()) })), + enough ? h('button', { class: 'btn-primary btn-block', onClick: proceed }, t('arkLnUseSpending')) + : canMove ? h('button', { class: 'btn-primary btn-block', onClick: moveFirst }, t('arkLnMoveFirst')) + : h('button', { class: 'btn-primary btn-block', onClick: receiveInstead }, t('arkLnReceiveInstead')), + enough && canMove + ? h('button', { class: 'btn-block', onClick: moveFirst }, t('arkLnMoveFirst')) : null, - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkLnFromSavings = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('cancel'))); + h('button', { class: 'btn-ghost btn-block', onClick: () => { close(); render(); } }, t('cancel'))); } // shared by the ark and lightning zap flows (see zaps.js for the twin) @@ -3167,7 +3261,7 @@ export function arkFeature(ctx) { // Fund a fresh bearer gift identity. Shared by the gifts feature's hook and // the zap flow's locked-gift fallback; `lockedTo` records who a locked gift // was for (the sender can still revoke either kind while unclaimed). - async function createArkGift(amountSat, lockedTo = null) { + async function createArkGift(amountSat, lockedTo = null, note = null) { const mgr = await connectArk(); const secret = crypto.getRandomValues(new Uint8Array(32)); const secretHex = hex.encode(secret); @@ -3177,7 +3271,7 @@ export function arkFeature(ctx) { const action = mgr.state.actions.find((a) => a.id === actionId); if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed')); const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id; - arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}) }); + arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}), ...(note ? { note } : {}) }); mgr._save(); return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat, address }; } @@ -3591,8 +3685,8 @@ export function arkFeature(ctx) { date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(), }); navigator.serviceWorker?.ready - .then((reg) => reg.showNotification('coinos', { body })) - .catch(() => { try { new Notification('coinos', { body }); } catch {} }); + .then((reg) => reg.showNotification(t('arkRenewWarnTitle'), { body })) + .catch(() => { try { new Notification(t('arkRenewWarnTitle'), { body }); } catch {} }); } catch {} } @@ -3641,7 +3735,7 @@ export function arkFeature(ctx) { const boardsPaused = () => ((ark && ark.info && ark.info.minBoardAmountSat) || 0) >= 2_100_000_000_000_000; function boardForm() { if (boardsPaused()) return h('div', { class: 'notice small' }, t('arkBoardPaused')); - const minBoard = (ark && ark.info && ark.info.minBoardAmountSat) || 0; + const minBoard = savingsBoardMin(); const canBoard = wallet.spendable >= minBoard; return h('div', { class: 'col', style: 'width:100%;gap:8px' }, h('div', { class: 'input-group' }, @@ -4010,6 +4104,7 @@ export function arkFeature(ctx) { return mgr.fetchBolt12(offerStr, amountSat); }, lnSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, + arkSavingsCanBoard() { return savingsCanBoard(); }, spendingRenewingSat() { return renewingSat(); }, // Max for any Lightning amount form: spendable minus estimated fees. lnMaxSendSat() { return lnMaxSat(); }, @@ -4033,7 +4128,7 @@ export function arkFeature(ctx) { const short = Math.max(0, Math.ceil(needSat * 1.01) + 2 - have); let gross = short; try { for (let i = 0; i < 4; i++) gross = short + boardFee(gross, ark.info.boardFees); } catch {} - const minBoard = (ark && ark.info && ark.info.minBoardAmountSat) || 0; + const minBoard = savingsBoardMin(); // remembered info too: the offer can come before connect ctx.goHome(); ui.arkMoveOpen = true; ui.arkMoveDir = 'toSpending'; @@ -4054,6 +4149,8 @@ export function arkFeature(ctx) { return mgr.lnOutcome(paymentHash, timeoutMs); }, arkSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, + // everything Spending holds, settled or not + arkHeldSat() { const b = arkBalance(); return b ? b.spendableSat + b.pendingSat + b.boardingSat : 0; }, arkReady() { return arkAvailable(); }, // A Lightning invoice for another feature to watch (point of sale): // the action id is the handle, the poller tells it when it settles. @@ -4092,6 +4189,18 @@ export function arkFeature(ctx) { // nwc.js calls this (with its connections) whenever the mirror should // refresh: on enable, on connection changes, after payments, on a timer. async arkBgWrite(connections, offer, prefs) { return writeBgMirror(connections, offer, prefs); }, + // Back on screen: what the worker paid while this app sat behind another + // one is folded in now, not at the next start. true when there was any. + async arkBgAbsorb() { + if (!ark || !ark.state) return false; + return withBgLock(async () => { + const rec = await loadBg(wallet._cacheKey()); + if (!rec || rec.v < 3 || !rec.mgr || (rec.workerAt || 0) <= bgAbsorbedAt || bgBusy(ark)) return false; + await mergeBgWorkerState(ark); + render(); + return true; + }); + }, async arkBgSpendableSat() { const rec = await loadBg(wallet._cacheKey()); return ((rec && rec.mgr && rec.mgr.vtxos) || []) @@ -4184,11 +4293,13 @@ export function arkFeature(ctx) { // paste. Own it: offer to board the shortfall from Savings when // there is one, else say plainly what's short. const need = (maybeLnInvoice(inv) || {}).amountSat || 0; - if (need && wallet.spendable > 1000 && ctx.hook('arkOfferBoard', need)) return true; + if (need && savingsCanBoard() && ctx.hook('arkOfferBoard', need)) return true; ui.sendError = t('arkLnExceedsSpending', { need: fmtAmount(need) + ' ' + unitLabel(), have: fmtAmount(arkBalance()?.spendableSat || 0) + ' ' + unitLabel(), - }); + }) + (wallet.spendable > 0 && !savingsCanBoard() // Savings has sats, just too few to move + ? ' ' + t('arkLnSavingsTooSmall', { have: fmtAmount(wallet.spendable) + ' ' + unitLabel(), min: fmtAmount(savingsBoardMin()) + ' ' + unitLabel() }) + : ''); render(); return true; } @@ -4289,7 +4400,22 @@ export function arkFeature(ctx) { for (const m of s.movements || []) for (const id of m.inputIds || []) accounted.add(id); const out = []; const seenCoin = new Set(); + // One payment delivered as several coins (a gift held in two, a + // dust-padded split) is one row: receives from the same mailbox + // message (`batch`; older rows: the same delivery instant) fold. + const folded = new Map(); for (const m of moves) { + if (m.type === 'receive' && sameDelivery(out[out.length - 1], m)) { + const last = out[out.length - 1]; + if (!folded.has(last.id)) { + const f = { ...last, vtxoIds: [last.vtxoId], vtxoId: null }; + out[out.length - 1] = f; folded.set(f.id, f); + } + const f = folded.get(last.id); + f.amountSat += m.amountSat; + f.vtxoIds.push(m.vtxoId); + continue; + } if (m.type !== 'reconcile') { out.push(m); continue; } if (m.vtxoId && (seenCoin.has(m.vtxoId) || accounted.has(m.vtxoId))) continue; if (m.vtxoId) seenCoin.add(m.vtxoId); @@ -4302,6 +4428,7 @@ export function arkFeature(ctx) { out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] }); } } + this._histFolded = folded; // a folded row's detail shows the whole payment return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }))); }, historyDetail() { @@ -4321,7 +4448,8 @@ export function arkFeature(ctx) { if (done) return arkMoveDetailView(done); if (a && !['done', 'failed'].includes(a.step)) return arkMoveDetailView(lnPendingMove(a)); } - const m = s ? (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null; + if (s) this.historyEntries(); // (memoized) keeps the folded rows current + const m = s ? (this._histFolded && this._histFolded.get(ui.arkMoveDetail)) || (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null; if (m) return arkMoveDetailView(m); ui.arkMoveDetail = null; return null; @@ -4472,7 +4600,7 @@ export function arkFeature(ctx) { setNetwork(g.net); return true; }, - arkGiftCreate(amountSat) { return createArkGift(amountSat); }, + arkGiftCreate(amountSat, note) { return createArkGift(amountSat, null, note); }, // Claim-side status: claimable (with the live amount), taken, wrongnet, or // unknown (not visible here — wrong server or not yet delivered). async arkGiftStatus(code) { diff --git a/src/features/gifts-wallet.js b/src/features/gifts-wallet.js index 824ddc0..9532125 100644 --- a/src/features/gifts-wallet.js +++ b/src/features/gifts-wallet.js @@ -274,9 +274,9 @@ export function installGiftWallet(wallet) { // gift's link/QR can be re-viewed later and a claimed one identified in history. _giftRecordsKey() { return this._cacheKey() + ':giftrec'; }, - recordGift({ code, locked, amount, claimCode, outpoints }) { + recordGift({ code, locked, amount, claimCode, outpoints, note, from }) { if (!outpoints || !outpoints.length) return; - this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false }; + this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false, ...(note ? { note } : {}), ...(from ? { from } : {}) }; this._saveGiftRecords(); try { this.saveCache(); } catch {} // publish the new gift to cross-device sync }, diff --git a/src/features/gifts.js b/src/features/gifts.js index 56fd99e..c6ccbd9 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -8,20 +8,26 @@ import { decryptWithCode, npubOf } from '../nostr.js'; import { installGiftWallet, previewGift, giftOutpoints, giftClaimTxs, isRetiredGift, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; +import { withdrawLinks } from './withdraw-links.js'; import { fmtBtc, timeAgo, shortAddr } from '../format.js'; +import { getEventHash, verifyEvent } from 'nostr-tools/pure'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { base32nopad } from '@scure/base'; export function giftsFeature(ctx) { const { h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal, fmtAmount, unitLabel, unitTag, parseAmount, getUnit, toggleUnit, download, brandHeader, activeAccount, setAccounts, getAccounts, claimTargets, - enterWallet, activateAccount, commitAccount, + enterWallet, activateAccount, commitAccount, pickConfirm, } = ctx; installGiftWallet(wallet); // gift primitives live outside the core wallet // Ark-gift support comes from the ark feature (when this build ships it); // every call degrades to null in an on-chain-only build. const hook = ctx.hook || (() => null); const arkGiftOf = (code) => hook('arkGiftDecode', code); + const wl = withdrawLinks(ctx); // prepaid LNURL-withdraw links, the gift link's sibling on Send // ================================================================ GIFT / CLAIM // Read a gift link, returning the code (and scrubbing it from the URL so the @@ -32,6 +38,8 @@ export function giftsFeature(ctx) { try { const am = location.pathname.match(/^\/ag\/([A-Za-z0-9]+)\/?$/i); // ark gift if (am) { + ui.claimNote = readNoteFrag(); + ui.claimFrom = readFromFrag(am[1]); history.replaceState(null, '', '/'); return arkGiftOf(am[1]) ? am[1] : null; } @@ -43,6 +51,7 @@ export function giftsFeature(ctx) { if (hm) code = hm[1]; } if (!code) return null; + if (pm) { ui.claimNote = readNoteFrag(); ui.claimFrom = readFromFrag(code); } history.replaceState(null, '', '/'); return previewGift(code) ? code : null; } catch { @@ -57,6 +66,8 @@ export function giftsFeature(ctx) { try { const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/); if (!m) return null; + ui.claimNote = null; + ui.claimFrom = null; history.replaceState(null, '', '/'); return previewLockedGift(m[1]); } catch { return null; } @@ -120,6 +131,77 @@ export function giftsFeature(ctx) { if (!acceptLockedPayload(payload)) { ui.claimError = t('claimCodeWrong'); render(); } } + // A gift's note rides its link after the # (#m=<text>): the fragment never + // leaves the browser, so no server sees it, and it is only as private as the + // link itself (which already IS the money). + const GIFT_NOTE_MAX = 280; + // Who it's from rides there too (f=…): the sender's nostr key, signed over + // the gift's code, so a link can't be passed on as someone else's gift. + // 100 bytes in base32: pubkey, created_at, schnorr sig. + const giftFrag = (note, from) => { + const p = []; + if (note) p.push('m=' + encodeURIComponent(note)); + if (from) p.push('f=' + from); + return p.length ? '#' + p.join('&') : ''; + }; + function readNoteFrag() { + try { + const m = new URLSearchParams(location.hash.slice(1)).get('m'); + return m ? m.trim().slice(0, GIFT_NOTE_MAX) || null : null; + } catch { return null; } + } + const GIFT_FROM_KIND = 27121; // ephemeral range: never meant for a relay + const giftFromTmpl = (code, created_at) => ({ + kind: GIFT_FROM_KIND, created_at, content: '', + tags: [['x', bytesToHex(sha256(new TextEncoder().encode(String(code).toUpperCase())))]], + }); + // the sender's pubkey, if the link's f= is their signature over this code + function readFromFrag(code) { + try { + const f = [...new URLSearchParams(location.hash.slice(1))].find(([k]) => k.toLowerCase() === 'f')?.[1]; + const b = f && base32nopad.decode(f.toUpperCase()); + if (!b || b.length !== 100) return null; + const ev = { ...giftFromTmpl(code, new DataView(b.buffer, b.byteOffset).getUint32(32)), + pubkey: bytesToHex(b.slice(0, 32)), sig: bytesToHex(b.slice(36)) }; + ev.id = getEventHash(ev); + return verifyEvent(ev) ? ev.pubkey : null; + } catch { return null; } + } + // Sign a new gift as ours (the messages feature holds the identity); null + // when there's no identity, the signer refuses, or it takes too long. + async function signGiftFrom(code) { + try { + const ev = await Promise.race([ + hook('signGiftFrom', giftFromTmpl(code, Math.floor(Date.now() / 1000))), + new Promise((r) => setTimeout(() => r(null), 20_000)), + ]); + if (!ev || !ev.sig) return null; + const b = new Uint8Array(100); + b.set(hexToBytes(ev.pubkey), 0); + new DataView(b.buffer).setUint32(32, ev.created_at); + b.set(hexToBytes(ev.sig), 36); + return base32nopad.encode(b); + } catch { return null; } + } + const GIFT_ANON_KEY = 'giftAnon'; + const giftAnon = () => { try { return localStorage.getItem(GIFT_ANON_KEY) === '1'; } catch { return false; } }; + // who sent it, and that claiming follows them + const giftFromView = () => (ui.claimFrom + ? h('div', { class: 'col', style: 'align-items:center;gap:4px' }, + h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center' }, + h('span', { class: 'small muted' }, t('giftFrom')), + hook('profileChip', ui.claimFrom, undefined, () => {}) + || h('span', { class: 'small mono' }, (npubOf(ui.claimFrom) || '').slice(0, 16) + '…')), + h('div', { class: 'small faint' }, t('giftClaimFollows'))) + : null); + // the sender's words, quoted above the amount wherever the gift is opened + const giftNoteView = () => (ui.claimNote + ? h('blockquote', { class: 'gift-note' }, ui.claimNote) + : null); + // with a note or a sender the link has a # in it: a byte-mode QR (the + // bare link is upper-cased into the denser alphanumeric mode) + const giftQr = (url, hasFrag) => qrSvg(hasFrag ? url : url.toUpperCase(), { ec: 'L', mode: hasFrag ? undefined : 'Alphanumeric' }); + // Entry point for claiming a gift code. If the recipient already has wallet(s), // offer to claim into one or make a new one; otherwise go straight to a fresh // wallet (the original first-timer flow). @@ -171,6 +253,8 @@ export function giftsFeature(ctx) { h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'), h('h2', { style: 'margin:0' }, t('giftWelcome')), + giftFromView(), + giftNoteView(), pv ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(pv.room)), ' ', unitTag('unit')) : null, h('div', { class: 'small faint' }, claimFeeNote(code)), h('p', { class: 'muted', style: 'margin:0' }, t('claimIntoPrompt')) @@ -206,53 +290,47 @@ export function giftsFeature(ctx) { h('h2', { style: 'margin:0' }, claimed ? t('claimedTitle') : t('newWalletTitle')), h('p', { class: 'muted', style: 'margin:0' }, claimed ? t('claimedBody') : t('newWalletBody')) ), - h('div', { class: 'card col' }, - h('h3', {}, t('recoveryPhrase')), - h('div', { class: 'warn-box' }, t('writeDownWarn')), - h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, - words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w))) - ), - h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('verifyBackup')), - h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); render(); } }, t('skipVerification')) - ) + // a heads-up before the words, then the words, then one word to + // check (Adam, 2026-11-07) — the same three steps as a new wallet + !ui.claimShown + ? h('div', { class: 'card col' }, + h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')), + h('p', { style: 'margin:0' }, t('seedIntroBody', { n: words.length })), + h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')), + h('button', { id: 'claim-seed-show', class: 'btn-primary btn-block', onClick: () => { ui.claimShown = true; render(); } }, t('seedIntroShow'))) + : h('div', { class: 'card col' }, + h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')), + h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, + words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w))) + ), + h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))), + h('button', { id: 'claim-seed-next', class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('next'))) ); } if (ui.claimStep === 'verify') { - // Same word-confirmation as new-wallet creation: prove the recipient wrote - // the phrase down before sending them off into their freshly funded wallet. const words = wallet.mnemonic.split(' '); + const q = ui.confirm; + if (!q || !q.options) { ui.claimStep = 'backup'; ui.claimShown = true; return claimScreen(); } return h( 'div', { class: 'col', style: 'gap:16px' }, brandHeader(false), h('div', { class: 'card col' }, h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')), - h('p', { class: 'muted', style: 'margin:0' }, t('confirmBackupIntro')), - ...ui.confirm.map((c, i) => - h('label', { class: 'field' }, - h('span', { class: 'lab' }, t('wordN', { n: c.index + 1 })), - h('input', { - type: 'text', class: 'mono-input', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', - value: c.value, onInput: (e) => (ui.confirm[i].value = e.target.value.trim()), - }) - ) - ), - ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - h('div', { class: 'row gap6' }, - h('button', { class: 'btn-ghost', onClick: () => { ui.claimStep = 'backup'; ui.claimError = ''; render(); } }, t('back')), + h('p', { style: 'margin:0' }, t('quizPrompt', { n: q.index + 1 })), + h('div', { class: 'quiz-opts' }, q.options.map((w) => h('button', { - class: 'btn-primary grow', + type: 'button', + class: 'quiz-opt' + (q.wrong.includes(w) ? ' wrong' : ''), onClick: () => { - const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]); - if (!ok) { ui.claimError = t('wordsMismatch'); render(); return; } - ui.screen = 'wallet'; ui.claimStep = null; ui.claimError = ''; - landAccount(); - render(); + if (w === words[q.index]) { ui.claimError = ''; finishClaim(); return; } + if (!q.wrong.includes(w)) q.wrong.push(w); + ui.claimError = t('quizWrong'); render(); }, - }, t('openWallet')) - ), - h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); render(); } }, t('skipVerification')) + }, w))), + ui.claimError && h('div', { class: 'notice err' }, ui.claimError), + h('button', { id: 'claim-seed-skip', class: 'btn-ghost btn-block', onClick: () => { ui.claimError = ''; finishClaim(); } }, t('skip')), + h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.claimStep = 'backup'; ui.claimShown = true; ui.claimError = ''; render(); } }, t('quizBack')), ) ); } @@ -286,7 +364,7 @@ export function giftsFeature(ctx) { // view the claim on a block explorer, or head back to the home screen. h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimTaken = null; ui.claimedAmount = 0; - if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; commitAccount(); } + if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; ui.claimShown = false; commitAccount(); } else { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); } render(); } }, activeAccount() && activeAccount().provisional ? t('createWalletAnyway') : t('goToWallet')), @@ -313,6 +391,8 @@ export function giftsFeature(ctx) { h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'), h('h2', { style: 'margin:0' }, t('giftWelcome')), + giftFromView(), + giftNoteView(), h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(total)), ' ', unitTag('unit') ), @@ -324,10 +404,12 @@ export function giftsFeature(ctx) { !ui.claimError && ui.claimNotVisible && h('div', { class: 'notice info' }, t('claimNotVisibleBody')), ui.busy || ui.claimChecking ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) + // an ark gift costs nothing to claim: just the button (an on-chain + // one says what its network fee will take) : h('button', { class: 'btn-primary btn-block', onClick: doClaim }, - t('claimBtn'), ' ', - h('span', { style: 'font-size:0.85em;opacity:0.9' }, - '(' + (ag ? t('claimArkFree') : t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() })) + ')')) + ...(ag ? [t('claimNowBtn')] : [t('claimBtn'), ' ', + h('span', { style: 'font-size:0.85em;opacity:0.9' }, + '(' + t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() }) + ')')])) ); } @@ -388,12 +470,23 @@ export function giftsFeature(ctx) { if (ctx.setAccount) ctx.setAccount('spending'); } + // The seed is written down: into the new wallet — by way of the + // wizard's username, avatar and cover, so a gift's recipient arrives as + // someone people can pay by name (skippable, like any first run). + function finishClaim() { + ui.screen = 'wallet'; ui.claimStep = null; ui.claimError = ''; + landAccount(); + if (ctx.offerProfileSetup) ctx.offerProfileSetup({ home: ui.claimVia === 'ark' ? 'spending' : 'savings' }); + render(); + } + function afterClaim(amount) { ui.claimedAmount = amount; const wasProvisional = !!(activeAccount() && activeAccount().provisional); commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one) + if (ui.claimFrom) hook('followGiftSender', ui.claimFrom); if (wasProvisional) { - ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up + ui.claimStep = 'backup'; ui.claimShown = false; // fresh wallet — show the new seed to back up } else { ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up ui.claimStep = null; @@ -526,13 +619,14 @@ export function giftsFeature(ctx) { // the sender can re-share an unclaimed gift. function viewGiftView() { const g = ui.viewGift; // { code, locked, amount, claimCode } - const url = `${location.origin}/g/${g.code}`; + const url = `${location.origin}/g/${g.code}${giftFrag(g.note, g.from)}`; let svg = null; - try { svg = qrSvg(url.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} + try { svg = giftQr(url, !!(g.note || g.from)); } catch {} return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, h('div', { class: 'card col', style: 'gap:12px;align-items:center' }, h('h3', { style: 'margin:0' }, t('giftView')), g.amount != null ? h('div', { class: 'amt' }, fmtAmount(g.amount), ' ', unitTag()) : null, + g.note ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, g.note) : null, svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')), h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, url), copyBtn(url, t('copyLink')), @@ -567,9 +661,9 @@ export function giftsFeature(ctx) { } ui.busy = true; ui.giftError = ''; render(); try { - const g = await hook('arkGiftCreate', amt); + const g = await hook('arkGiftCreate', amt, giftNoteNow()); ui.giftIsArk = true; - finishGift(g, amt); + await finishGift(g, amt); } catch (e) { ui.giftError = e.message; } @@ -611,12 +705,21 @@ export function giftsFeature(ctx) { doCreateGift(amt, rate); } - // Finalize a freshly-built gift: a plain bearer link. - function finishGift(g, fallbackAmt) { + // Finalize a freshly-built gift: a plain bearer link, signed as ours + // unless the sender would rather stay anonymous. Recorded before the + // signer is asked, so a signer that never answers can't lose the gift. + async function finishGift(g, fallbackAmt) { const amount = g.amount || fallbackAmt || 0; + const note = giftNoteNow(); // the link says what was typed when it was made + const rec = { code: g.code, locked: false, amount, outpoints: g.reserved, note }; + wallet.recordGift(rec); + const from = giftAnon() ? null : await signGiftFrom(g.code); + if (from) wallet.recordGift({ ...rec, from }); ui.giftCode = g.code; - wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved }); + ui.giftNoteMade = note; + ui.giftFromMade = from; } + const giftNoteNow = () => String(ui.giftNote || '').trim().slice(0, GIFT_NOTE_MAX) || null; function giftCard() { const active = wallet.outstandingGifts(); @@ -631,13 +734,14 @@ export function giftsFeature(ctx) { // (many-input) gift can exceed QR capacity — then fall back to the link. (() => { let svg = null; - try { svg = qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} + try { svg = giftQr(giftUrl(), !!(ui.giftNoteMade || ui.giftFromMade)); } catch {} return svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')); })(), + ui.giftNoteMade ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, ui.giftNoteMade) : null, h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()), h('div', { class: 'row gap6 wrap' }, copyBtn(giftUrl(), t('copyLink')), - h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; ui.giftIsArk = false; render(); } }, t('giftAnother')) + h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftNote = ''; ui.giftNoteMade = null; ui.giftFromMade = null; ui.giftMax = false; ui.giftIsArk = false; render(); } }, t('giftAnother')) ) ) : ui.giftSplitOffer @@ -692,6 +796,15 @@ export function giftsFeature(ctx) { ui.giftSource === 'chain' ? h('div', { class: 'notice warn small' }, t('giftChainWarn')) : null, + // an optional note, shown to whoever opens the gift + h('textarea', { class: 'gift-note-input', rows: 2, maxlength: GIFT_NOTE_MAX, placeholder: t('giftNotePlaceholder'), + value: ui.giftNote || '', onInput: (e) => { ui.giftNote = e.target.value; } }), + // the link names its sender (and claiming follows them) unless unticked + h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' }, + h('input', { type: 'checkbox', checked: !giftAnon(), onChange: (e) => { + try { if (e.target.checked) localStorage.removeItem(GIFT_ANON_KEY); else localStorage.setItem(GIFT_ANON_KEY, '1'); } catch {} + } }), + h('span', { class: 'small' }, t('giftFromMe'))), ui.giftError && h('div', { class: 'notice err' }, ui.giftError), ui.busy ? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' })) @@ -864,36 +977,40 @@ export function giftsFeature(ctx) { // Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims // into a fresh wallet only they control. The coin is reserved until claimed. function giftUrl() { - return `${location.origin}/${ui.giftIsArk ? 'ag' : 'g'}/${ui.giftCode}`; + return `${location.origin}/${ui.giftIsArk ? 'ag' : 'g'}/${ui.giftCode}${giftFrag(ui.giftNoteMade, ui.giftFromMade)}`; } function giftRate() { return (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; } - function doCreateGift(amt, rate) { + async function doCreateGift(amt, rate) { + ui.busy = true; render(); try { - finishGift(wallet.createGift(amt, rate), amt); + await finishGift(wallet.createGift(amt, rate), amt); ui.giftError = ''; ui.giftSplitOffer = null; } catch (e) { ui.giftError = e.message; ui.giftSplitOffer = null; } + ui.busy = false; render(); } // Gift the whole spendable balance as a no-change sweep (the recipient receives // everything minus their claim fee). - function doCreateGiftAll(rate) { + async function doCreateGiftAll(rate) { + ui.busy = true; render(); try { - finishGift(wallet.createGiftAll(rate)); + await finishGift(wallet.createGiftAll(rate)); ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; } catch (e) { ui.giftError = e.message; } + ui.busy = false; render(); } @@ -906,7 +1023,7 @@ export function giftsFeature(ctx) { ui.busy = true; ui.giftError = ''; render(); try { const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - finishGift(await wallet.createGiftFromSplit(amt, rate), amt); + await finishGift(await wallet.createGiftFromSplit(amt, rate), amt); ui.giftSplitOffer = null; ui.giftError = ''; } catch (e) { @@ -970,9 +1087,11 @@ export function giftsFeature(ctx) { return { blob, claimCode, url: `${location.origin}/lg/${blob}` }; }, receiveTakeover() { return claimCelebration(); }, - sendView() { return ui.giftMode ? giftView() : null; }, + sendView() { return ui.giftMode === 'lnurl' ? wl.view() : ui.giftMode ? giftView() : null; }, sendFormExtras() { - return h('button', { class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')); + return h('div', { class: 'row gap6 wrap', style: 'justify-content:center;column-gap:18px' }, + h('button', { class: 'linklike small', style: 'margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')), + wl.entry()); }, balanceLines() { const locked = wallet.giftLockedValue(); diff --git a/src/features/hats.js b/src/features/hats.js index a14fc2b..932f908 100644 --- a/src/features/hats.js +++ b/src/features/hats.js @@ -222,9 +222,52 @@ export function hatsFeature(ctx) { } } + // ---- the whole list ------------------------------------------------------ + // Only a couple of dozen heads wear a hat, so the registrar hands over all + // of them (/hats/worn) and every avatar is answered from that, even one + // seen for the first time; per-head lookups were 400 ms + a round trip + // after the page had painted. Public data, so it's kept per device, not per + // account. Until it has arrived once, the per-head path above still runs. + const ALL_KEY = (net) => 'hat-worn-all:' + net; + const allWorn = {}; // net -> { hats: {pk: id}, t } + const readAll = (net) => { + if (!(net in allWorn)) { + try { allWorn[net] = JSON.parse(localStorage.getItem(ALL_KEY(net)) || 'null'); } catch { allWorn[net] = null; } + } + return allWorn[net]; + }; + const allBusy = {}, allFailedAt = {}; + async function fetchAll(net) { + if (allBusy[net] || Date.now() - (allFailedAt[net] || 0) < 60_000) return; + allBusy[net] = true; + try { + const r = await fetch(`${REGISTRAR}/hats/worn?net=${net}`); + if (!r.ok) throw new Error('hats list failed'); + const j = await r.json(); + const hats = j.hats || {}; + const prev = readAll(net); + allWorn[net] = { hats, t: Date.now() }; + try { localStorage.setItem(ALL_KEY(net), JSON.stringify(allWorn[net])); } catch {} + if (!prev || JSON.stringify(prev.hats) !== JSON.stringify(hats)) render(); + } catch { + allFailedAt[net] = Date.now(); + } finally { + allBusy[net] = false; + } + } + const fromAll = (pk) => { + const net = hatNet(); + const all = readAll(net); + if (!all || Date.now() - all.t > REVALIDATE) fetchAll(net); + return all ? { hat: all.hats[pk] || null } : null; + }; + setTimeout(() => { try { fromAll(''); } catch {} }, 0); // warm before the first avatar + // Stale-while-revalidate: answer from cache immediately, refresh behind. function hatFor(pk) { if (!pk || !/^[0-9a-f]{64}$/.test(pk)) return null; + const known = fromAll(pk); + if (known) return known.hat; loadStore(); const v = worn.get(hatNet() + ':' + pk); if (!v || Date.now() - v.t > REVALIDATE) queueFetch(pk); diff --git a/src/features/messages.js b/src/features/messages.js index c82bd0e..85b44f3 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -15,14 +15,14 @@ import { mediaServers } from '../media-servers.js'; // listens for both identities and decrypts with whichever keys are present. import { - subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, + subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, wakeRelays, npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS, } from '../nostr.js'; import { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack import { channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId, - foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, + foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, banlistHead, makeBanlist, communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle, } from '../concord.js'; import { makeDMRumor, makeDMReaction, unwrapDM, wrapDM } from '../dm.js'; @@ -67,7 +67,12 @@ import { mergeFeeds } from '../feed-sync.js'; // coinos's own relay and primal: both take DM gift wraps and pass call // messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it // refused or ignored the household IP's publishes, call answers among them. -const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net']; +// messages.coinos.io (Magnitude Relay, built for gift-wrapped messages) leads; +// relay.coinos.io stays on while older inbox lists still name it +const MESSAGES_RELAY = 'wss://messages.coinos.io'; +const DM_RELAYS = [MESSAGES_RELAY, 'wss://relay.coinos.io', 'wss://relay.primal.net']; +// what this app put in a wallet key's kind 10050 before messages.coinos.io +const OLD_DM_DEFAULTS = ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.damus.io']; const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state @@ -483,8 +488,9 @@ export function messagesFeature(ctx) { function roomUnread(room) { const s = st(); const my = myPubkeys(); + const banned = room.folded && room.folded.banned; for (const [id, msgs] of room.byChannel) - if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > (s.read[chRead(id)] || 0)) return true; + if (newestFrom(msgs.values(), (m) => !my.includes(m.author) && !(banned && banned.has(m.author))) > (s.read[chRead(id)] || 0)) return true; return false; } @@ -582,7 +588,10 @@ export function messagesFeature(ctx) { let repostBy = null; // set while noteRow draws a repost's note // A profile shows its articles (NIP-23) among the notes, as Amethyst does. // Their boosts too, under "<name> reposted", as in a feed. - const PROFILE_KINDS = [...FEED_KINDS, 30023, REPOST]; + // ...and their NIP-22 comments (kind 1111): the Replies tab is where + // those go, and a comment scoped to a web address (DaddyTube's, on a + // film's IMDb page) was never asked for at all + const PROFILE_KINDS = [...FEED_KINDS, 30023, REPOST, 1111]; // An edited article is a new event at the same address: keep the newest. const newestArticles = (evs) => { const best = new Map(); @@ -752,14 +761,38 @@ export function messagesFeature(ctx) { let allUnsubs = []; let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt + // Is the page under the reader's finger, or still coasting from a flick? + // Background work (a repaint for a face that arrived, writing the profile + // cache) waits for it to settle: on Adam's Pixel a flick through the feed + // spent 1.7 s of 9 on the main thread doing exactly that, and taps queued + // behind it (2026-11-07, "flick to scroll … not getting up to speed … + // clicking around feels sluggish"). Never longer than `most` ms, though: + // a slow, steady read must still see what arrived. + let pageMovedAt = 0, fingerDown = false; + if (typeof window !== 'undefined') { + window.addEventListener('scroll', () => { pageMovedAt = Date.now(); }, { passive: true, capture: true }); + window.addEventListener('touchstart', () => { fingerDown = true; }, { passive: true, capture: true }); + for (const n of ['touchend', 'touchcancel']) window.addEventListener(n, (e) => { if (!e.touches || !e.touches.length) fingerDown = false; }, { passive: true, capture: true }); + } + const pageMoving = () => fingerDown || Date.now() - pageMovedAt < 200; + const whenSettled = (fn, most = 1500, step = 150) => { + const until = Date.now() + most; + const go = () => { if (pageMoving() && Date.now() < until) { setTimeout(go, step); return; } fn(); }; + go(); + }; + let repaintTimer = null; const scheduleRepaint = () => { if (repaintTimer) return; + const since = Date.now(); const run = () => { // not under a pager in motion: the page beneath is out of sight, and // its repaint (40–55 ms on Adam's phone) dropped frames from flights // through the pictures (2026-10-17) — it waits for the landing if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; } + // nor mid-flick on the page itself (up to 1.5 s): a whole-page morph + // in the middle of a fling is a dropped frame and a late tap + if (pageMoving() && Date.now() - since < 1500) { repaintTimer = setTimeout(run, 150); return; } repaintTimer = null; // a visitor's public page (a profile, a thread, a feed) repaints too if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable(); @@ -818,11 +851,14 @@ export function messagesFeature(ctx) { // memory and is written back once per burst: a batch of thirty profiles // landing during a scroll used to parse and re-serialize the whole blob // thirty times over, on the main thread, mid-scroll. - let profBlob = null, profFlush = null; + let profBlob = null, profFlush = null, profFlushSince = 0; const PROFILE_ROWS = 1500; // a row without a thumbnail is ~430 chars; IndexedDB has room const profilesDisk = () => (profBlob ||= wallet.loadFeatureState('profiles', {})); - function flushProfiles() { - profFlush = null; + function flushProfiles(force) { + // up to 5 s for the page to settle: stringifying this blob (thumbnails + // and all, up to ~2 MB) is a long main-thread task + if (!force && pageMoving() && Date.now() - (profFlushSince || 0) < 5000) { profFlush = setTimeout(flushProfiles, 300); return; } + profFlush = null; profFlushSince = 0; const s = profBlob; if (!s) return; // Who stays when space runs out: your own faces, the people you follow, @@ -840,7 +876,10 @@ export function messagesFeature(ctx) { // Thumbnails are the bulk of this blob, so they live on a budget: the // least recently seen strangers give theirs up first. The row itself // stays — that face just paints the way it used to. - let size = JSON.stringify(s).length; + // (an estimate, not a JSON.stringify of the lot just to measure it: + // a row is ~430 chars plus its thumbnail) + let size = 0; + for (const k in s) size += 430 + ((s[k] && s[k].thumb) || '').length; if (size > THUMB_BUDGET) { // never our own: the header face is the one that must paint offline for (const k of evictFirst(Object.keys(s).filter((k) => s[k].thumb && !mine.has(k)))) { @@ -867,7 +906,7 @@ export function messagesFeature(ctx) { ? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}), ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1, thumbFailVersion: p.thumbFailVersion || 0 } : {}) }; - if (!profFlush) profFlush = setTimeout(flushProfiles, 500); + if (!profFlush) { profFlushSince = Date.now(); profFlush = setTimeout(flushProfiles, 500); } } // A punk picture is OUR OWN art. Every coinos user who keeps the default // publishes https://v3.coinos.io/punks/N.webp as their nostr picture, and @@ -1011,6 +1050,101 @@ export function messagesFeature(ctx) { } } } + // ---- a Blossom blob gone from its host, found on another --------------- + // A picture named by its sha256 (…/<64 hex>.jpg) is a Blossom blob: any + // server holding the same bytes serves them under the same name. When the + // host in the URL no longer has it — Karnage's face and banner on + // media.21media.to answered "blob not found" (2026-11-02) while + // blossom.primal.net, the server he publishes in his kind 10063, still had + // both — the picture is looked for on the servers the person published + // and then on the public ones we know, and the first that answers stands + // in for it: the thumbnail is made from it, the big face and the banner + // paint from it. Remembered on the device, so the search runs once. + const BLOB_RE = /^(https?:\/\/[^/?#]+)(?:\/[^?#]*)?\/([0-9a-f]{64})(\.[a-z0-9]{2,5})?(?:[?#].*)?$/i; + const BLOB_ALT_HOSTS = ['https://blossom.primal.net', 'https://blossom.ditto.pub', 'https://nostr.download', + 'https://blossom.nostr.build', 'https://blossom.band', 'https://cdn.satellite.earth']; + const BLOB_ALT_KEY = 'btc-wallet-blob-alts'; + const blobAlts = new Map(); // url -> the copy that answered, or null when none did + (() => { try { for (const [k, v] of Object.entries(JSON.parse(localStorage.getItem(BLOB_ALT_KEY) || '{}'))) if (typeof v === 'string') blobAlts.set(k, v); } catch {} })(); + const saveBlobAlts = () => { + try { + const found = [...blobAlts].filter(([, v]) => v).slice(-200); + localStorage.setItem(BLOB_ALT_KEY, JSON.stringify(Object.fromEntries(found))); + } catch {} + }; + const blobProbing = new Map(); + const blobAltOf = (url) => (url && blobAlts.get(url)) || null; + // the servers a person publishes for their files (BUD-03), asked only when + // one of their pictures has gone missing + const blobServersOf = async (pk) => { + try { + const evs = await queryOn(PROFILE_RELAYS, { kinds: [10063], authors: [pk], limit: 1 }, 4000); + const ev = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; + return ev ? ev.tags.filter((x) => x[0] === 'server' && /^(https:\/\/|http:\/\/localhost(:\d+)?\/)/i.test(x[1] || '')) + .map((x) => x[1].replace(/\/+$/, '')) : []; + } catch { return []; } + }; + // does an <img> load from here? (no CORS needed to find out) + const imageAnswers = (url, ms = 8000) => new Promise((resolve) => { + const im = new Image(); + const timer = setTimeout(() => { im.onload = im.onerror = null; im.src = ''; resolve(false); }, ms); + im.onload = () => { clearTimeout(timer); resolve(true); }; + im.onerror = () => { clearTimeout(timer); resolve(false); }; + im.src = url; + }); + function findBlobAlt(url, pk) { + if (blobAlts.has(url)) return Promise.resolve(blobAlts.get(url)); + if (blobProbing.has(url)) return blobProbing.get(url); + const m = BLOB_RE.exec(url || ''); + if (!m || typeof Image === 'undefined') return Promise.resolve(null); + const task = (async () => { + const origin = m[1].toLowerCase(), name = m[2] + (m[3] || ''); + const hosts = [...new Set([...(pk ? await blobServersOf(pk) : []), ...BLOB_ALT_HOSTS])] + .filter((h) => h.toLowerCase() !== origin); + // asked together (a host that hangs mustn't hold the rest), the + // person's own servers preferred among those that answer + const answers = await Promise.all(hosts.map((h) => imageAnswers(h + '/' + name).then((ok) => (ok ? h + '/' + name : null)))); + const alt = answers.find(Boolean) || null; + blobAlts.set(url, alt); + if (alt) saveBlobAlts(); + return alt; + })().finally(() => blobProbing.delete(url)); + blobProbing.set(url, task); + return task; + } + // Centre-crop to a square (the way background-size:cover paints it), + // shrink, and encode — off the main thread where the browser allows: the + // crop and resize through createImageBitmap, the encode through an + // OffscreenCanvas's convertToBlob. The synchronous canvas.toDataURL it + // replaces cost ~30 ms a face on a phone, mid-scroll (2026-11-07). + async function encodeThumb(bmp) { + const side = Math.min(bmp.width, bmp.height); + if (!side) throw new Error('empty'); + const sx = (bmp.width - side) / 2, sy = (bmp.height - side) / 2; + if (typeof OffscreenCanvas !== 'undefined' && OffscreenCanvas.prototype.convertToBlob) { + let small = null; + try { + small = await createImageBitmap(bmp, sx, sy, side, side, { resizeWidth: THUMB_PX, resizeHeight: THUMB_PX, resizeQuality: 'high' }); + const oc = new OffscreenCanvas(THUMB_PX, THUMB_PX); + oc.getContext('2d').drawImage(small, 0, 0); + const asUrl = (b) => new Promise((ok, bad) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => bad(r.error); r.readAsDataURL(b); }); + let blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.75 }); + if (blob.type !== 'image/webp') blob = await oc.convertToBlob({ type: 'image/jpeg', quality: 0.7 }); + if (blob.size * 4 / 3 > THUMB_MAX) blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.5 }); // a busy picture, leaned on harder + const data = await asUrl(blob); + if (data.length > THUMB_MAX) throw new Error('too big'); + return data; + } finally { try { small && small.close(); } catch {} } + } + const c = document.createElement('canvas'); + c.width = c.height = THUMB_PX; + c.getContext('2d').drawImage(bmp, sx, sy, side, side, 0, 0, THUMB_PX, THUMB_PX); + let data = c.toDataURL('image/webp', 0.75); + if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7); + if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); + if (data.length > THUMB_MAX) throw new Error('too big'); + return data; + } function makeThumb(pk, p) { if (!p || !p.picture || typeof document === 'undefined') return; if (noCorsHost(p.picture)) return; // a host that won't hand over bytes; the <img> shows it anyway @@ -1067,21 +1201,23 @@ export function messagesFeature(ctx) { // the browser's cache is welcome: the face was just painted from this // very URL, and 'reload' re-downloaded every original (38 MB in one // scroll) — a stale copy still makes a fine thumbnail - const res = await corsFetch(url, AbortSignal.timeout(THUMB_SLOW)); - if (!res.ok) throw new Error('http ' + res.status); + let res = null; + try { res = await corsFetch(url, AbortSignal.timeout(THUMB_SLOW)); } catch (e) { + if (e && e.corsRefused) noteNoCors(url); + if (!BLOB_RE.test(url)) throw e; + } + // the host no longer has it (or won't answer): the same blob on + // another server is as good — and the face it paints is this one + if (!res || !res.ok) { + const alt = BLOB_RE.test(url) ? await findBlobAlt(url, pk) : null; + if (!alt) throw new Error('http ' + (res ? res.status : 'none')); + res = await corsFetch(alt, AbortSignal.timeout(THUMB_SLOW)); + if (!res.ok) throw new Error('http ' + res.status); + scheduleRepaint(); // the big face, where one is up, can paint from the copy now + } bmp = await createImageBitmap(await res.blob()); - // centre-crop to a square, the way background-size:cover paints it - const side = Math.min(bmp.width, bmp.height); - if (!side) throw new Error('empty'); - const c = document.createElement('canvas'); - c.width = c.height = THUMB_PX; - c.getContext('2d').drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, - side, side, 0, 0, THUMB_PX, THUMB_PX); - let data = c.toDataURL('image/webp', 0.75); - if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7); - if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); // a busy picture, leaned on harder - if (data.length > THUMB_MAX) throw new Error('too big'); - done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX }); + const data = await encodeThumb(bmp); + whenSettled(() => done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX })); } catch (e) { // a quick TypeError is the CORS refusal (a dead host takes longer) // only a refusal corsFetch confirmed (a body cut off mid-download @@ -1471,6 +1607,7 @@ export function messagesFeature(ctx) { for (const room of wanted) subscribeRoom(room); if (wn) wn.resubscribe(); if (cd) cd.resubscribe(); + if (ng) ng.resubscribe(); } // ---- subscription watchdog ------------------------------------------------ @@ -1882,9 +2019,12 @@ export function messagesFeature(ctx) { const mine = my.includes(m.author); const reacts = room.reactions.get(m.rumor.id); const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean); - const close = () => { ui.msgSheet = null; ui.emojiPick = null; render(); }; - const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, + const close = () => { ui.msgSheet = null; ui.emojiPick = null; ui.msgBanArm = null; render(); }; + const item = (icon, label, onClick, cls = '') => h('button', { class: 'msg-sheet-item' + cls, onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label); + // the community's admin can take a person out, with everything they said + const canBan = !mine && canModerate(room) && m.author !== room.jm.owner; + const armed = ui.msgBanArm === m.rumor.id; return h('div', { class: 'confirm-pop-backdrop', onClick: (e) => { if (e.target === e.currentTarget) close(); }, @@ -1901,7 +2041,54 @@ export function messagesFeature(ctx) { try { await navigator.clipboard.writeText(msgSnippet(room, m, 100000)); toast(t('copied')); } catch {} close(); }), - !ui.emojiPick && mine ? item('✕', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null)); + !ui.emojiPick && mine ? item('✕', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null, + // two taps: the first asks, the second does it + !ui.emojiPick && canBan ? item('🚫', t(armed ? 'msgBanConfirm' : 'msgBan', { name: displayName(m.author) }), () => { + if (!armed) { ui.msgBanArm = m.rumor.id; render(); return; } + close(); setBanned(room, m.author, true); + }, ' danger' + (armed ? ' armed' : '')) : null)); + } + + // Removing someone from a community is the owner's ban list (CORD-04 vsk 4): + // one owner-signed edition naming every removed key. Every client that + // folds the control plane drops those authors' messages, old and new, and + // takes them off the member list. Only where we hold the control stream's + // signing key (a community founded here, or the built-in one). + const canModerate = (room) => !!room.control.sk && myPubkeys().includes(room.jm.owner); + let banBusy = false; + async function setBanned(room, pk, banned) { + if (banBusy) return false; + const id = await identity(); + if (!id || id.pubkey !== room.jm.owner || !room.control.sk) { toast(t('msgOwnerOnly')); return false; } + banBusy = true; + try { + // The new edition replaces the list whole, so it must be built on the + // newest one: ask the relays now rather than trust what has streamed + // in. A relay that hands back nothing at all (the founding editions + // are always there) is not an answer, and we stop. + const wraps = await queryOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, 5000).catch(() => []); + if (!wraps.length) { toast(t('msgSendFailed')); return false; } + const have = new Set(room.controlEntries.map((e) => e.rumor.id)); + for (const w of wraps) { + const o = openWrap(w, room.control); + if (o && o.rumor.kind === 3308 && !have.has(o.rumor.id)) { have.add(o.rumor.id); room.controlEntries.push(o); } + } + const head = banlistHead(room.controlEntries, { ownerHex: room.jm.owner, cid: room.jm.community_id }); + const list = head.list.filter((x) => x !== pk); + if (banned) list.push(pk); + if (list.length === head.list.length) { room.scheduleFold(); return true; } // already so + const edition = { ...makeBanlist(head, list, Date.now()), pubkey: id.pubkey }; + const wrap = await wrapRumor(edition, id.signer, room.control, { plaintext: true }); + if (!(await publishOn(room.relays, wrap))) { toast(t('msgSendFailed')); return false; } + room.controlEntries.push({ rumor: rumorWithId(edition), author: id.pubkey }); + room.scheduleFold(); + bumpMsgRev(); + toast(t(banned ? 'msgBanned' : 'msgUnbanned', { name: displayName(pk) })); + return true; + } catch (e) { + toast(t('msgSendFailed')); + return false; + } finally { banBusy = false; } } // The composer's "replying to" context bar, with its way out. @@ -2197,6 +2384,13 @@ export function messagesFeature(ctx) { // An invite link opened in the browser lands here before the wallet exists. const urlInvite = typeof location !== 'undefined' ? parseInviteLink(location.href) : null; if (urlInvite) { try { history.replaceState(null, '', '/'); } catch {} } + // /?chat: the public chat page's button on a device that has an account. + // It opens the coinos community's room itself once the wallet is open + // (init below), where "Open coinos" used to leave a signed-in reader on + // the wallet to find the chat (Andy, 2026-11-10). + let urlChat = typeof location !== 'undefined' && !urlInvite && location.pathname === '/' + && /(?:^|&)chat(?:=|&|$)/.test((location.search || '').slice(1)); + if (urlChat) { try { history.replaceState(null, '', '/'); } catch {} } // coinos's own pack share link, /emojis/pack/<naddr> — the Android app // owns every v3.coinos.io path, so one tapped on a phone lands here, not // in a browser; added once a wallet is open (init below). @@ -3314,12 +3508,19 @@ export function messagesFeature(ctx) { // — the user's other clients own that. And when a login identity IS active, // don't advertise the seed key at all: it's internal, not a second inbox // (advertising it is what let DMs land on a phantom identity). + // A list this app wrote with its old defaults (nothing else in it) is + // brought up to today's, so senders find messages.coinos.io first. async function ensureDmRelayList() { if (!wallet.nostr || !wallet.nostr.sk) return; if (hook('nostrLoginIdentity')) return; const pk = wallet.nostr.pk; const existing = await queryOn(DM_RELAYS, { kinds: [10050], authors: [pk] }, 2500); - if (existing.length) return; + if (existing.length) { + const latest = existing.reduce((a, b) => (b.created_at > a.created_at ? b : a)); + const urls = latest.tags.filter((x) => x[0] === 'relay').map((x) => String(x[1] || '').replace(/\/$/, '')); + const ours = urls.length && urls.every((u) => OLD_DM_DEFAULTS.includes(u)); + if (!ours || urls.includes(DM_RELAYS[0])) return; + } const evt = finalizeEvent({ kind: 10050, content: '', @@ -3535,8 +3736,43 @@ export function messagesFeature(ctx) { bumpMsgRev(); scheduleRepaint(); } - const gClient = (g) => (g && g.cordn ? cd : wn); - const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || null; + // NIP-29 groups (nip29-client.js): a group on one relay that keeps its + // members and signs its metadata; messages are plain signed events with + // an `h` tag. Not encrypted. Joined by address (host'id, a client's link), + // and the list of them is the NIP-51 kind 10009 other clients keep too. + let ng = null, ngLoading = null; + function nip29Chat() { + const pk = identityPk(); + if (!pk || !wallet._cacheKey) return Promise.resolve(null); + if (ng && ng.pubkey === pk) return Promise.resolve(ng); + if (ngLoading && ngLoading.pk === pk) return ngLoading.p; + if (ng) { try { ng.stop(); } catch {} ng = null; } + const p = import('../nip29-client.js').then(async (m) => { + if (identityPk() !== pk) return null; + const c = m.nip29Client({ + scope: wallet._cacheKey() + ':' + pk, pubkey: pk, identity, on: onNg, + listRelays: async (deep) => deep + ? [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...(await relaysOf(pk).catch(() => []))])] + : zapRelays(), + }); + ng = c; + ngLoading = null; + await c.start(); + return c; + }).catch((e) => { console.warn('chat: relay groups unavailable', e); ngLoading = null; return null; }); + ngLoading = { pk, p }; + return p; + } + function onNg(type, g, data) { + if (!ng) return; + if (type === 'message') { try { noteEmoji(data); } catch {} } + if (g) wnFolds.delete(g.id); + bumpMsgRev(); + scheduleRepaint(); + } + const gClient = (g) => (g && g.nip29 ? ng : g && g.cordn ? cd : wn); + const gOn = (g) => (g.nip29 ? onNg : g.cordn ? onCd : onWn); + const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || (ng && ng.groups.get(id)) || null; // One delivered event of a direct group, into that person's thread. function wnIntoThread(g, peer, m) { @@ -3686,14 +3922,14 @@ export function messagesFeature(ctx) { const inner = wn.event(7, emoji, [['e', m.rumor.id], ['p', m.rumor.pubkey], ['k', String(m.rumor.kind)], ...reactEmojiTags(emoji)]); const ok = await wn.send(g, inner).catch(() => false); if (!ok) { toast(t('msgSendFailed')); return; } - onWn('message', g, inner); + gOn(g)('message', g, inner); } // ---- groups ---- const wnRead = (id) => 'wn:' + id; // groups with a screen of their own (a direct group lives in its DM thread) - const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : [])]; + const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : []), ...(ng ? ng.groups.values() : [])]; function wnTitle(g) { const c = gClient(g); @@ -3808,7 +4044,7 @@ export function messagesFeature(ctx) { const sent = wn.send(g, inner).catch(() => false); wnFolds.delete(g.id); render(); - if (!(await sent)) { wnFolds.delete(g.id); toast(t('msgSendFailed')); render(); } + if (!(await sent)) { wnFolds.delete(g.id); toast(g.nip29 && g.sendRefused ? t('msgSendFailed') + ': ' + g.sendRefused : t('msgSendFailed')); render(); } } async function wnAct(fn) { @@ -3842,8 +4078,9 @@ export function messagesFeature(ctx) { const punk = localPunk(p.picture, big); if (punk) return `url(${JSON.stringify(punk)})`; const local = p.thumb && p.thumbFor === p.picture ? p.thumb : null; - if (!local) return `url(${JSON.stringify(p.picture)})`; - return (big ? `url(${JSON.stringify(p.picture)}),` : '') + `url(${JSON.stringify(local)})`; + const pic = blobAltOf(p.picture) || p.picture; // the same blob elsewhere, when the host lost it + if (!local) return `url(${JSON.stringify(pic)})`; + return (big ? `url(${JSON.stringify(pic)}),` : '') + `url(${JSON.stringify(local)})`; } const FACE_WAIT_MS = 1000; @@ -4049,7 +4286,17 @@ export function messagesFeature(ctx) { }; function warmBanner(pk) { const b = bannerOf(pk); - if (b && !mediaReady.has(b)) return warmMedia(b, { thumb: true }).then(() => scheduleRepaint()); + if (!b) return; + const shown = blobAltOf(b) || b; + if (mediaReady.has(shown)) return; + return warmMedia(shown, { thumb: true }).then(async () => { + // the host lost it: the same blob on another server paints instead + if (!mediaReady.has(shown) && shown === b && BLOB_RE.test(b)) { + const alt = await findBlobAlt(b, pk); + if (alt) await warmMedia(alt, { thumb: true }); + } + scheduleRepaint(); + }); } function prefetchProfilePage(pk) { if (!pk || prefetched.has(pk)) return; @@ -4123,7 +4370,8 @@ export function messagesFeature(ctx) { // ---- profile notes: latest public posts & replies ----------------------- - const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])]; + // (not messages.coinos.io: it takes messages, not posts) + const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS.filter((r) => r !== MESSAGES_RELAY)])]; // ---- zap tallies --------------------------------------------------------- // How much a post or chat message has been zapped: the public receipts @@ -4221,6 +4469,8 @@ export function messagesFeature(ctx) { if (persist) persistThread(c); } const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1]; + const APP_KIND = 32267; // a Zapstore app listing (NIP-82): zaps on one are zaps on your app + const APP_RELAYS = ['wss://relay.zapstore.dev']; function receiptSats(ev) { if (ev.kind === 9737) { const net = tagOf(ev, 'network'); @@ -4241,6 +4491,16 @@ export function messagesFeature(ctx) { if (ev.kind === 9737) return String(ev.content || '').slice(0, 200); try { return String((JSON.parse(tagOf(ev, 'description') || 'null') || {}).content || '').slice(0, 200); } catch { return ''; } } + // The address (kind:pubkey:d) a zap was for, when it wasn't a note: a + // stream (30311) or a Zapstore app listing (32267). LNURL servers often + // leave the a tag off the receipt (Zapstore's did, 2026-11-07), so the zap + // request inside it is asked too. + function zapAddrOf(ev) { + const a = (e) => ((e && e.tags) || []).find((x) => x[0] === 'a' && x[1]); + const own = a(ev); + if (own) return own[1]; + try { const r = a(JSON.parse(tagOf(ev, 'description') || 'null')); return r ? r[1] : null; } catch { return null; } + } function zapperOf(ev) { if (ev.kind === 9737) { // Signed by the wallet key; the sender tag names the person behind it. @@ -4528,10 +4788,12 @@ export function messagesFeature(ctx) { const momentsOn = (id, file) => [...(timedActivity.get(id)?.values() || [])] .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev)) .sort((a, b) => a.moment.seconds - b.moment.seconds) - .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) })); + .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(momentStripped(x.ev, x.text) || x.icon).slice(0, 240) })); // how a moment shows on a bar: a reaction as its emoji (a :code: as its - // picture, from the reaction's own tag), a zap ⚡, a reply ↩ + // picture, from the reaction's own tag), a zap ⚡, a reply as its + // author's face function momentGlyph(x) { + if (x.ev.kind === 1 || x.ev.kind === 1111) return avatar(x.ev.pubkey, 'chat-avatar media-mark-face', false); if (x.ev.kind !== 7) return x.icon; const code = shortcodeOf(x.ev.content); const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]); @@ -4551,6 +4813,19 @@ export function messagesFeature(ctx) { } return null; } + // A comment made at a moment of a clip opens with its time as a link that + // plays the clip from there; the "[0:03] " other clients read as text is + // left out of the words beside it. + function momentLink(ev) { + const m = readMoment(ev); + if (!m) return null; + return h('a', { class: 'media-time-link', href: m.url + '#t=' + m.seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => { + e.stopPropagation(); + const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url); + if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); } + } }, '▶ ' + mediaClock(m.seconds)); + } + const momentStripped = (ev, text) => (readMoment(ev) ? String(text || '').replace(/^\s*\[\d{1,2}(?::\d{2}){1,2}\]\s*/, '') : text); const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; }; function attachMediaTimeline(player, url, explicitEvent) { if (player._timeline) return; @@ -4562,7 +4837,8 @@ export function messagesFeature(ctx) { }; const box = player.parentElement; box._skipMorph = true; - box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph })); + box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph, + onMarks: player.tagName === 'VIDEO' ? (on) => ownBar(player, on) : null })); player.dispatchEvent(new Event('timeupdate')); } function noteEvent(ev) { @@ -4894,25 +5170,38 @@ export function messagesFeature(ctx) { const notesCache = new Map(); // pk -> { status: 'loading'|'ready', notes: [kind-1 events] } // NIP-65: where this author actually writes. Their notes and threads live // there first — our default relays are just the common ground. + // Asked one at a time — a quoted note's author, a face tapped, a reply's + // target — these went out as one REQ per author per relay: a feed with + // six quotes on screen sent eighteen. Asks now wait a beat and go out + // together, one REQ with every author wanted, through the feed's own + // batched fetch, which also remembers who has no list so nobody is asked + // twice. const relayListCache = new Map(); // pk -> Promise<string[]> + const RELAY_ASK_MS = 80; + let relayAskQ = new Map(), relayAskTimer = null; // pk -> [resolve] function relaysOf(pk) { let p = relayListCache.get(pk); if (p) return p; // the feed's batched fetch has probably already asked for this one const known = relayListsNow().get(pk); if (known) { p = Promise.resolve(known.r || []); relayListCache.set(pk, p); return p; } - p = queryOn([...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [10002], authors: [pk] }, 3500) - .then((evs) => { - const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; - if (!newest) return []; - return [...new Set(newest.tags - .filter((x) => x[0] === 'r' && x[1] && x[2] !== 'read' && /^wss:\/\//.test(x[1])) - .map((x) => x[1].replace(/\/$/, '')))].slice(0, 4); - }) - .catch(() => []); + p = new Promise((resolve) => { + if (!relayAskQ.has(pk)) relayAskQ.set(pk, []); + relayAskQ.get(pk).push(resolve); + if (!relayAskTimer) relayAskTimer = setTimeout(flushRelayAsks, RELAY_ASK_MS); + }); relayListCache.set(pk, p); return p; } + async function flushRelayAsks() { + relayAskTimer = null; + const batch = relayAskQ; relayAskQ = new Map(); + try { await fetchRelayLists([...batch.keys()], [...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])]); } catch {} + for (const [pk, waiters] of batch) { + const r = ((relayListsNow().get(pk) || {}).r || []).slice(0, 4); + for (const w of waiters) w(r); + } + } // Plus relays that keep history: the big ones prune within days, and an // author without a NIP-65 list (Joe Nakamoto, 2026-10-01) otherwise shows // only whatever primal still holds — 2 posts of 22. @@ -4947,8 +5236,10 @@ export function messagesFeature(ctx) { // Threads"): the replies these people write, each under the post it // answers, instead of their posts. Asked for as notes and NIP-22 comments; // reposts have no place in it. (REPLY_KINDS, above: a note or a comment.) - const feedKinds = (def) => (def && def.replies ? REPLY_KINDS : FEED_KINDS); - const feedKeeps = (def, e) => (def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e)); + // A word search (def.search) asks NIP-50 relays for notes and replies + // whose text holds it: kind 1 only, replies kept as rows of their own. + const feedKinds = (def) => (def && def.search ? [1] : def && def.replies ? REPLY_KINDS : FEED_KINDS); + const feedKeeps = (def, e) => (def && def.search ? e.kind === 1 : def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e)); // What a reply answers: the e tag marked 'reply' (NIP-10), else 'root' // (a reply straight to the root), else — the deprecated positional form — // the last e tag; a NIP-22 comment's lowercase e (its parent), else its E. @@ -4969,6 +5260,17 @@ export function messagesFeature(ctx) { if (!ref.author) ref.authors = [...new Set((ev.tags || []).filter((x) => x[0] === 'p').map((x) => hex(x[1])).filter((p) => p && p !== ev.pubkey))].slice(0, 3); return ref; } + // The web page a NIP-22 comment is on (its I tag, an http(s) URL), with + // the label its alt tag gives it — DaddyTube: "Comment on Sheep + // Detectives (2025) in DaddyTube" — or else the address, host and path. + function webScopeOf(ev) { + const url = ((ev.tags || []).find((x) => x[0] === 'I' && /^https?:\/\/\S+$/i.test(x[1] || '')) || [])[1]; + if (!url) return null; + let where = url; + try { const u = new URL(url); where = (u.host + u.pathname).replace(/^www\./, '').replace(/\/$/, ''); } catch {} + const alt = ((ev.tags || []).find((x) => x[0] === 'alt' && String(x[1] || '').trim()) || [])[1]; + return { url, label: alt ? String(alt).trim().slice(0, 140) : t('noteCommentOn', { where }) }; + } // A note already in hand — in a feed, a thread, someone's page — before // asking any relay for it: the post a reply answers is usually right // there in the Following feed. @@ -5156,7 +5458,8 @@ export function messagesFeature(ctx) { ]); const all = [...(relayEvs || []), ...primalEvs].filter((e) => e.pubkey === pk); const list = all.filter((e) => e.kind === 3).sort((a, b) => b.created_at - a.created_at)[0] || null; - return { list, existing: all.length > 0 }; + const profile = all.filter((e) => e.kind === 0).sort((a, b) => b.created_at - a.created_at)[0] || null; + return { list, profile, existing: all.length > 0 }; } // Fetch the newest list from the relays. Older than what we hold is @@ -5355,12 +5658,12 @@ export function messagesFeature(ctx) { // small, widely mirrored, and worth having before anything else is asked. // Authors with no list at all are remembered as such, so we don't ask again // every time the feed refreshes. - async function fetchRelayLists(pks) { + async function fetchRelayLists(pks, relays = NOTE_RELAYS) { const want = [...new Set(pks)].filter((pk) => !relayListsNow().has(pk)); if (!want.length) return; for (let i = 0; i < want.length; i += 100) { const batch = want.slice(i, i + 100); - const evs = await queryOn(NOTE_RELAYS, { kinds: [10002], authors: batch }, 4500).catch(() => []); + const evs = await queryOn(relays, { kinds: [10002], authors: batch }, 4500).catch(() => []); const newest = new Map(); for (const ev of evs || []) { const cur = newest.get(ev.pubkey); @@ -5660,13 +5963,16 @@ export function messagesFeature(ctx) { // posts from anyone on these tags, as well as the feed's people (Everything) const orTopicsOf = (def) => [...new Set(((def && def.orTopics) || []).map(normTopic).filter(Boolean))]; const feedTopics = (def = feedDef()) => [...new Set(((def && def.topics) || []).map(normTopic).filter(Boolean))].slice(0, 20); - const feedHasQuery = (def = feedDef()) => !!def && (!!def.all || feedAuthors(def).length > 0 || feedTopics(def).length > 0); + const feedHasQuery = (def = feedDef()) => !!def && (!!def.all || !!def.search || feedAuthors(def).length > 0 || feedTopics(def).length > 0); // A feed's own relay list, when it has one, replaces the usual choice // (the authors' outboxes, or the wide relays for a topic) everywhere the // feed reads. Independent of what the feed asks for. // Popular relays that answer a bare "everything" query (probed 2026-09-27; // paid/search relays like nostr.wine and nostr.band answer nothing and are // left out). The firehose reads a few of them by default. + // NIP-50 full-text search over notes (probed 2026-11-11: these three + // answer `search` on kind 1; nostr.band and primal answered nothing). + const TEXT_SEARCH_RELAYS = ['wss://relay.ditto.pub', 'wss://search.nos.today', 'wss://nostr.wine']; const RELAY_PICKS = ['wss://relay.coinos.io', 'wss://relay.ditto.pub', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.snort.social', 'wss://nostr.mom', 'wss://nostr.bitcoiner.social', 'wss://eden.nostr.land', 'wss://nostr.land', 'wss://nostr.oxtr.dev', 'wss://relay.nostrplebs.com', 'wss://offchain.pub', 'wss://nostr21.com', 'wss://relay.nostr.net', 'wss://nostr-pub.wellorder.net']; @@ -6186,9 +6492,15 @@ export function messagesFeature(ctx) { async function prefetchFeed(c) { const def = feedDef(c.id); const authors = feedAuthors(def), topics = feedTopics(def); - if (!authors.length && !topics.length && !(def && def.all)) { c.status = 'ready'; return; } + if (!authors.length && !topics.length && !(def && (def.all || def.search))) { c.status = 'ready'; return; } c.at = Date.now(); const merge = { newerThan: c.notes.length ? c.notes[0].created_at : Infinity }; + if (def.search) { + try { await queryFeed(feedRelaysOr(def, TEXT_SEARCH_RELAYS), { kinds: [1], search: def.search, limit: FEED_PAGE }, merge, c, 5000); } catch {} finally { + if (!c.stopped) { c.status = 'ready'; if (c === feed && ui.chatOpen && ui.msgView === 'feed') scheduleRepaint(); } + } + return; + } if (def && def.curated) { try { await popularPass({}, merge, c, def); } catch {} finally { if (!c.stopped) { c.status = 'ready'; if (c === feed && ui.chatOpen && ui.msgView === 'feed') { scheduleRepaint(); watchFeed(); } } @@ -6197,13 +6509,17 @@ export function messagesFeature(ctx) { } try { const orT = orTopicsOf(def); + // the feed's own kinds: a Conversations view asks for replies and + // NIP-22 comments (asked for posts, it painted no comment at all + // until a refresh half a minute on; the pass below holds for them) + const kinds = feedKinds(def); await Promise.all([ // people's reposts with their posts (a topic or firehose ask: posts only); // every author, a chunk of them per ask ...(authors.length ? authorChunks(authors) : [null]).map((chunk) => - queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: chunk && !topics.length ? [...FEED_KINDS, REPOST] : FEED_KINDS, limit: FEED_PAGE, + queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: chunk && !topics.length && !def.replies ? [...kinds, REPOST] : kinds, limit: FEED_PAGE, ...(chunk ? { authors: chunk } : {}), ...(topics.length ? { '#t': topics } : {}) }, merge, c, 3000)), - orT.length ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE, '#t': orT }, merge, c, 3000) : null]); + orT.length ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, limit: FEED_PAGE, '#t': orT }, merge, c, 3000) : null]); } catch {} finally { if (!c.stopped) { c.status = 'ready'; @@ -6277,6 +6593,13 @@ export function messagesFeature(ctx) { // ---- feeds anyone can open -------------------------------------------- // Every kind of feed has a URL, and a visitor with no wallet can read it. const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv; + // Whose page is painted: a visitor's (no wallet on this device), or the + // wallet's that boot is about to restore (ctx.restorePending). The feed, + // reached by URL, paints before that restore lands; painted as a visitor's + // it had no post button, chips or nav until the wallet opened a beat later + // (Adam, 2026-10-31). Taps go by isVisitor(): by the time one lands the + // restore, synchronous, has run. + const visitorView = () => isVisitor() && !(ctx.restorePending && ctx.restorePending()); const showFeed = (id) => { ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.chatOpen = true; ui.msgView = 'feed'; @@ -6326,6 +6649,8 @@ export function messagesFeature(ctx) { const topics = [...new Set((q.get('t') || '').split(',').map(normTopic).filter(Boolean))]; const packs = (q.get('pack') || '').split(',').map((x) => parsePackLink(x.trim())).filter(Boolean); const relays = urlRelays(q); + const words = (q.get('q') || '').trim(); + if (words) { openTextSearch(words, relays); return true; } if (!authors.length && !topics.length && !packs.length) { openFirehose(relays); return true; } const sig = JSON.stringify([authors, topics, packs.map(packKey), relays]); let hsh = 5381; for (let i = 0; i < sig.length; i++) hsh = ((hsh * 33) ^ sig.charCodeAt(i)) >>> 0; @@ -6356,6 +6681,7 @@ export function messagesFeature(ctx) { if (def.all) return (def.curated ? '/feed' : '/feed/all') + r; if (def.builtin) return share && mePk() && !def.replies ? '/feed/' + npubOf(mePk()) + r : null; if (def.of) return '/feed/' + npubOf(def.of) + r; + if (def.search) return '/feed?q=' + encodeURIComponent(def.search) + (r ? '&' + r.slice(1) : ''); const authors = def.authors || [], topics = feedTopics(def), packs = def.packs || []; if (!authors.length && !packs.length && topics.length === 1) return '/feed/t/' + encodeURIComponent(topics[0]) + r; if (!authors.length && !topics.length && packs.length === 1) { @@ -6483,6 +6809,15 @@ export function messagesFeature(ctx) { ui.chatOpen = true; ui.msgView = 'feed'; switchFeed(id); } + // Words typed into search and submitted without picking a suggestion: + // the notes and replies that say them, as a feed for this session. + function openTextSearch(q, relays = []) { + q = String(q || '').replace(/\s+/g, ' ').trim().slice(0, 200); + if (!q) return; + const id = 'search:' + q.toLowerCase() + (relays.length ? '@' + relays.join(',') : ''); + if (!adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '\u201c' + q + '\u201d', follows: false, authors: [], packs: [], topics: [], search: q, relays }); + showFeed(id); + } // Something above the posts is in use — a post being written, the relay // panel, a focused field — so new posts wait behind the pill rather than // push it off the screen. @@ -6910,7 +7245,7 @@ export function messagesFeature(ctx) { if (!url || mediaReady.has(url)) return Promise.resolve(); const known = videoDims.get(url) || dim; if (known) { rememberVideo(url, known.width, known.height); return Promise.resolve(); } - if (typeof document === 'undefined' || videoUnknown.has(url)) return Promise.resolve(); + if (typeof document === 'undefined' || videoUnknown.has(url) || clipGone(url)) return Promise.resolve(); // (gone: nothing to measure) if (mediaWarming.has(url)) return mediaWarming.get(url); // the 64 KB probe first: it reads the shape from the file's own header // and costs nothing like a player's metadata load of an index-last file @@ -7032,7 +7367,7 @@ export function messagesFeature(ctx) { return urls; } const avatarUrl = (p, pk) => p?.picture - ? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture + ? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || blobAltOf(p.picture) || p.picture : punkSmallUrl(pk); async function warmAvatar(pk, deadline) { let p = liveProfileOf(pk); @@ -7205,6 +7540,35 @@ export function messagesFeature(ctx) { // Posts at the door: filtered in synchronously, so the same note from a // second relay is dropped while the first copy is still warming. const feedStaged = new WeakMap(); + // A word search turns up bot fleets posting one newsletter dozens of times + // from a dozen keys, each copy reworded a little (an emoji, "BREAKING", the + // picture moved) so it isn't the same text. Words and links as a set: a + // post sharing half of them with one already in the search feed is that + // post again, and only the first is shown. + const textSigs = new WeakMap(); // event -> Set of words + function textSig(e) { + let sig = textSigs.get(e); + if (!sig) { + const words = String(e.content || '').toLowerCase().match(/https?:\/\/\S+|[\p{L}\p{N}]{3,}/gu) || []; + textSigs.set(e, sig = new Set(words)); + } + return sig; + } + const NEAR_COPY = 0.5; // measured 2026-11-11: copies 0.53+, distinct posts at most 0.45 + const nearCopies = new WeakMap(); // feed state -> sigs admitted + function nearCopy(c, held, e) { + const sig = textSig(e); + if (sig.size < 8) return false; // short posts ("gm fiatjaf") are allowed to agree + let seen = nearCopies.get(c); + if (!seen) nearCopies.set(c, seen = held.map(textSig).filter((x) => x.size >= 8)); + for (const o of seen) { + let both = 0; + for (const w of sig) if (o.has(w)) both++; + if (both / (sig.size + o.size - both) >= NEAR_COPY) return true; + } + seen.push(sig); + return false; + } async function mergeFeed(evs, opts = {}, c = feedNow()) { let staged = feedStaged.get(c); if (!staged) feedStaged.set(c, staged = new Set()); @@ -7226,6 +7590,7 @@ export function messagesFeature(ctx) { const def = feedDef(c.id); let add = (evs || []).filter((e) => (e.kind === REPOST ? !(def && def.replies) && !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : feedKeeps(def, e)) && !hidden(e) && fresh(e) && !known.has(e.id) && !staged.has(e.id) && addrNew(e) && known.add(e.id) && staged.add(e.id) && staged.add(noteOf(e).id)); + if (def && def.search) add = add.filter((e) => !nearCopy(c, held, e)); if (!add.length) return false; if (def && def.curated) { const kept = curateFeed(add); @@ -7513,6 +7878,7 @@ export function messagesFeature(ctx) { const tag = topics.length ? { '#t': topics } : {}; let got = false; if (def.curated) return popularPass(extra, merge, c, def); + if (def.search) return queryFeed(relays || feedRelaysOr(def, TEXT_SEARCH_RELAYS), { kinds: [1], search: def.search, limit, ...extra }, merge, c); const orT = orTopicsOf(def); const orPass = orT.length ? queryFeed(relays || feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) @@ -7733,22 +8099,33 @@ export function messagesFeature(ctx) { } // While the feed is what's on screen, new posts arrive by themselves — on // the same relays the pass above reads, one subscription each. + // The live subscriptions stay up across refreshes. Every refresh pass + // used to end by closing and reopening all of them — seventeen REQs over + // eight relays, twice per opening of Following — for subscriptions whose + // relays and authors hadn't changed; only the head they count fresh posts + // against moves, and that is read at each event. + let feedWatchKey = '', feedWatchHead = { t: Infinity }; function watchFeed() { - stopFeedWatch(); const c = feedNow(); const def = feedDef(c.id); // the Conversations view has its own - if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return; + if (!feedHasQuery(def) || def.search || !ui.chatOpen || ui.msgView !== 'feed') { stopFeedWatch(); return; } const newerThan = c.notes.length ? c.notes[0].created_at : Infinity; const since = Math.floor(Date.now() / 1000) - 60; const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {}; const kinds = feedKinds(def); - const on = (ev) => { mergeFeed([ev], { live: true, newerThan }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; const authors = feedAuthors(def); + const orT = orTopicsOf(def); + const plan = def.curated || !authors.length ? [] : feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); + const key = [c.id, !!def.curated, !!def.all, !!def.replies, kinds.join(), topics.join(), orT.join(), (feedRelays(def) || []).join(), + ...plan.map(({ relays, authors: a }) => relays.join() + '>' + a.join())].join('\n'); + if (feedUnsubs.length && key === feedWatchKey) { feedWatchHead.t = newerThan; return; } + stopFeedWatch(); + feedWatchKey = key; + const head = feedWatchHead = { t: newerThan }; + const on = (ev) => { mergeFeed([ev], { live: true, newerThan: head.t }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; } if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, since }, on)); return; } - const orT = orTopicsOf(def); if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, since }, on)); - const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); for (const { relays, authors: a } of plan) for (let i = 0; i < a.length; i += REQ_AUTHORS) feedUnsubs.push(subscribeOn(relays, { kinds: topics.length || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); @@ -7756,6 +8133,7 @@ export function messagesFeature(ctx) { function stopFeedWatch() { for (const u of feedUnsubs) { try { u(); } catch {} } feedUnsubs = []; + feedWatchKey = ''; } // Prepare older pages several screens ahead of the reader. A scroll while // preparation is underway shares the same work instead of exposing it early. @@ -7949,13 +8327,13 @@ export function messagesFeature(ctx) { // known, never taller than the screen; only a clip taller than that sits // in a black frame, rather than in a strip at the left. const videoBoxStyle = (d) => d && d.width && d.height - ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:100vh;object-fit:contain` + ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:var(--vid-cap);object-fit:contain` : 'width:100%;aspect-ratio:16/9;object-fit:contain'; // Over a clip that waits for a tap: what it costs, and the tap that plays // it (with sound — the reader asked for this one). The box is left alone // by the morph from here, or a repaint would take the cover off. function heavyCover(box, url, onPlay) { - if (box.querySelector('.vid-heavy')) return; + if (box.querySelector('.vid-heavy') || box.classList.contains('gone')) return; box._skipMorph = true; const cover = h('button', { class: 'vid-heavy', type: 'button', 'aria-label': t('videoTapToPlay'), onClick: (e) => { @@ -7967,7 +8345,7 @@ export function messagesFeature(ctx) { if (v) { if (v.getAttribute('src') !== playSrc(url)) v.setAttribute('src', playSrc(url)); v.preload = 'auto'; - v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); v.controls = true; }); + v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); nativeControls(v); }); } } }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url))); box.append(cover); @@ -8059,13 +8437,34 @@ export function messagesFeature(ctx) { const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url : streamsHere(url) ? hlsOf(url) : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; + // A clip whose file its host no longer has: the player is the first to + // know (Primal's blossom answers a 404 with no CORS headers, so the + // probe couldn't read it and the box sat as a dead "Tap to play" — a + // post whose video "won't play", Adam, 2026-11-01). It says so where the + // player stood, with the file's link, and the grid leaves the clip out + // for a day, as it does a 404 the probe could read. The copy is tried + // first: a file the browser can't decode fails the same way, and the + // copy plays that one. + function clipDead(v, url) { + noteVideoInfo(url, { goneAt: Date.now() }); + v._gone = true; + v.controls = false; + const box = v.parentElement; + if (!box) return; + box._skipMorph = true; + box.classList.add('gone'); + for (const el of box.querySelectorAll('.vid-heavy, .vid-pp, .vid-sound')) el.remove(); + if (!box.querySelector('.vid-nopic')) box.append(h('div', { class: 'vid-nopic' }, h('span', {}, t('videoGone')), + h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() }, t('videoOpenFile')))); + } // A player whose clip turns out to have no picture it can show (its // metadata says zero width) switches to the copy; if the copy fails too, // the original's sound plays under a line saying why, with the file. + // One whose original won't load at all tries the copy too; the copy + // failing as well means the file is gone (clipDead). function guardPicture(v, url) { - if (!vmediaOk(url)) return; const onCopy = () => (v.getAttribute('src') || '').startsWith(VMEDIA); - v.addEventListener('loadedmetadata', () => { + if (vmediaOk(url)) v.addEventListener('loadedmetadata', () => { if (v.videoWidth || onCopy() || h264Failed.has(url)) return; noPicture.add(url); const want = !v.paused; @@ -8073,7 +8472,18 @@ export function messagesFeature(ctx) { if (want) v.play().catch(() => {}); }); v.addEventListener('error', () => { - if (!onCopy()) return; + if (!v.getAttribute('src')) return; // let go of (scrolled off), not failed + if (!onCopy()) { + if (vmediaOk(url) && !h264Failed.has(url) && !v._origFailed) { + v._origFailed = true; + const want = !v.paused; + v.setAttribute('src', h264Of(url)); + if (want) v.play().catch(() => {}); + return; + } + clipDead(v, url); + return; + } // the stream failed: the copy or the original, as it would have been if (isHls(v.getAttribute('src'))) { hlsFailed.add(url); @@ -8083,6 +8493,7 @@ export function messagesFeature(ctx) { return; } h264Failed.add(url); + if (v._origFailed) { clipDead(v, url); return; } // neither the file nor a copy of it: gone const want = !v.paused; v.setAttribute('src', url); if (want) v.play().catch(() => {}); @@ -8095,10 +8506,38 @@ export function messagesFeature(ctx) { }); } // A tap on the picture itself (not the player's own play button or its - // control bar): inline, it goes full screen — the corner button is a + // control bar): inline, a mouse click pauses a playing clip; a paused one + // (or any touch) goes full screen — the corner button is a // reach on a phone. Full screen, it pauses a playing clip, and a tap on a // paused one comes back out. The player's controls are inside the video // element, so where they sit is judged from the tap's position. + // The big play/pause over a feed clip is for a mouse: <html> carries + // .has-mouse while the last pointer was one (a touch laptop switches with + // the hand), and CSS shows the button on :hover (a repaint can't drop it). + if (typeof document !== 'undefined') { + const mouseIs = (on) => document.documentElement.classList.toggle('has-mouse', on); + document.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') mouseIs(true); }, { passive: true }); + document.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse') mouseIs(false); }, { passive: true }); + } + // A clip whose post has timed reactions seeks on the moments bar under it + // (the reactions sit on it at their moments), so the player's own controls + // — a second seek bar — stay off; the middle button and the sound button + // are its controls then (Adam: "hide the main seek bar … and only show a + // moments seek bar"). Otherwise the player's controls, once it has woken. + const nativeControls = (v) => { v.controls = !v._ownBar && !v._gone; }; + function ownBar(player, on) { + const box = player.closest('.note-video-box'); + if (!box || player._ownBar === on) return; + player._ownBar = on; + box.classList.toggle('own-bar', on); + if (!box.classList.contains('idle')) nativeControls(player); + // with no player controls, the sound button stays to turn it back down + if (on && !box.querySelector('.vid-sound')) { + const b = soundBtn((button) => { player.muted = !player.muted; button.setSound(!player.muted); }); + b.setSound(!player.muted); + box.append(b); + } + } const fsElement = () => (typeof document !== 'undefined' && (document.fullscreenElement || document.webkitFullscreenElement)) || null; let videoTapAt = 0; // a touch's tap on a clip, taken at pointerup: the click after it lands on whatever opened function videoTap(e, url) { @@ -8120,12 +8559,24 @@ export function messagesFeature(ctx) { else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {}); return true; } + // a mouse click on a playing clip just pauses it (Adam: "clicking on a + // video that's playing should pause the video, not full screen it"); it + // keeps its controls and the middle button resumes it, and a click on it + // paused opens the pager. A touch has no middle button: its tap still opens. + if (!v.paused && e.pointerType === 'mouse') { + v.pause(); + wokeClips.add(url); + const box = v.closest('.note-video-box'); + if (box) { box.classList.remove('idle'); box._skipMorph = true; } + nativeControls(v); + return true; + } if (openClip(v, url)) return true; // a clip still idle (autoplaying, no controls) becomes the reader's wokeClips.add(url); const box = v.closest('.note-video-box'); if (box) { box.classList.remove('idle'); box._skipMorph = true; } - v.controls = true; + nativeControls(v); const go = v.requestFullscreen || v.webkitRequestFullscreen || v.webkitEnterFullscreen; if (!go) return true; // (the screen isn't turned for it: a wide clip plays with bars, and the @@ -8133,8 +8584,21 @@ export function messagesFeature(ctx) { Promise.resolve(go.call(v)).catch(() => {}); return true; } + // A YouTube box we took full screen still shows YouTube's own full-screen + // button, and a press on it asks for full screen again from inside the + // frame — nothing to see, and no way out ("worked to make the video + // fullscreen but not toggle it off"). The page does see that happen: the + // frame becomes the full-screen element in place of its box. The press + // meant "out", so out it goes, both steps. + let ytFs = null, ytLeaving = false; if (typeof document !== 'undefined') document.addEventListener('fullscreenchange', () => { const el = fsElement(); + const leave = () => Promise.resolve((document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)).catch(() => {}); + if (!el) { ytFs = null; ytLeaving = false; } + else if (el.classList && el.classList.contains('yt-embed')) { + if (ytLeaving) { ytLeaving = false; ytFs = null; leave(); return; } + ytFs = el; + } else if (el.tagName === 'IFRAME' && ytFs && ytFs.contains(el)) { ytLeaving = true; leave(); return; } // The player's own full-screen button on a feed clip: the pager, not // Chrome's full screen (no next clip there). The button stays on the bar // (left off, Chrome greys it out) and is taken over here. @@ -8167,8 +8631,7 @@ export function messagesFeature(ctx) { // learned now, used by the next paint of any row with this clip onLoadedmetadata: (e) => { rememberVideo(url, e.target.videoWidth, e.target.videoHeight); attachMediaTimeline(e.target, url); }, // a touch's tap was taken at pointerup (below): its click, if one comes, is spent - onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); }, - onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } }); + onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); } }); guardPicture(v, url); // With its own controls up, Chrome on Android keeps a tap on the clip for // them (it shows or hides the bar) and the page never gets the click — so @@ -8187,13 +8650,38 @@ export function messagesFeature(ctx) { if (videoTap(e, url)) el._tapAt = videoTapAt = Date.now(); }); v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted - const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : '') }, v); + // a tall clip is held to the cap's height and the box narrows to its + // shape, instead of a full-width box of black bars (Adam, on a 1920 screen) + const boxCap = (w, ht) => w && ht ? `max-width:calc(var(--vid-cap) * ${w} / ${ht})` : undefined; + const known = feedMedia(url); + const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : ''), style: boxCap(known && known.width, known && known.height) }, v); + v.addEventListener('loadedmetadata', () => { if (v.videoWidth && v.videoHeight) box.style.maxWidth = `calc(var(--vid-cap) * ${v.videoWidth} / ${v.videoHeight})`; }); if (loud) box._skipMorph = true; + // With a mouse: a big play/pause button in the middle while the pointer is + // over the clip. It plays or pauses in place, never full screen (a click + // elsewhere on a paused clip opens the pager). Touch keeps its taps. + const pp = h('button', { class: 'vid-pp', type: 'button', 'aria-label': t('videoPlay'), onClick: (e) => { + e.stopPropagation(); e.preventDefault(); + const player = e.currentTarget.closest('.note-video-box')?.querySelector('video'); + if (!player) return; + if (player.paused) { tappedClips.add(url); player.play().catch(() => {}); } else player.pause(); + } }, h('span', { class: 'vid-pp-i', 'aria-hidden': 'true' })); + const ppState = () => { + const live = v.closest('.note-video-box'); + const b = live && live.querySelector('.vid-pp'); + if (!b) return; + b.classList.toggle('playing', !v.paused); + b.setAttribute('aria-label', t(v.paused ? 'videoPlay' : 'videoPause')); + }; + v.addEventListener('play', ppState); + v.addEventListener('pause', ppState); + box.append(pp); + // remembered by clip, so a repaint draws the box as it now stands const wake = () => { wokeClips.add(url); box.classList.remove('idle'); - v.controls = true; + nativeControls(v); }; v.addEventListener('playing', wake); const btn = loud ? null : soundBtn((button) => { @@ -8202,14 +8690,19 @@ export function messagesFeature(ctx) { const liveBox = button.closest('.note-video-box'); const player = liveBox?.querySelector('video'); if (!player) return; + if (player._ownBar && !player.muted) { player.muted = true; button.setSound(false); return; } player.muted = false; unmutedClips.add(url); liveBox._skipMorph = true; // the box is the viewer's now: no repaint rebuilds it muted if (player.paused) player.play().catch(() => {}); - button.remove(); // the player's own controls carry mute from here + // the player's own controls carry mute from here — unless they're off + // for the moments bar: then this button turns it down again + if (player._ownBar) button.setSound(true); else button.remove(); }); v.onvolumechange = () => { - if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected) btn.remove(); } // unmuted from the native control: same + const b = v.closest('.note-video-box')?.querySelector('.vid-sound'); + if (v._ownBar && b && b.setSound) b.setSound(!v.muted); + if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected && !v._ownBar) btn.remove(); } // unmuted from the native control: same else unmutedClips.delete(url); // muted again from the control: the next box starts quiet }; if (btn) box.append(btn); @@ -8221,6 +8714,7 @@ export function messagesFeature(ctx) { () => { box._inView = true; clearTimeout(box._release); + if (v._gone) return; // nothing to load, and nothing to ask if (!v.getAttribute('src') && !v.getAttribute('data-lazy-src')) { v.setAttribute('src', playSrc(url)); v.addEventListener('loadedmetadata', () => { v.style.minHeight = ''; }, { once: true }); @@ -8249,6 +8743,9 @@ export function messagesFeature(ctx) { try { v.removeAttribute('src'); v.load(); } catch {} }, 2500); }); + // known gone already (this player, or one before it, found the file + // missing): said at once, no request made + if (clipGone(url)) clipDead(v, url); return box; } @@ -8307,15 +8804,26 @@ export function messagesFeature(ctx) { if (!go) return; Promise.resolve(go.call(box, { navigationUI: 'hide' })).catch(() => {}); }; + // With a mouse the player's own buttons are all in reach, so its clicks + // are its own: the shield is off while a mouse is over the box (by-mouse; + // a finger on the same screen gets it back) and the middle button pauses, the + // corner one goes full screen and back (Adam, 2026-11-10: the pause button + // "didn't pause, it fullscreened. it should just pause"). A click on the + // still plays it where it is. const tapped = (e) => { if (e.target.closest('.yt-open, .vid-sound, iframe')) return; e.stopPropagation(); e.preventDefault(); const box = e.currentTarget; if (!box.dataset.playing) load(box, false); // a tap means sound else { post(box, 'unMute'); post(box, 'playVideo'); if (box._soundOn) box._soundOn(); } - full(box); + if (e.pointerType !== 'mouse') full(box); }; - const frame = h('div', { class: 'yt-embed', onClick: tapped }, + const byMouse = (e) => { e.currentTarget.classList.toggle('by-mouse', e.pointerType === 'mouse'); }; + const frame = h('div', { + class: 'yt-embed', onClick: tapped, + onPointerEnter: byMouse, onPointerMove: byMouse, onPointerDown: byMouse, + onPointerLeave: (e) => { e.currentTarget.classList.remove('by-mouse'); }, + }, h('img', { class: 'yt-poster', loading: 'lazy', alt: '', src: !feedPaint || feedMedia('https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg') @@ -8398,20 +8906,35 @@ export function messagesFeature(ctx) { } } } - // All at once, first answer wins: asked in turn, a note on a far relay - // took longer than the feed waits for a row. + // The near relays first — the reference's hints, ours, the author's own + // — and the wide net only once those have come back empty, or after a + // moment if they are slow about it (asked in turn, a note on a far relay + // took longer than the feed waits for a row; asked all at once, every + // quoted note and every reply's parent went to a dozen relays, and the + // Conversations view spent half its REQs — three hundred in twenty + // seconds — on notes our own relays answered in the first beat). + const WIDE_AFTER_MS = 1200; function findNote(ref) { const ask = (relays) => askNote(relays, ref.id); const near = [...new Set([...(ref.relays || []), ...zapRelays()])]; const authors = [...new Set([ref.author, ...(ref.authors || [])].filter(Boolean))]; - const asks = [ - askNote(near, ref.id, ref.relays || []), - ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))), - ask(WIDE_RELAYS.filter((r) => !near.includes(r))), - ]; return new Promise((resolve) => { - let left = asks.length; - for (const a of asks) a.then((ev) => { if (ev) resolve(ev); else if (--left === 0) resolve(null); }); + let done = false; + const found = (ev) => { if (!done && ev) { done = true; resolve(ev); } }; + const nearAsks = [ + askNote(near, ref.id, ref.relays || []), + ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))), + ]; + for (const a of nearAsks) a.then(found); + const nearDone = Promise.all(nearAsks); + let wide = null; + const goWide = () => { + if (done || wide) return; + wide = ask(WIDE_RELAYS.filter((r) => !near.includes(r))); + wide.then(found); + }; + const timer = setTimeout(goWide, WIDE_AFTER_MS); + nearDone.then(() => { clearTimeout(timer); if (done) return; goWide(); wide.then((ev) => { if (!done) { done = true; resolve(ev || null); } }); }); }); } function quotedNote(ref) { @@ -11077,6 +11600,10 @@ export function messagesFeature(ctx) { return null; } const hidden = (ev) => !!hiddenWhy(ev); + // A list row with nothing left to show: a post its author deleted, or a + // repost of one (a deletion that lands after the row did, too). Threads + // keep their deleted posts' places; lists just drop them. + const goneHere = (ev) => isDeleted(ev) || (ev.kind === REPOST && !!repostInner(ev) && isDeleted(repostInner(ev))); const hiddenPk = (pk) => !!pk && !isMe(pk) && (isMuted(pk) || (baselineMutes() || { set: new Set() }).set.has(pk) || isSpammer(pk) || reportsNow().mine.has(pk)); const visiblePks = (pks) => [...(pks || [])].filter((pk) => !hiddenPk(pk)); @@ -11106,10 +11633,14 @@ export function messagesFeature(ctx) { function noteReport(ev) { indexReport(reportsNow(), ev, myPubkeys().includes(ev.pubkey)); } function indexReport(r, ev, mine) { const hex = (v) => /^[0-9a-f]{64}$/.test(v || ''); + // every post named counts toward es, the 'other' ones too: a report + // of a post as 'other' with its author's p tag beside it used to have + // es = 0 and land on the AUTHOR as a person report let es = 0; for (const x of ev.tags || []) { - if (x[0] !== 'e' || !hex(x[1]) || x[2] === 'other') continue; + if (x[0] !== 'e' || !hex(x[1])) continue; es++; + if (x[2] === 'other') continue; (r.ev.get(x[1]) || r.ev.set(x[1], new Set()).get(x[1])).add(ev.pubkey); if (mine) r.mine.add(x[1]); } @@ -11123,10 +11654,16 @@ export function messagesFeature(ctx) { } } const REPORTS_MS = 30 * 60_000; + // The half hour counts from the last sync the device SAVED, so a reload + // (every open of the installed app is one) doesn't ask the outbox relays + // again — some forty REQs with every follow's reports — and a sync after + // one asks only for what came since. (The index is kept.) async function syncReports({ force = false } = {}) { if (!modOn('reports') || !myPubkeys().length) return; - if (!force && Date.now() - reportsAt < REPORTS_MS) return; + const last = Math.max(reportsAt, reportsNow().at || 0); + if (!force && Date.now() - last < REPORTS_MS) return; reportsAt = Date.now(); + const since = last ? { since: Math.floor(last / 1000) - 3600 } : {}; const authors = [...new Set([...myPubkeys(), ...followsNow().set])].slice(0, FEED_AUTHORS_MAX); const from = new Set(authors); try { @@ -11135,7 +11672,7 @@ export function messagesFeature(ctx) { const got = await Promise.all(plan.flatMap(({ relays, authors: a }) => { const chunks = []; for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS)); - return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500 }, 5000).catch(() => [])); + return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500, ...since }, 5000).catch(() => [])); })); for (const ev of got.flat()) if (ev && ev.kind === 1984 && from.has(ev.pubkey)) noteReport(ev); reportsNow().at = Date.now(); @@ -11775,8 +12312,16 @@ export function messagesFeature(ctx) { const rm = reacts.get(ev.id); const cached = ui.noteThread ? threadCache.get(ui.noteThread.rootId)?.counts?.[ev.id] : null; - const likeN = cached ? cached.likes : rm ? [...rm.values()].reduce((n, { who }) => n + visiblePks(who).length, 0) : 0; - const boostN = cached ? cached.boosts : visiblePks(boosts.get(ev.id) || new Set()).length; + // A thread remembers its counts between visits, so a short relay answer + // can't erase them — but what the relays HAVE brought since (a heart + // the feed's own count fetch tallied before this visit) outranks the + // memory: the number says what the who-panel below it lists. "Why does + // the count beside the heart say 1 if I have 2 heart reactions?" + // (Adam, 2026-11-02): the memory said 1, the tally in hand 2. + const liveLikes = rm ? [...rm.values()].reduce((n, { who }) => n + visiblePks(who).length, 0) : 0; + const liveBoosts = visiblePks(boosts.get(ev.id) || new Set()).length; + const likeN = cached ? Math.max(cached.likes || 0, liveLikes) : liveLikes; + const boostN = cached ? Math.max(cached.boosts || 0, liveBoosts) : liveBoosts; const btn = (icon, label, count, on, onClick, cls = '', extra = null) => h('button', { class: 'note-act' + (on ? ' on' : '') + (cls ? ' ' + cls : ''), title: label, 'aria-label': label, 'aria-disabled': onClick ? undefined : 'true', @@ -11792,7 +12337,7 @@ export function messagesFeature(ctx) { const z = zapTotals.get(ev.id) || zapSeeds().get(ev.id); const zp = pendingOf(ev.id); const optimistic = zp ? zp.sats : 0; - const zapSats = (cached ? cached.sats : z ? z.sats : 0) + optimistic; + const zapSats = Math.max(cached ? cached.sats || 0 : 0, z ? z.sats : 0) + optimistic; const zapFlying = !!zp && zp.flying; // tap pays the default amount; hold opens the screen that sets it const zapHold = canZap ? holdable((el) => openZapSettings(pk, ev.id, el.getBoundingClientRect()), @@ -11924,6 +12469,11 @@ export function messagesFeature(ctx) { const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null; const isReply = ev.kind === COMMENT || (!foreign && !zapRec && ev.tags.some((x) => x[0] === 'e')); const parent = context && !foreign && !zapRec ? parentRefOf(ev) : null; + // A NIP-22 comment on something that isn't an event — a web page (I + // tag: DaddyTube comments on a film's IMDb page) — has no post to show + // above it; the page it is on goes there instead, named by the + // comment's own alt tag when it carries one, else by the address. + const scope = ev.kind === COMMENT && !parent && !(ev.tags || []).some((x) => (x[0] === 'e' || x[0] === 'E') && x[1]) ? webScopeOf(ev) : null; const canZap = canZapPk(pk); // an optimistic post mid-publish: visible but not yet a real event — // dimmed, and no thread/reply/zap until its signed self takes over @@ -11966,6 +12516,9 @@ export function messagesFeature(ctx) { parent ? h('div', { class: 'note-context', 'data-context-of': parent.id }, h('div', { class: 'small faint note-context-label' }, '\u21a9 ' + t('noteReplyingTo')), quoteCard(parent, 0)) : null, + scope ? h('div', { class: 'note-context note-scope' }, + h('a', { class: 'small faint note-context-label', href: scope.url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() }, + '\u{1F4AC} ' + scope.label)) : null, h('div', { class: 'row between note-header', style: 'align-items:center;gap:8px' }, h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' }, h('span', { @@ -11975,7 +12528,7 @@ export function messagesFeature(ctx) { onClick: (e) => { e.stopPropagation(); openProfile(pk); }, }, name), h('span', { class: 'small faint', style: 'white-space:nowrap' }, - (isReply && !parent ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000) + (isReply && !parent && !scope ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000) + (edited ? ' · ' + t('postEditedTag') : ''))), pending ? null : h('button', { // the overflow stays up here; reply, boost, quote, react and zap @@ -11990,13 +12543,9 @@ export function messagesFeature(ctx) { zapText(ev) ? h('div', {}, ...noteBody(zapText(ev))) : null) : foreign ? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText) - : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))), - readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => { - e.stopPropagation(); - const m = readMoment(ev); - const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url); - if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); } - } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null, + : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, + momentLink(ev), + ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(momentStripped(ev, shownTranslation(ev)?.text ?? noteText(ev)), 0, noteEmojiTags(ev)))), foreign || pending ? null : translateLine(ev), ev.kind === POLL ? pollBox(ev) : null, pending ? null : noteActions(pk, ev, { canZap }), @@ -12257,12 +12806,24 @@ export function messagesFeature(ctx) { // at the top of the feed before any signing or relay round-trip (a remote // signer alone can take seconds), swapped for the signed event on success // and withdrawn on failure. + // the hashtags in the text as t tags (NIP-24), so topic feeds and other + // clients' searches find the post — up to the count past which a post + // reads as hashtag spam + const postHashtags = (text) => [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX); + function postTags(text, media, me) { + // NIP-92: what we uploaded, described, so clients needn't sniff the URL + const imeta = media.filter((m) => m && m.url).map(mediaImeta); + // NIP-18 quote: the reference is already in the text; the tags let a + // client render the quoted post inline and tell its author + const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8)) + ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : []; + // the people it mentions are told (NIP-27 p tags) + const mentions = mentionedPks(text).filter((pk) => pk !== me && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]); + return [...imeta, ...q, ...mentions, ...postHashtags(text).map((x) => ['t', x]), CLIENT_TAG]; + } async function publishPost(text, media = []) { const id = await requireIdentity(); - // the hashtags in the text as t tags (NIP-24), so topic feeds and other - // clients' searches find the post — up to the count past which a post - // reads as hashtag spam - const hashtags = [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX); + const hashtags = postHashtags(text); const temp = { id: 'pending:' + Math.random().toString(36).slice(2), pubkey: id.pubkey, content: text, created_at: Math.floor(Date.now() / 1000), @@ -12281,15 +12842,7 @@ export function messagesFeature(ctx) { for (const c of feeds) c.notes = [temp, ...c.notes]; render(); try { - // NIP-92: what we uploaded, described, so clients needn't sniff the URL - const imeta = media.filter((m) => m && m.url).map(mediaImeta); - // NIP-18 quote: the reference is already in the text; the tags let a - // client render the quoted post inline and tell its author - const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8)) - ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : []; - // the people it mentions are told (NIP-27 p tags) - const mentions = mentionedPks(text).filter((pk) => pk !== id.pubkey && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]); - const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [...imeta, ...q, ...mentions, ...temp.tags, CLIENT_TAG] }; + const partial = { kind: 1, content: text, created_at: temp.created_at, tags: postTags(text, media, id.pubkey) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])]; const ok = await publishOn(relays, evt); @@ -12305,6 +12858,93 @@ export function messagesFeature(ctx) { } } + // ---- scheduled posts ----------------------------------------------------- + // Signed now, with created_at = when it should appear, and handed to the + // notifier, which holds it and publishes it then (relays refuse notes from + // the future). Signing now means a remote signer is asked while you're + // here, and the note that goes out is exactly the one you wrote. This + // device remembers what it scheduled, to show and cancel; the post itself + // turns up in the feeds once it's out. + const SCHED_MIN_AHEAD = 120; + async function schedulePost(text, media, at) { + const id = await requireIdentity(); + if (!(at > Math.floor(Date.now() / 1000) + SCHED_MIN_AHEAD)) throw new Error(t('schedTooSoon')); + const partial = { kind: 1, content: text, created_at: at, tags: postTags(text, media, id.pubkey) }; + const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])]; + const res = await fetch(`${NOTIFIER}/schedule`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event: evt, relays }), + }).then((r) => r.json()).catch(() => ({ error: t('msgSendFailed') })); + if (!res.ok) throw new Error(res.error || t('msgSendFailed')); + ui.quoteOf = null; + const s = st(); + s.scheduled ||= {}; + s.scheduled[evt.id] = { at, text, pubkey: id.pubkey }; + save(s); + render(); + return evt; + } + // what this account has waiting, soonest first; anything past its time + // has gone out (or the notifier gave up) and leaves the list + function scheduledPosts() { + const s = st(), me = mePk(), now = Math.floor(Date.now() / 1000); + let dropped = false; + for (const [k, v] of Object.entries(s.scheduled || {})) if (v.at < now - 60) { delete s.scheduled[k]; dropped = true; } + if (dropped) save(s); + return Object.entries(s.scheduled || {}).filter(([, v]) => v.pubkey === me) + .map(([id, v]) => ({ id, ...v })).sort((a, b) => a.at - b.at); + } + // once a session, ask which are still held: one cancelled on another + // device is gone from the notifier, so it goes from here too + let schedChecked = false; + function checkScheduled(list) { + if (schedChecked || !list.length) return; + schedChecked = true; + fetch(`${NOTIFIER}/schedule/status?ids=${list.map((x) => x.id).join(',')}`).then((r) => r.json()).then((st_) => { + const s = st(); + let n = 0; + for (const [k, v] of Object.entries(st_ || {})) if (v === 'gone' && s.scheduled?.[k]) { delete s.scheduled[k]; n++; } + if (n) { save(s); render(); } + }).catch(() => {}); + } + async function cancelScheduled(item) { + const id = await requireIdentity(); + const partial = { kind: 5, content: '', created_at: Math.floor(Date.now() / 1000), tags: [['e', item.id]] }; + const del = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); + const res = await fetch(`${NOTIFIER}/schedule/cancel`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ event: del }), + }).then((r) => r.json()).catch(() => ({})); + if (!res.ok) throw new Error(res.error || t('msgSendFailed')); + const s = st(); + delete s.scheduled?.[item.id]; + save(s); + render(); + } + const schedWhen = (at) => new Date(at * 1000).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + // <input type=datetime-local> speaks local wall-clock time + const localInput = (ms) => { const d = new Date(ms); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); }; + function scheduledList() { + const list = scheduledPosts(); + checkScheduled(list); + if (!list.length) return null; + return h('div', { class: 'card col sched-list', 'data-key': 'sched-list', style: 'gap:8px' }, + h('div', { class: 'small muted' }, t('schedListTitle', { n: list.length })), + ...list.map((x) => h('div', { class: 'row gap6 sched-item', 'data-key': 'sched:' + x.id, style: 'align-items:flex-start' }, + h('div', { class: 'col grow', style: 'min-width:0;gap:2px' }, + h('div', { class: 'small', style: 'font-weight:600' }, '\u{1F552} ' + schedWhen(x.at)), + h('div', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, x.text)), + h('button', { + class: 'btn-ghost btn-sm', disabled: ui.schedCancelling === x.id, + onClick: async () => { + ui.schedCancelling = x.id; render(); + try { await cancelScheduled(x); toast(t('schedCancelled')); } + catch (e) { if (!e.silent) toast(e.message || String(e)); } + ui.schedCancelling = null; render(); + }, + }, ui.schedCancelling === x.id ? h('span', { class: 'spinner sm' }) : t('cancel'))))); + } + async function publishReply(c, s, text) { const id = await requireIdentity(); const target = c.replies.find((e) => e.id === s.focusId) || c.root || s.seed; @@ -12447,7 +13087,9 @@ export function messagesFeature(ctx) { onKeydown: (e) => { mentionKey(e); if (e.defaultPrevented) return; - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; } + // a phone's Return is a new line (Reply is the button); a + // keyboard's Enter sends + if (e.key === 'Enter' && !e.shiftKey && !matchMedia('(pointer: coarse)').matches) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; } if (e.ctrlKey && (e.key === 'j' || e.key === 'J')) { e.preventDefault(); const el = e.target; @@ -12488,7 +13130,19 @@ export function messagesFeature(ctx) { previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }), h('button', { class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button', - onClick: () => { s.replying = false; s.preview = false; s.mediaMoment = null; render(); }, + // Cancel discards: the typed text, its attachments and the box. + // (It only folded the `replying` flag before, and the box stays + // up while a draft has text — so with anything typed it did + // nothing visible.) The field may still be focused, and the + // morph leaves a focused field's value alone: clear it by hand. + onClick: () => { + const text = s.draft || ''; + s.draft = ''; s.replying = false; s.preview = false; s.mediaMoment = null; + ui.postMedia = (ui.postMedia || []).filter((m) => !text.includes(m.url)); + const inp = document.querySelector('.thread-reply-input'); + if (inp) inp.value = ''; + render(); + }, }, t('cancel'))), h('button', { class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading, @@ -12597,9 +13251,24 @@ export function messagesFeature(ctx) { let kind0Chain = Promise.resolve(); let kind0Last = null; // { at, content } — the newest kind 0 we published const withKind0Lock = (fn) => { const p = kind0Chain.then(fn, fn); kind0Chain = p.catch(() => {}); return p; }; + // A kind 0 is replaceable too: one built on a profile we failed to find + // wipes the real one wherever it lands. 2026-10-30, a long-time nostr + // user signed in with his key for the first time; our relays had never + // seen his kind 0 (it lived on ditto, snort and oxtr), the lookup below + // came back empty, and the payment-address publish went out as + // {lud16, nip05} alone — name, picture and banner gone everywhere. So an + // empty quick lookup widens to the same search the follow list gets + // (indexes, archives, his own write relays, Primal's signed copy) before + // anything is written. Once per key per session: a key with no profile + // anywhere is new, and its first publish shouldn't wait twice. + const kind0DeepMiss = new Set(); async function newestKind0(pk) { const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3000); let newest = evs.sort((a, b) => b.created_at - a.created_at)[0] || null; + if (!newest && !(kind0Last && kind0Last.pk === pk) && !kind0DeepMiss.has(pk)) { + try { newest = (await deepContactList(pk)).profile || null; } catch {} + if (!newest) kind0DeepMiss.add(pk); + } if (kind0Last && kind0Last.pk === pk && (!newest || newest.created_at <= kind0Last.at)) newest = kind0Last; let base = {}; try { base = newest ? JSON.parse(newest.content) : {}; } catch {} @@ -12626,6 +13295,8 @@ export function messagesFeature(ctx) { if (opts.fieldWhen && opts.fieldWhen[k] && !opts.fieldWhen[k](base)) continue; merged[k] = v; } + // fields to take out altogether (an address that no longer exists) + for (const k of opts.remove || []) if (!opts.fieldWhen || !opts.fieldWhen[k] || opts.fieldWhen[k](base)) delete merged[k]; if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name; if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; @@ -12679,8 +13350,15 @@ export function messagesFeature(ctx) { // when empty; move them when they pointed at our (old) address. A // deliberately foreign lud16/nip05 is the user's business. if (addr) { - for (const k of ['lud16', 'nip05']) - if (!merged[k] || merged[k] === oldAddr) merged[k] = addr; + const domain = addr.split('@')[1]; + for (const k of ['lud16', 'nip05']) { + const cur = merged[k]; + if (!cur || cur === oldAddr) { merged[k] = addr; continue; } + // a name at our own domain that is not ours and no longer answers + // (the placeholder a migration released) is ours now — Save is the + // heal an interactive signer can't do in the background + if (typeof cur === 'string' && cur !== addr && cur.split('@')[1] === domain && !/^_@/.test(cur) && await addressDead(cur)) merged[k] = addr; + } } if (merged.name) merged.display_name = merged.name; const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; @@ -12691,6 +13369,78 @@ export function messagesFeature(ctx) { fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null }); } + // ---- profile history: undo a kind 0 that replaced the real one --------- + // Relays holding the newest copy dropped the older ones; the archives + // often haven't. An older copy fills the editor's EMPTY fields (name, + // bio, picture, banner) — nothing typed or already set is touched, and + // nothing is published until Save. + const PROFILE_HISTORY_FIELDS = ['name', 'about', 'picture', 'banner']; + async function findProfileHistory() { + const me = mePk(); + if (!me) return []; + const relays = [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...PROFILE_ARCHIVE_RELAYS, ...FOLLOW_HISTORY_RELAYS, + ...(await relaysOf(me).catch(() => []))])]; + const evs = await queryOn(relays, { kinds: [0], authors: [me], limit: 50 }, 8000).catch(() => []); + const byId = new Map(); + for (const e of evs || []) if (e.pubkey === me) byId.set(e.id, e); + return [...byId.values()].sort((a, b) => b.created_at - a.created_at); + } + const profileFieldsOf = (e) => { + let c = {}; + try { c = JSON.parse(e.content) || {}; } catch {} + const out = {}; + for (const k of PROFILE_HISTORY_FIELDS) { + const v = k === 'name' ? (c.display_name || c.name) : c[k]; + if (typeof v === 'string' && v.trim()) out[k] = v.trim(); + } + return out; + }; + // what an older copy has that the draft is missing + const profileHistoryExtra = (e, draft) => Object.keys(profileFieldsOf(e)).filter((k) => !(draft && String(draft[k] || '').trim())); + function fillProfileFrom(e) { + const draft = ui.profEdit; + if (!draft) return 0; + const old = profileFieldsOf(e); + let n = 0; + for (const k of profileHistoryExtra(e, draft)) { draft[k] = old[k]; (draft.touched ||= {})[k] = true; n++; } + if (n) { + if (old.picture && draft.picture === old.picture) preloadPicture({ picture: old.picture }); + toast(t('profHistFilled', { n })); + } else toast(t('profHistNothing')); + if (ui.profHist) ui.profHist.done = true; + render(); + return n; + } + function profileHistoryCard() { + const draft = ui.profEdit; + if (!draft || !mePk()) return null; + const ph = ui.profHist; + const look = () => { + ui.profHist = { loading: true }; + render(); + findProfileHistory().then((vs) => { ui.profHist = { versions: vs }; render(); }) + .catch(() => { ui.profHist = { versions: [] }; render(); }); + }; + const versions = (ph && ph.versions) || []; + const offers = versions.map((e) => ({ e, extra: profileHistoryExtra(e, draft) })).filter((v) => v.extra.length); + return h('div', { class: 'col prof-hist-card', style: 'gap:8px' }, + h('p', { class: 'small muted', style: 'margin:0' }, t('profHistDesc')), + !ph || ph.done + ? h('button', { class: 'btn-sm', type: 'button', style: 'align-self:flex-start', onClick: look }, t('profHistLook')) + : ph.loading + ? h('div', { class: 'row', style: 'justify-content:center;padding:6px' }, h('span', { class: 'spinner sm' })) + : offers.length + ? h('div', { class: 'col', style: 'gap:8px' }, ...offers.slice(0, 6).map(({ e, extra }) => { + const f = profileFieldsOf(e); + return h('div', { class: 'row between gap6', style: 'align-items:center' }, + h('div', { class: 'col', style: 'gap:1px;min-width:0' }, + h('div', { class: 'break' }, f.name || t('profHistNoName')), + h('div', { class: 'small faint' }, new Date(e.created_at * 1000).toLocaleDateString() + + ' · ' + extra.map((k) => t('profHist_' + k)).join(', '))), + h('button', { class: 'btn-sm', type: 'button', onClick: () => fillProfileFrom(e) }, t('profHistUse'))); + })) + : h('div', { class: 'small faint' }, t('profHistNone'))); + } // A payment-address rename released the old name: repoint the kind 0's // lud16 and nip05 — but only where they pointed at the released address // (or were empty). Deliberately different values are not ours to touch. @@ -12708,6 +13458,11 @@ export function messagesFeature(ctx) { // alone. Once per address per session. const addrHealed = new Set(); async function healStaleAddress(olds, myAddr) { + // Amber by intent needs a tap per signature: a background publish would + // sit in the kind-0 queue forever and block every later one. Save in the + // profile editor repoints the address for these users instead. + const id = await identity().catch(() => null); + if (!id || (id.signer && id.signer.interactive)) return; for (const old of olds) { if (addrHealed.has(old)) continue; addrHealed.add(old); @@ -12869,7 +13624,10 @@ export function messagesFeature(ctx) { if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); } const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null; const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null; - const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null; + // the lightning line too: a stale same-domain lud16 (jar@ after the + // rename to jarryd@) showed under the right NIP-05 as a second address + const rawLud = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null; + const lud16 = rawLud ? (stale(rawLud) ? myAddr : rawLud) : null; const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05; // an about of "~" or a lone character is noise, not a bio. // Until the full kind 0 lands, the light profile cache answers — it was @@ -12895,7 +13653,8 @@ export function messagesFeature(ctx) { // doesn't drop by the height of a cover photo when it arrives : urlish(lp.banner); const draftPic = draft && urlish(draft.picture); - if (bannerUrl && !draft && !mediaReady.has(bannerUrl)) warmBanner(pk); + const bannerShown = bannerUrl && !draft ? blobAltOf(bannerUrl) || bannerUrl : bannerUrl; + if (bannerUrl && !draft && !mediaReady.has(bannerShown)) warmBanner(pk); return h('div', { class: 'col', style: 'gap:16px' }, // full header on profiles too — losing the search button here made // finding the NEXT person a trek back home @@ -12904,7 +13663,7 @@ export function messagesFeature(ctx) { t(full || lp.eventAt || lp.name || lp.picture ? 'profFetchFailed' : 'profNotFound'), h('button', { class: 'btn-sm', onClick: () => { fetchFullProfile(pk); render(); } }, t('retry'))) : null, h('div', { class: 'card col', style: 'gap:12px' }, - bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(draft ? bannerUrl : mediaSrc(bannerUrl, mediaReady.get(bannerUrl)))})` }) : null, + bannerUrl ? h('div', { class: 'profile-banner', style: `background-image:url(${JSON.stringify(draft ? bannerUrl : mediaSrc(bannerShown, mediaReady.get(bannerShown)))})` }) : null, h('div', { class: 'row gap6', style: 'align-items:center' }, draftPic ? h('div', { class: 'chat-avatar profile-avatar ava-img', style: `background-image:url(${JSON.stringify(draftPic)})` }) @@ -13012,6 +13771,7 @@ export function messagesFeature(ctx) { ui.profUploading = false; render(); }, })), + profileHistoryCard(), h('button', { class: 'btn-primary btn-block', disabled: ui.profSaving, onClick: saveProfile }, ui.profSaving ? h('span', { class: 'spinner sm' }) : t('save')), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profEdit = null; ui.profEditFilled = false; render(); } }, t('cancel'))) @@ -13025,6 +13785,7 @@ export function messagesFeature(ctx) { // the half-written post back, composer open. Posting clears // it; Cancel just closes the composer and keeps the text. postComposer(), + ui.profCompose == null ? scheduledList() : null, // the non-destructive way out: add another identity (Nostr, // passkey, Google) next to this one and switch between them // on the Accounts screen — logout stays for actually leaving @@ -13113,7 +13874,8 @@ export function messagesFeature(ctx) { // Streams joins the tabs once they've streamed const streamed = pastOf(pk).length > 0; const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk); - const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e)); + // a post its author deleted leaves the list (no "was deleted" row) + const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e) && !goneHere(e)); // a tab that holds only a few of the pages fetched so far (someone // who mostly replies, or mostly posts) asks for older ones by itself: // too short to scroll means the scroll that loads more never comes @@ -13146,7 +13908,7 @@ export function messagesFeature(ctx) { return h('div', { class: 'col', style: 'gap:8px' }, tabs, h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab }, - ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))), + ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e) && !goneHere(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))), c.loadingMore ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' }, h('span', { class: 'spinner sm' })) @@ -13204,6 +13966,7 @@ export function messagesFeature(ctx) { ui.profCompose = null; ui.postMedia = []; ui.postPreview = false; + ui.postSchedule = null; discardDraft(POST_DRAFT); render(); } @@ -13299,11 +14062,34 @@ export function messagesFeature(ctx) { mentionList('.post-input'), ui.postPreview ? draftPreview(text) : null, mediaBar(), + ui.postSchedule != null ? h('label', { class: 'row gap6 sched-pick', style: 'align-items:center' }, + h('span', { class: 'small muted' }, t('schedAt')), + h('input', { + type: 'datetime-local', class: 'grow', value: ui.postSchedule, + min: localInput(Date.now() + SCHED_MIN_AHEAD * 1000), max: localInput(Date.now() + 90 * 86400_000), + onInput: (ev) => { ui.postSchedule = ev.target.value; }, + })) : null, h('div', { class: 'row gap6 wrap post-actions' }, - h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => { + h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading || !!ui.postScheduling, onClick: async () => { const body = composeText().trim(); if (!body) return; const media = (ui.postMedia || []).filter((m) => body.includes(m.url)); + if (ui.postSchedule != null) { + // nothing goes on screen yet, so the composer stays open until + // the notifier has it (a signer may ask first) + const at = Math.floor(new Date(ui.postSchedule).getTime() / 1000); + ui.postScheduling = true; render(); + try { + await schedulePost(body, media, at); + ui.profCompose = null; ui.postMedia = []; ui.postSchedule = null; + setDraft(POST_DRAFT, ''); + const inp = document.querySelector('.post-input'); + if (inp) inp.value = ''; + toast(t('schedDone', { when: schedWhen(at) })); + } catch (e) { if (!e.silent) toast(e.message || String(e)); } + ui.postScheduling = false; render(); + return; + } // the post is on screen instantly (publishPost is optimistic) — // close the composer now; a failure reopens it with the text intact ui.profCompose = null; @@ -13317,14 +14103,21 @@ export function messagesFeature(ctx) { if (!e.silent) toast(e.message || String(e)); render(); } - } }, t('profPostBtn')), + } }, ui.postScheduling ? h('span', { class: 'spinner sm' }) : t(ui.postSchedule != null ? 'schedBtn' : 'profPostBtn')), h('input', { type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none', onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); }, }), h('div', { class: 'row gap6 composer-options' }, previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }), + h('button', { + type: 'button', class: 'btn-ghost btn-sm', title: t('schedToggle'), 'aria-label': t('schedToggle'), + 'aria-pressed': String(ui.postSchedule != null), + // an hour from now, on the next quarter hour + onClick: () => { ui.postSchedule = ui.postSchedule != null ? null : localInput(Math.ceil((Date.now() + 3600_000) / 900_000) * 900_000); render(); }, + }, '\u{1F552}'), h('button', { class: 'btn-ghost btn-sm', onClick: requestCancelPost }, t('cancel')))), + scheduledList(), postCancelWarning(), gifs.sheet('post', (g) => { const cur = composeText().replace(/\s+$/, ''); @@ -13356,10 +14149,13 @@ export function messagesFeature(ctx) { function feedChips() { const items = feedList(); const cur = feedDef(); - if (cur && adhocFeeds.has(cur.id)) items.push(cur); + // (a wallet restoring behind /feed is on the visitor's copy of Popular + // for a beat: the built-in chip stands for it, not a second Popular) + const onVisitorCopy = curFeedId === 'all:visitor'; + if (cur && adhocFeeds.has(cur.id) && !onVisitorCopy) items.push(cur); return h('div', { class: 'row feed-chips', 'data-key': 'feed-chips' }, ...items.map((f) => h('button', { - class: 'feed-chip' + (f.id === curFeedId ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id, + class: 'feed-chip' + (f.id === curFeedId || (onVisitorCopy && f.id === EVERYTHING) ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id, onClick: () => switchFeed(f.id), // someone's timeline carries the person, not a name — an empty // label left a blank black pill @@ -13438,7 +14234,7 @@ export function messagesFeature(ctx) { }, 0); } function appBottomNav() { - const visitor = isVisitor(); + const visitor = visitorView(); const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null; // a stream (or Go live) over the shell is a place of its own: no button // is "here", so the one tapped goes there (Feed under a stream thought it @@ -15920,6 +16716,9 @@ export function messagesFeature(ctx) { window.addEventListener('popstate', () => setTimeout(syncMediaPager, 0)); } + // what the feed floats over the page (post button, new-posts pill): the + // shell takes them each render, see pageFloaters in the hooks + let pageFloaters = []; function feedView() { syncFollowSets().catch(() => {}); // throttled inside syncReports().catch(() => {}); // likewise @@ -15929,7 +16728,7 @@ export function messagesFeature(ctx) { const c = feedNow(); c.opened = true; const def = feedDef(); - const visitor = isVisitor(); + const visitor = visitorView(); queueMicrotask(() => { syncMediaPager(); if (listView()) watchSeen(); @@ -15949,7 +16748,7 @@ export function messagesFeature(ctx) { // (a reply whose post every lookup came back without isn't shown: a // "Couldn't find that note" over it told nobody anything — Adam, 2026-10-20) const repliesHere = feedReplies(c); - const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev)); + const shownHere = (ev) => !hidden(ev) && !goneHere(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev)); const visible = c.notes.filter(shownHere); // Every child keyed by its post, hairlines included, so the morph // reconciles the list by post: a new one at the top is inserted as its @@ -15984,9 +16783,28 @@ export function messagesFeature(ctx) { }, '↑ ', h('span', { class: 'n', 'data-key': 'n:' + waiting }, String(waiting)), ' ', waiting === 1 ? t('feedOneNewWord') : t('feedNNewWord')) : null; + // The post button floats bottom right wherever the reader is in the + // feed; the composer opens at the top and takes the focus. It and the + // pill are position: fixed — and a page sliding in has a transform on + // it, which makes fixed descendants position within the page instead: + // the button rode at the foot of the feed card, below the screen, until + // the entrance animation ended ("not seeing the Post button right away", + // Adam, 2026-11-02; measured on his phone: 4109 px down at 87 ms, in + // place at 562 ms). So they're handed to the shell, which paints them + // beside the page, outside anything that animates. + const fab = visitor || ui.profCompose != null ? null : h('button', { + class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'), + onClick: () => { + ui.profCompose = draftFor(POST_DRAFT) || ''; + render(); + try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); } + setTimeout(() => document.querySelector('.post-input')?.focus(), 60); + }, + }, h('span', { class: 'feed-compose-icon', 'aria-hidden': 'true', html: + '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path stroke="currentColor" d="M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"/><path stroke="currentColor" opacity=".5" d="M8 17h5"/><path class="compose-pen-fill" d="m16.5 3.5 3 3-8 8-4 1 1-4Z"/><path class="compose-pen" d="M16.5 3.5a2.1 2.1 0 0 1 3 3l-8 8-4 1 1-4Z"/><path class="compose-pen" d="m14.5 5.5 3 3"/></svg>' }), h('span', {}, t('feedPostFab'))); + pageFloaters = [pill, fab].filter(Boolean); // the chat shell draws the brand header; this is just the page under it return h('div', { class: 'card col chat-page feed-page', style: 'gap:10px' }, - h('div', { 'data-key': 'feed-notice', style: 'display:contents' }, pill), h('div', { class: 'row gap6', style: 'align-items:center' }, backBtn(() => { stopFeedWatch(); @@ -16009,18 +16827,6 @@ export function messagesFeature(ctx) { ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), (refreshLive(), mediaModes()), - // floating bottom right, wherever the reader is in the feed: the - // composer opens at the top and takes the focus - visitor || ui.profCompose != null ? null : h('button', { - class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'), - onClick: () => { - ui.profCompose = draftFor(POST_DRAFT) || ''; - render(); - try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); } - setTimeout(() => document.querySelector('.post-input')?.focus(), 60); - }, - }, h('span', { class: 'feed-compose-icon', 'aria-hidden': 'true', html: - '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path stroke="currentColor" d="M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"/><path stroke="currentColor" opacity=".5" d="M8 17h5"/><path class="compose-pen-fill" d="m16.5 3.5 3 3-8 8-4 1 1-4Z"/><path class="compose-pen" d="M16.5 3.5a2.1 2.1 0 0 1 3 3l-8 8-4 1 1-4Z"/><path class="compose-pen" d="m14.5 5.5 3 3"/></svg>' }), h('span', {}, t('feedPostFab'))), visitor ? null : postComposer(), def.of && !ofLoaded(def) ? h('div', { class: 'row gap6', style: 'justify-content:center;align-items:center;padding:12px 0' }, @@ -16300,7 +17106,16 @@ export function messagesFeature(ctx) { type: 'text', class: 'user-search-input user-search-main', placeholder: t('searchUsersHint'), value: s.q, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', onInput: (e) => { s.q = e.target.value; userSearcher.update(s.q); }, + onKeydown: (e) => { if (e.key === 'Enter' && !e.isComposing && s.q.trim()) { e.preventDefault(); openTextSearch(s.q); render(); } }, }), + // the words themselves: every note and reply that says them + s.q.trim().length >= 2 && !/^(npub1|nprofile1|nevent1|note1|naddr1|nostr:)/i.test(s.q.trim()) + ? h('div', { class: 'list' }, h('div', { class: 'item chat-thread-row', onClick: () => { openTextSearch(s.q); render(); } }, + h('div', { class: 'chat-avatar fallback' }, '\u{1F50D}'), + h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, + h('span', { class: 'chat-name' }, '\u201c' + s.q.trim() + '\u201d'), + h('div', { class: 'muted small' }, t('searchPostsRow'))))) + : null, // a word is a topic too: posts tagged with it, as a feed (() => { const m = /^#?([A-Za-z0-9_\u00C0-\uFFFF]{2,64})$/.exec(s.q.trim()); @@ -16709,6 +17524,7 @@ export function messagesFeature(ctx) { // login is asked when it first opens a Cordn group or invites someone). if (ui.msgView !== 'grp' && cd) cd.focus(null); cordnChat().then((c) => { if (c && !cdReadied) { cdReadied = true; c.ready(); } }); + nip29Chat(); // Group chats ride along: the client loads with the wallet, and standing // on this screen is what makes us invitable (a KeyPackage on the relays). whiteNoise().then(async (c) => { @@ -17226,7 +18042,14 @@ export function messagesFeature(ctx) { h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, h('span', { class: 'chat-name' }, displayName(r.pk)), r.pk === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null), - h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk)))))); + h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk))))), + ...(canModerate(room) && room.folded && room.folded.banned.size ? [ + h('div', { class: 'small muted chat-removed-head' }, t('msgBannedHead')), + ...[...room.folded.banned].map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) }, + h('span', { class: 'ava-wrap' }, avatar(pk)), + h('span', { class: 'chat-name grow', style: 'min-width:0' }, displayName(pk)), + h('button', { class: 'btn-sm', onClick: (e) => { e.stopPropagation(); setBanned(room, pk, false); } }, t('msgUnban')))), + ] : [])); } function invitePanel(room) { @@ -17350,7 +18173,42 @@ export function messagesFeature(ctx) { // which protocol the group speaks: White Noise (Marmot) or Cordn h('div', { class: 'seg' }, h('button', { class: proto === 'wn' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'wn'; render(); } }, t('msgGroupProtoWn')), - h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd'))), + h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd')), + h('button', { class: proto === 'n29' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'n29'; render(); } }, t('msgGroupProtoN29'))), + proto === 'n29' ? nip29JoinPanel() : null, + proto === 'n29' ? null : wnNewGroupForm(proto, picks, ready)); + } + // Join a NIP-29 group by its address (they're made in other clients). + async function nip29Join() { + const raw = (ui.msgN29Addr || '').trim(); + if (!raw || ui.msgBusy) return; + const m = await import('../nip29-client.js'); + const a = m.parseGroupAddress(raw); + if (!a) { toast(t('msgNip29BadAddress')); return; } + ui.msgBusy = true; render(); + try { + const c = await nip29Chat(); + if (!c) throw new Error('no signer'); + const g = await c.join(a); + ui.msgN29Addr = ''; ui.msgHomePanel = null; + ui.msgView = 'grp'; ui.msgPeer = g.id; ui.msgStick = true; + if (g.joinRefused) toast(t('msgNip29JoinRefused', { why: g.joinRefused })); + } catch (e) { wnFail(e); } finally { ui.msgBusy = false; render(); } + } + function nip29JoinPanel() { + return h('div', { class: 'col gap6' }, + h('div', { class: 'small muted' }, t('msgGroupProtoN29Hint')), + h('div', { class: 'row gap6' }, + h('input', { + class: 'grow nip29-address', type: 'text', placeholder: t('msgNip29Address'), autocapitalize: 'none', spellcheck: 'false', + value: ui.msgN29Addr || '', onInput: (e) => { ui.msgN29Addr = e.target.value; }, + onKeydown: (e) => { if (e.key === 'Enter') nip29Join(); }, + }), + h('button', { class: 'btn-primary btn-sm', disabled: ui.msgBusy, onClick: nip29Join }, + ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgJoin')))); + } + function wnNewGroupForm(proto, picks, ready) { + return h('div', { class: 'col gap6' }, proto === 'cd' ? h('div', { class: 'small muted' }, t('msgGroupProtoCdHint')) : null, h('input', { type: 'text', placeholder: t('msgGroupName'), maxlength: '64', @@ -17369,7 +18227,11 @@ export function messagesFeature(ctx) { const accounts = wn.accounts(g); const picks = ui.msgGroupAdd || (ui.msgGroupAdd = []); return h('div', { class: 'col chat-members' }, - h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')), + h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, g.nip29 ? t('msgNip29Info', { relay: wn.groupRef(g) }) : t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')), + g.nip29 && v.about ? h('div', { class: 'small', style: 'padding:2px 12px 6px' }, v.about) : null, + g.nip29 ? h('div', { class: 'row gap6', style: 'padding:0 12px 6px' }, h('button', { class: 'btn-ghost btn-sm', onClick: async () => { + try { await navigator.clipboard.writeText(wn.groupRef(g)); toast(t('copied')); } catch {} + } }, t('msgNip29CopyAddress'))) : null, ...accounts.map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) }, avatar(pk), h('div', { class: 'row gap6 grow', style: 'align-items:center;min-width:0' }, @@ -17429,7 +18291,7 @@ export function messagesFeature(ctx) { !ui.emojiPick && m.mine && !g.removed ? item('×', t('msgDelete'), async () => { close(); const del = wn.event(5, '', [['e', m.rumor.id], ['k', '9']]); - if (await wn.send(g, del).catch(() => false)) (g.cordn ? onCd : onWn)('message', g, del); + if (await wn.send(g, del).catch(() => false)) gOn(g)('message', g, del); }) : null)); } @@ -17437,7 +18299,7 @@ export function messagesFeature(ctx) { const g = findGroup(ui.msgPeer); if (!g) { // the groups are still loading (a reload straight into this screen) - if (!wn || wnLoading || !cd || cdLoading) { Promise.all([whiteNoise(), cordnChat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); } + if (!wn || wnLoading || !cd || cdLoading || !ng || ngLoading) { Promise.all([whiteNoise(), cordnChat(), nip29Chat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); } ui.msgView = 'home'; return homeView(); } @@ -17546,7 +18408,7 @@ export function messagesFeature(ctx) { h('span', { class: 'small muted chat-quote-text' }, ...snippetNodes(reply.rumor))), h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')) : null, live - ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn ? null : (f) => wnSendFile(g, f)) + ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn || g.nip29 ? null : (f) => wnSendFile(g, f)) : h('div', { class: 'muted small', style: 'text-align:center;padding:12px' }, t('msgGroupGone')), ui.msgSheet ? wnSheet(g) : null); } @@ -17835,9 +18697,11 @@ export function messagesFeature(ctx) { if (ZAP_KINDS.includes(ev.kind)) { const actor = zapperOf(ev), sats = receiptSats(ev); if (!actor || my.includes(actor) || !sats) return null; - // a zap on a stream names it by address (a 30311), not by an e tag - const stream = !lastE ? ((ev.tags || []).find((x) => x[0] === 'a' && String(x[1] || '').startsWith(LIVE_KIND + ':')) || [])[1] || null : null; - return { id: ev.id, what: 'zap', actor, target: lastE, stream, hint, sats, text: zapText(ev), ts: ev.created_at }; + // a zap on a stream or an app names it by address, not by an e tag + const addr = !lastE ? zapAddrOf(ev) : null; + const stream = addr && addr.startsWith(LIVE_KIND + ':') ? addr : null; + const app = addr && addr.startsWith(APP_KIND + ':') ? addr : null; + return { id: ev.id, what: 'zap', actor, target: lastE, stream, ...(app ? { app } : {}), hint, sats, text: zapText(ev), ts: ev.created_at }; } if (my.includes(ev.pubkey)) return null; if (ev.kind === 7) { @@ -17874,6 +18738,12 @@ export function messagesFeature(ctx) { const old = c.items.find((y) => y.id === x.id); if (old && !old.emojiUrl) { old.emojiUrl = x.emojiUrl; old.emoji = x.emoji; healed = true; } } + // ...and a zap on an app saved before rows knew one + for (const x of items) { + if (!x || !x.app || !known.has(x.id)) continue; + const old = c.items.find((y) => y.id === x.id); + if (old && !old.app) { old.app = x.app; healed = true; } + } const add = items.filter((x) => x && !hiddenPk(x.actor) && !known.has(x.id) && known.add(x.id)); if (!add.length) { if (healed) persistNotifs(); return healed; } warmNotifFaces(add); @@ -17991,7 +18861,32 @@ export function messagesFeature(ctx) { return ref && ref.type === 'pubkey' ? '@' + displayName(ref.pk) : m.slice(6, 18) + '\u2026'; }).replace(/\s+/g, ' ').trim(); } + // Zapstore app listings (NIP-82 kind 32267): a zap on one is a zap on your + // app. Its name is asked of Zapstore's relay once per session. + const appIdOf = (addr) => String(addr || '').split(':').slice(2).join(':'); + const appNames = new Map(); // app id -> name | null (asking / none) + function appName(addr) { + const id = appIdOf(addr); + if (!id) return null; + if (!appNames.has(id)) { + appNames.set(id, null); + queryOn(APP_RELAYS, { kinds: [APP_KIND], '#d': [id], limit: 5 }, 5000).then((evs) => { + const ev = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; + const name = ev && tagOf(ev, 'name'); + if (name) { appNames.set(id, String(name).slice(0, 60)); scheduleRepaint(); } + }).catch(() => {}); + } + return appNames.get(id); + } + const openApp = (addr) => { + const url = 'https://zapstore.dev/apps/' + encodeURIComponent(appIdOf(addr)); + if (ctx.openExternal) ctx.openExternal(url); else window.open(url, '_blank', 'noopener'); + }; function notifLabel(x) { + if (x.what === 'zap' && x.app) { + const name = appName(x.app); + return name ? t('alertZapApp', { name, sats: fmtSats(x.sats) + ' sats' }) : t('alertZapAppAnon', { sats: fmtSats(x.sats) + ' sats' }); + } if (x.what === 'zap') return t('alertZap', { sats: fmtSats(x.sats) + ' sats' }); if (x.what === 'react') { const code = shortcodeOf(x.emoji); @@ -18029,6 +18924,20 @@ export function messagesFeature(ctx) { // a zap on a stream opens the stream; one on your profile (no post // at all) opens the zapper's — the tap used to do nothing else if (x.stream) { const [, pk, ...d] = x.stream.split(':'); stopNotifWatch(); openLiveRef({ pk, d: d.join(':') }).catch(() => {}); } + else if (x.app) openApp(x.app); + // a row saved before rows read the zap request's address: look at + // the receipt once, then go where it says + else if (x.what === 'zap' && !x.addrChecked) { + x.addrChecked = true; + queryOn(zapRelays(), { ids: [x.id] }, 4500).catch(() => []).then((evs) => { + const addr = evs && evs[0] ? zapAddrOf(evs[0]) : null; + if (addr && addr.startsWith(APP_KIND + ':')) x.app = addr; + else if (addr && addr.startsWith(LIVE_KIND + ':')) x.stream = addr; + persistNotifs(); + render(); + open(); + }); + } else { stopNotifWatch(); openProfile(x.actor); } } }; @@ -18124,10 +19033,14 @@ export function messagesFeature(ctx) { resumed(awayMs) { // a blink between apps didn't kill anything if (awayMs && awayMs < 5_000) return; - resubscribeStreams(); // DMs and rooms, whether or not chat is on screen - if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return true; - if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {}); - else refreshFeed({ force: true }).catch(() => {}); + // the sockets first: after a freeze they read open and are dead, and + // a refresh sent on them finds nothing (wakeRelays) + wakeRelays().catch(() => {}).then(() => { + resubscribeStreams(); // DMs and rooms, whether or not chat is on screen + if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return; + if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {}); + else refreshFeed({ force: true }).catch(() => {}); + }); return true; }, // A zap flow reporting how its payment went, so the chip that appeared @@ -18182,6 +19095,8 @@ export function messagesFeature(ctx) { return unreadCount(); }, bottomNav: appBottomNav, + // taken once per render: a page that isn't the feed leaves none + pageFloaters() { const f = pageFloaters; pageFloaters = []; return f; }, notifySettingsCards() { return [notifyCard()]; }, screenView() { liveShown = false; @@ -18232,6 +19147,7 @@ export function messagesFeature(ctx) { // The header magnifier: search anyone on nostr, results open profiles. userSearchAvailable() { return true; }, // the desktop header's search field: typing there IS the search page's query + userSearchSubmit(q) { if (String(q || '').trim()) { openTextSearch(q); render(); } }, userSearchQuery(q) { if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = { q: '', rows: null }; warmSearch(); } ui.userSearch.q = q; userSearcher.update(q); render(); @@ -18315,6 +19231,21 @@ export function messagesFeature(ctx) { // lud16 and nip05 — but only where they pointed at the released address // (or were empty). Deliberately different values are not ours to touch. addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); }, + // the address was given up with nothing in its place (account deleted): + // a profile still naming it would send zaps to nobody + addressGone(oldAddr) { + const is = (k) => (base) => base[k] === oldAddr; + return publishProfileFields({}, { + onlyWhen: (base) => is('lud16')(base) || is('nip05')(base), + remove: ['lud16', 'nip05'], fieldWhen: { lud16: is('lud16'), nip05: is('nip05') }, + }).catch(() => {}); + }, + // older copies of our kind 0 on history-keeping relays, and filling the + // open editor's empty fields from one (tests) + profileHistory() { return findProfileHistory(); }, + fillProfileFrom(e) { return fillProfileFrom(e); }, + // the profile editor's Save, without the editor (tests) + async saveProfileDraft(e) { const id = await identity(); return withKind0Lock(() => saveProfileNow(id, { name: '', about: '', picture: '', banner: '', ...e })); }, // opens the profile, unless the caller has a step to take first (the // Accounts page switches to that identity before showing its profile) profileChip(pk, size, onOpen) { @@ -18326,6 +19257,21 @@ export function messagesFeature(ctx) { }, avatar(pk, 'chat-avatar ' + (big ? 'send-avatar' : 'mini'), false), h('span', { class: big ? '' : 'small' }, displayName(pk))); }, + // a gift link names its sender: sign its template as us (gifts.js) + async signGiftFrom(tmpl) { + const id = await identity(); + if (!id) return null; + return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl); + }, + // A claimed gift's sender: follow them, once any follow publish in + // flight (a fresh wallet's starter follows) has landed — followMany + // drops a call that arrives mid-publish. + async followGiftSender(pk) { + for (let i = 0; followsPub && i < 60; i++) await new Promise((r) => setTimeout(r, 500)); + if (!(await identity())) return false; + await followMany([pk]).catch(() => {}); + return true; + }, // A sign-in (Google, passkey, key) that found no wallet on its identity: // the same start, but only the follows of an identity that follows // nobody yet — an established nostr user keeps their own list. @@ -18400,6 +19346,13 @@ export function messagesFeature(ctx) { loadLinkInvite(urlInvite); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); } + if (urlChat) { + urlChat = false; // once: init runs again for every wallet born in this tab + setTimeout(() => { + ui.chatOpen = true; ui.msgView = 'room'; ui.msgCommunity = COMMUNITY.community_id; + ui.msgChannel = null; ui.msgStick = true; render(); + }, 0); + } if (urlPack) { addPack(urlPack).catch(() => {}); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); @@ -18468,7 +19421,7 @@ export function messagesFeature(ctx) { follows = null; followsAt = 0; feed = null; relayLists = null; mutes = null; mutesAt = 0; stCache = null; stAt = 0; // the next account's state, not this one's - if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped + if (profFlush) { clearTimeout(profFlush); flushProfiles(true); } // this account's faces, written before the blob is dropped profBlob = null; timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); noteCountsReady.clear(); diff --git a/src/features/names.js b/src/features/names.js index 45b0b36..3f23a0c 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -17,7 +17,7 @@ import { nip98Header } from '../nip98.js'; import { qrSvg } from '../qr.js'; import { bech32 } from '@scure/base'; import { isArkAddress } from './ark.js'; -import { getNetwork } from '../api.js'; +import { getNetwork, getArkProviderId } from '../api.js'; import { t } from '../i18n.js'; import { getCurrency } from '../rates.js'; @@ -132,6 +132,27 @@ export function namesFeature(ctx) { return j; } + // Delete the account at the registrar: every name this wallet's key owns + // or manages is freed and what the server filed under them goes with it + // (coinosv3-server names/forget.js). `check` only asks what stands in the + // way: sats still on their way here, or held in a withdraw link. + async function accountDelete(check) { + const url = `${REGISTRAR}/account/delete`; + const body = JSON.stringify(check ? { check: true } : {}); + const auth = await withTimeout(nip98Header(walletSigner(), url, 'POST', body), 12000, 'signing'); + const r = await withTimeout(fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: auth }, body }), 30000, 'registrar'); + const j = await r.json().catch(() => ({})); + if (j.error === 'funds') return { blocked: true, ...j }; + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + if (!check) { + // a profile still pointing at a freed address would pay nobody (or its next owner) + for (const key of j.names || []) + await Promise.race([Promise.resolve(hook('addressGone', key)).catch(() => {}), new Promise((res) => setTimeout(res, 8000))]); + wallet.saveFeatureState('names', {}); + } + return j; + } + async function release() { const st = load(); if (!st.name) return; @@ -411,6 +432,7 @@ export function namesFeature(ctx) { // should have to retype who they already are — but only when that name is // actually free on the registrar (or already theirs to retake). let lnReceipts = { at: 0, p: null }; + let listeningAt = 0; let suggestedFor = null; function suggestName() { @@ -455,29 +477,35 @@ export function namesFeature(ctx) { // said as it's typed, not after a round trip (the registrar is the judge: // a name claimed before the rule can still be retaken by its owner) nameRuleHint(ui.nameClaim) ? h('div', { class: 'small', style: 'color:var(--red,#c0392b)' }, t('namesRule')) : null, - h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: async () => { - const name = (ui.nameClaim || '').toLowerCase().trim(); - if (!name) return; - ui.busy = true; ui.nameClaimError = null; ui.nameClaimMigration = null; render(); - try { - // Sign with the login identity when one can be (re)attached: the - // registrar lets a migrated coinos user's own identity take their - // name back, which the wallet key alone could never prove. - const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null); - await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {}); - // the wizard's Spending step tells a name claimed here from one - // the registrar handed back (an account set up before) - if (ui.onb && ui.onb.step === 'spend') ui.onb.claimedHere = name; - ui.nameClaim = ''; - ui.nameEditOpen = false; - toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` })); - } catch (e) { - ui.nameClaimError = e.message; - if (ui.nameClaim === name) ui.nameClaimMigration = e.migrationName || null; - } - ui.busy = false; render(); - } }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')), - ui.nameClaimMigration && ui.nameClaimMigration === ui.nameClaim && DOMAIN() === 'coinos.io' + h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: () => claimTyped(ui.nameClaim) }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')), + migrateLink()); + } + async function claimTyped(typed) { + const name = (typed || '').toLowerCase().trim(); + if (!name) return; + ui.busy = true; ui.nameClaimError = null; ui.nameClaimMigration = null; render(); + try { + // Sign with the login identity when one can be (re)attached: the + // registrar lets a migrated coinos user's own identity take their + // name back, which the wallet key alone could never prove. + const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null); + await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {}); + // the wizard's Spending step tells a name claimed here from one + // the registrar handed back (an account set up before) + if (ui.onb && ui.onb.step === 'spend') ui.onb.claimedHere = name; + ui.nameClaim = ''; + ui.nameEdit = null; + ui.nameEditOpen = false; + toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` })); + } catch (e) { + ui.nameClaimError = e.message; + if ((ui.nameEdit ?? ui.nameClaim) === name) ui.nameClaimMigration = e.migrationName || null; + } + ui.busy = false; render(); + } + // "I have a coinos account": a name the registrar holds for a legacy user + function migrateLink() { + return ui.nameClaimMigration && ui.nameClaimMigration === (ui.nameEdit ?? ui.nameClaim) && DOMAIN() === 'coinos.io' ? h('button', { type: 'button', class: 'linklike small', onClick: () => { @@ -490,7 +518,48 @@ export function namesFeature(ctx) { location.href = `https://coinos.io/migrate?to=${encodeURIComponent(addr)}&back=${encodeURIComponent(location.origin + '/')}`; } }, - }, t('onbHaveCoinos')) : null); + }, t('onbHaveCoinos')) : null; + } + + // Settings: the address and changing it are one card — the name sits in + // its own field; typing a different one offers the change (and says the + // old address stops working), otherwise Copy and Release. + function addressCard(st) { + const domain = st.domain || DOMAIN(); + const addr = `${st.name}@${domain}`; + const typed = ui.nameEdit ?? st.name; + const changed = !!typed && typed !== st.name; + return h('div', { class: 'card col', 'data-key': 'names' }, + h('h3', {}, t('namesTitle')), + ui.nameClaimError ? h('div', { class: 'notice err' }, ui.nameClaimError) : null, + h('div', { class: 'row gap6 name-field' }, + h('input', { + type: 'text', class: 'mono-input', placeholder: t('namesPlaceholder'), style: 'flex:1', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', 'aria-label': t('namesCustom'), + value: typed, + onInput: (e) => { + const was = changed; + ui.nameEdit = e.target.value.toLowerCase().trim(); + ui.nameClaimError = null; + if (ui.nameClaimMigration) ui.nameClaimMigration = null; + if ((!!ui.nameEdit && ui.nameEdit !== st.name) !== was || nameRuleHint(ui.nameEdit)) render(); + }, + }), + h('span', { class: 'muted', style: 'align-self:center' }, '@' + domain)), + changed && nameRuleHint(typed) ? h('div', { class: 'small', style: 'color:var(--red,#c0392b)' }, t('namesRule')) : null, + changed + ? h('div', { class: 'col', style: 'gap:8px' }, + h('p', { class: 'small muted', style: 'margin:0' }, t('namesCustomHow')), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost btn-sm', disabled: ui.busy, onClick: () => { ui.nameEdit = null; ui.nameClaimError = null; ui.nameClaimMigration = null; render(); } }, t('cancel')), + h('button', { class: 'btn-primary btn-sm grow', disabled: ui.busy, onClick: () => claimTyped(typed) }, + ui.busy ? h('span', { class: 'spinner sm' }) : t('namesChangeTo', { addr: typed }))), + migrateLink()) + : h('div', { class: 'row gap6' }, + copyBtn(addr, t('namesCopy')), + h('button', { class: 'btn-ghost btn-sm', onClick: async () => { + await release(); toast(t('namesReleased')); render(); + } }, t('namesRelease')))); } // Settings: one card per concern, all laid out (no collapsed sections — @@ -519,14 +588,7 @@ export function namesFeature(ctx) { h('div', { class: 'addr-box break', style: 'font-size:11px' }, `${st.name}.user._bitcoin-payment.yourdomain.com. CNAME ${st.name}.user._bitcoin-payment.${domain}.`)), card('ownnode', t('namesOwnNode'), t('namesOwnNodeMenuHow'), ownNodeSection()), - card('custom', t('namesCustom'), t('namesCustomHow'), claimForm(false)), - card('names', t('namesTitle'), null, - h('div', { class: 'addr-box break', style: 'font-size:14px' }, addr), - h('div', { class: 'row gap6' }, - copyBtn(addr, t('namesCopy')), - h('button', { class: 'btn-ghost btn-sm', onClick: async () => { - await release(); toast(t('namesReleased')); render(); - } }, t('namesRelease')))), + addressCard(st), ].filter(Boolean); } // The terminal's cards live on the Point of sale settings page now — no @@ -884,7 +946,7 @@ export function namesFeature(ctx) { checked = false; lastError = null; // The claim field is a draft for THIS account — a name typed before a // logout used to greet the next account that opened on this device. - ui.nameClaim = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = null; + ui.nameClaim = null; ui.nameEdit = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = null; refresh(); }, // The pencil by the address opens this: choosing a name gets a page of @@ -919,6 +981,20 @@ export function namesFeature(ctx) { }; return lnReceipts.p; }, + // A wallet on another ark server (Second, a custom one) gets its name's + // Lightning receives only while it is open to answer the registrar's + // offer request — the registrar can't arkoor to a server it has no + // float on — and its hourly retry rarely met a short session (52 sats + // waited 449 tries, 2026-10-18 → 11-03). So once the offer listener is + // up, say so: whatever is queued for this name goes now. Once per ten + // minutes; a wallet on the coinos server is paid while asleep and + // never needs to (and never spends a signature on it). + namesListening() { + if (!available() || !load().name || getArkProviderId() === 'coinos') return null; + if (Date.now() - listeningAt < 600_000) return null; + listeningAt = Date.now(); + return post('/listening', 'POST', {}).catch(() => { listeningAt = 0; return null; }); + }, // the claimed payment address, for anyone prefilling a lightning address namesAddress() { const st = load(); return st.name ? `${st.name}@${st.domain || DOMAIN()}` : null; }, // Sign-in races this lookup against the seed work: by the time the @@ -1001,6 +1077,7 @@ export function namesFeature(ctx) { return null; }, settingsCards() { return namesCards(); }, + accountDelete(check) { return wallet.watchOnly || !wallet.nostrSign ? null : accountDelete(check); }, posSettingsCards() { return posSettingsCardsForName(); }, // the Ark server changed: the name's ark= instruction must follow namesRefresh() { refresh().catch(() => {}); return true; }, diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index f39f9c3..4a811fc 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -69,7 +69,7 @@ export function nostrLoginFeature(ctx) { // decrypt that" is a healthy connection doing its job — the DM inbox probes // it with wraps that turn out to be someone else's, and treating each // refusal as a dead signer disconnected Amethyst users over and over. - const SIGNER_DEAD = /did not answer|timed? ?out|connect|closed|socket|relay|network/i; + const SIGNER_DEAD = /did not answer|timed? ?out|connect|closed|not open|socket|relay|network/i; const selfHealing = (signer) => { if (!signer || signer.kind !== 'bunker') return signer; const guard = (fn) => async (...args) => { @@ -364,6 +364,11 @@ export function nostrLoginFeature(ctx) { await run(() => keySigner(sk)); } catch (e) { if (/derive a wallet key/.test(e.message) || e.message === pass.LOCK_PASSKEY) { ui.nostrLoginError = e.message; render(); return; } + // Only "no passkey here" (NotAllowedError, as browsers report it) rolls + // into creating one; anything else is a failure to show, not a reason + // to make a second passkey (the Beta's "RP ID cannot be validated" went + // on to fail a create too, 2026-11-06). + if (e && e.name && e.name !== 'NotAllowedError') { ui.nostrLoginError = e.message || String(e); render(); return; } try { const sk = await pass.passkeyCreate(); await run(() => keySigner(sk)); @@ -422,21 +427,25 @@ export function nostrLoginFeature(ctx) { h('button', { class: 'btn-sm', type: 'button', onClick: () => { if (amberDeliver(e.text)) e.text = ''; else { ui.nostrLoginError = t('nlAmberBadPaste'); render(); } } }, t('nlAmberPasteBtn'))), h('button', { class: 'btn-ghost btn-sm', type: 'button', onClick: () => amberCancel() }, t('cancel'))); } - function externalButtons(run, big = false) { + // passkeyFirst: the front door lists the passkey right under the seed + // phrase, before Google (Adam, 2026-11-06) + // short: under a "Sign in" heading the buttons just name the way in + function externalButtons(run, big = false, passkeyFirst = false, short = false) { const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials; const pad = big ? 'padding:14px' : ''; - return [ + const google = [ h('button', { class: 'btn-block', style: pad, disabled: ui.nostrLoginBusy, onClick: () => googleLogin(run) }, - markedLabel(GOOGLE_MARK, t('nlGoogle'), ui.nostrLoginVia === 'google')), + markedLabel(GOOGLE_MARK, t(short ? 'doorGoogle' : 'nlGoogle'), ui.nostrLoginVia === 'google')), GOOGLE_CENTRAL !== GOOGLE_LEGACY ? h('button', { class: 'linklike small', style: 'align-self:center', disabled: ui.nostrLoginBusy, onClick: () => googleLogin(run, GOOGLE_LEGACY) }, t('nlGooglePrev')) : null, - hasPasskey - ? h('button', { class: 'btn-block', style: pad, disabled: ui.nostrLoginBusy, - onClick: () => passkeyLogin(run) }, markedLabel(PASSKEY_MARK, t('nlPasskey'), ui.nostrLoginVia === 'passkey')) - : null, ]; + const passkey = hasPasskey + ? h('button', { class: 'btn-block', style: pad, disabled: ui.nostrLoginBusy, + onClick: () => passkeyLogin(run) }, markedLabel(PASSKEY_MARK, t(short ? 'doorPasskey' : 'nlPasskey'), ui.nostrLoginVia === 'passkey')) + : null; + return passkeyFirst ? [passkey, ...google] : [...google, passkey]; } // The ways in, as buttons. `run` receives a signer factory. The expanded @@ -611,12 +620,18 @@ export function nostrLoginFeature(ctx) { // time, and a pause after a failure: identity() asks on every send, and a // signer that isn't answering shouldn't mean a new relay connection per // keystroke. - async function resumeLogin() { + const tabHidden = () => typeof document !== 'undefined' && document.visibilityState === 'hidden'; + async function resumeLogin({ force = false } = {}) { if (liveSigner()) return live; const st = load(); if (!st.pubkey) return null; if (resuming) return resuming; - if (Date.now() - resumeFailedAt < 20_000) return null; + if (!force && Date.now() - resumeFailedAt < 20_000) return null; + // A phone freezes a hidden tab: a remote signer asked from there can't be + // heard, and that silence isn't the session failing — don't count it. + let wasHidden = tabHidden(); + const onVis = () => { if (tabHidden()) wasHidden = true; }; + if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis); resuming = (async () => { // a stored passkey-derived key reattaches instantly — no network, no // dialog; see sessionOf for why this key (alone among keys) is kept @@ -645,10 +660,13 @@ export function nostrLoginFeature(ctx) { })(); try { const s = await resuming; - if (!s) resumeFailedAt = Date.now(); - else announceLive(); + if (!s && !wasHidden) resumeFailedAt = Date.now(); + else if (s) announceLive(); return s; - } finally { resuming = null; } + } finally { + resuming = null; + if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis); + } } // Which door this account came through, for the reconnect surfaces. State @@ -708,8 +726,31 @@ export function nostrLoginFeature(ctx) { // reattach silently). Same guarded buttons as the settings card — an // extension, the signer-app deeplink/QR, or a pasted key — and the run // refuses a different account. + // While the reconnect screen is up, keep trying the stored session: the + // remote signer authorised this client key long ago and nearly always still + // answers — it just wasn't heard the moment the screen opened (tab frozen, + // relay socket cut, the signer app asleep until opened). One try and then + // nothing meant a brand-new connection every time. + let retryT = 0; + const retryStored = () => { + clearTimeout(retryT); retryT = 0; + if (ui.nostrReconnect !== true || liveSigner()) return; + const st = load(); + if (!st.session || st.session.amber) return; + if (!tabHidden()) { + resumeLogin({ force: true }).then((sg) => { + if (sg && ui.nostrReconnect === true) { stopNostrConnect(); ui.nostrReconnect = null; ui.nostrConnectOpen = false; render(); } + }).catch(() => {}); + } + retryT = setTimeout(retryStored, 10_000); + }; + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { if (!tabHidden() && ui.nostrReconnect === true) retryStored(); }); + } + function reconnectScreen() { const st = load(); + if (ui.nostrReconnect === true && !retryT && st.session && !st.session.amber) retryT = setTimeout(retryStored, 0); // 'upgrade': Amber by intent is live but can't read messages — offer // only its NIP-46 door (nostrconnect), same account enforced const upgrade = ui.nostrReconnect === 'upgrade'; @@ -756,7 +797,7 @@ export function nostrLoginFeature(ctx) { if (!st.pubkey || liveSigner()) return false; ui.nostrReconnect = true; ui.nostrLoginError = ''; - this.nostrLoginResume().then((sg) => { + resumeLogin({ force: true }).then((sg) => { if (sg && ui.nostrReconnect) { ui.nostrReconnect = null; render(); } }).catch(() => {}); render(); @@ -820,9 +861,12 @@ export function nostrLoginFeature(ctx) { .catch(() => { _spendingHinted = false; }); return true; }, - frontDoorSignin() { - return h('div', { class: 'col', style: 'gap:8px' }, - ...externalButtons(loginWith, true), + // The front door's ways in, loose: the page spaces them evenly with the + // seed-phrase and coinos.io doors around them (a wrapper of their own + // put 8px between these and 20px between them and the rest). + frontDoorSignin({ short = false } = {}) { + return [ + ...externalButtons(loginWith, true, true, short), h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => { if (ui.onb) ui.onb.step = 'signin'; ui.nostrLoginOpen = true; @@ -830,8 +874,9 @@ export function nostrLoginFeature(ctx) { render(); } }, h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' }, h('span', { style: 'display:flex;flex-shrink:0', html: OSTRICH }), - t('nlSignInNostr'))), - ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null); + t(short ? 'doorNostr' : 'nlSignInNostr'))), + ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null, + ].filter(Boolean); }, nostrSettingsCards() { return [settingsCard()]; }, }; diff --git a/src/features/nwc.js b/src/features/nwc.js index 9c71a7f..9768740 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -30,7 +30,7 @@ import { t } from '../i18n.js'; import { dlog } from '../debug.js'; import { qrSvg } from '../qr.js'; import { getNetwork } from '../api.js'; -import { loadBg, saveBg, clearBg } from '../nwc-bg.js'; +import { loadBg, saveBg, clearBg, readBgLog } from '../nwc-bg.js'; import { encodeNoffer } from '../noffer.js'; import { maybeBolt11 } from '../ark/lightning.js'; import { withRequestLock } from '../nwc-lock.js'; @@ -56,7 +56,7 @@ const nowSec = () => Math.floor(Date.now() / 1000); const errRes = (code, message) => ({ error: { code, message } }); export function nwcFeature(ctx) { - const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, toast } = ctx; + const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, copy, toast } = ctx; // Relay transport, injectable so the protocol can be driven in tests // without a live relay. const { @@ -388,7 +388,10 @@ export function nwcFeature(ctx) { return !!(prior && prior.length); }; if (age > 5 && await lookBack()) return dlog('nwc: skip — request already answered elsewhere', ev.id.slice(0, 8)); - if (mobile && age <= 5) await new Promise((r) => setTimeout(r, 1000)); + // (a phone with this app on screen is as awake as any computer: only + // one behind another app holds back — every zap answered by an open + // phone used to wait this second for nobody) + if (mobile && age <= 5 && typeof document !== 'undefined' && document.hidden) await new Promise((r) => setTimeout(r, 1000)); claimed = await claim(ev.id); if (claimed === false) return dlog('nwc: standing down — another device claimed', ev.id.slice(0, 8)); if (claimed === null && age <= 5) { @@ -478,11 +481,21 @@ export function nwcFeature(ctx) { try { res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat }); } catch (e) { - // The ASP already holds a payment for this invoice: another device - // of this wallet took the same request first. Its outcome is ours — - // wait for it rather than report a failure over a zap that is - // paying. The payer records the spend; this side only relays. - if (!/already in progress/i.test(e.message || '')) throw e; + // A payment for this invoice already exists: ours, begun on an + // earlier request (the worker started it while closed, say), or + // another device's — the ASP refuses a second HTLC for one invoice. + // Its outcome is the answer, never an error whose text says "paid": + // a client once read "already paid or is being paid" as paid, hours + // before the money moved. The payer records the spend; this side + // only relays. + if (!/already in progress|already paid/i.test(e.message || '')) throw e; + const mine = await hook('arkLnLookup', { paymentHash: dec.paymentHash }).catch(() => null); + if (mine && mine.type === 'outgoing' && mine.state === 'settled' && mine.preimage) { + return { sibling: true, result: { preimage: mine.preimage, fees_paid: 0 } }; + } + if (mine && mine.type === 'outgoing' && mine.state === 'pending') { + return errRes('OTHER', 'a payment for this invoice is still in progress; ask again in a moment'); + } const out = await hook('arkLnOutcome', dec.paymentHash, 60000); if (!out || out.status !== 'success') throw e; return { sibling: true, result: { preimage: out.preimage, fees_paid: 0 } }; @@ -661,6 +674,10 @@ export function nwcFeature(ctx) { { kinds: [OFFER_KIND], '#p': [o.pk], since: nowSec() - MAX_AGE_SEC }, (ev) => { onOfferRequest(ev).catch((e) => console.warn('nwc: offer handler', e.message)); }, )); + // a beat for the subscription to reach the relays, then tell the + // registrar this wallet is here to answer: Lightning deliveries + // queued for a wallet on another ark server go out now (names) + setTimeout(() => { if (gen === listenGen) Promise.resolve(hook('namesListening')).catch(() => {}); }, 1500); } const list = [...conns(), ...revokedConns()]; dlog(`nwc: listen() gen ${gen} — ${list.length} connection(s)`); @@ -967,7 +984,14 @@ export function nwcFeature(ctx) { if (typeof window !== 'undefined') { window.addEventListener('online', () => listen()); document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && conns().length) { listen(); refreshRegistration(); } + if (document.visibilityState === 'visible' && conns().length) { + listen(); refreshRegistration(); + // the worker may have paid while this app sat behind another one + Promise.resolve(hook('arkBgAbsorb')).then((had) => { if (had) return reconcileBg(); }).catch(() => {}); + } + // going behind another app: the worker answers from the mirror now, so + // it gets this moment's coins, not the last minute-and-a-half's + if (document.visibilityState === 'hidden' && conns().length) writeBg(); }); } } @@ -1093,7 +1117,19 @@ export function nwcFeature(ctx) { h('button', { class: 'btn-sm', onClick: () => { const s = load(); s.paySentNotify = !on; save(s); writeBg(); render(); } }, on ? t('nwcOn') : t('nwcOff'))), - h('div', { class: 'small faint' }, t('notifPaySentHint')))]; + h('div', { class: 'small faint' }, t('notifPaySentHint'))), + // the worker's diary, for the report that says where a closed wallet + // got to (a phone stops it without a trace) + h('div', { class: 'card col', style: 'gap:10px' }, + h('div', { class: 'row between' }, + h('span', { class: 'small' }, t('nwcWorkerLog')), + h('button', { class: 'btn-sm', onClick: async () => { + const lines = await readBgLog(); + if (!lines.length) { toast(t('nwcWorkerLogEmpty')); return; } + await copy(lines.join('\n')); + toast(t('copied')); + } }, t('copy'))), + h('div', { class: 'small faint' }, t('nwcWorkerLogHint')))]; }, }; } diff --git a/src/features/withdraw-links.js b/src/features/withdraw-links.js new file mode 100644 index 0000000..a63d180 --- /dev/null +++ b/src/features/withdraw-links.js @@ -0,0 +1,195 @@ +// Prepaid Lightning withdraw links (LNURL-withdraw): make a link or QR that +// whoever holds it can pull sats from — a voucher, a faucet, a printed card — +// even while this wallet is asleep. "Do we have lnurl withdraw links +// somewhere? can we add them?" → "Prepaid" (Adam, 2026-11-07). +// +// The money is paid up front, from Spending over Lightning, to the coinos +// registrar (coinosv3-server names/withdraw.js), which pays each claim out of +// it; until claimed it sits there, and the creator can take back whatever is +// left at any time ("Take back"), which closes the link. Links belong to the +// wallet's own nostr key, so every device with the seed sees them. +// +// It lives on the Send page as a sibling of the gift link: ui.giftMode === +// 'lnurl' (giftMode is already a navigation field with its resets). +import { nip98Header } from '../nip98.js'; +import { encodeLnurl } from '../lnurl.js'; +import { qrSvg } from '../qr.js'; +import { getNetwork } from '../api.js'; +import { t } from '../i18n.js'; + +const REGISTRAR = 'https://names.coinos.io'; +const NOTE_MAX = 120; +const net = () => (getNetwork() === 'mutinynet' ? 'mutinynet' : 'mainnet'); +const available = () => ['mainnet', 'mutinynet'].includes(getNetwork()); + +export function withdrawLinks(ctx) { + const { h, ui, render, wallet, toast, copyBtn, goBack, fmtAmount, unitLabel, unitTag, parseAmount, getUnit } = ctx; + const hook = ctx.hook || (() => null); + const base = () => ctx.withdrawRegistrar || REGISTRAR; + // ui.wl: { list: [...]|null, listError, amount, uses, note, busy, error, open: id|null, made: id|null, taking: id|null } + const st = () => (ui.wl ||= { list: null, amount: '', uses: '1', note: '' }); + + const signer = { signEvent: (e) => wallet.nostrSign(e) }; + async function call(method, path, payload) { + const url = base() + path; + const body = payload ? JSON.stringify(payload) : ''; + const auth = await nip98Header(signer, url, method, body); + const r = await fetch(url, { method, headers: { authorization: auth, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body } : {}) }); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + return j; + } + + async function load() { + const s = st(); + try { + const j = await call('GET', '/withdraw/mine'); + s.list = (j.links || []).filter((l) => l.net === net()); + s.listError = ''; + } catch (e) { s.listError = e.message; s.list ||= []; } + render(); + } + + function open() { + ui.wl = null; + ui.giftMode = 'lnurl'; + ui.sendError = ''; + st(); + render(); + load(); + } + + const lnurlOf = (l) => encodeLnurl(l.url).toUpperCase(); + const amt = (sat) => fmtAmount(sat) + ' ' + unitLabel(); + + async function create() { + const s = st(); + const sat = parseAmount(s.amount, getUnit()) || 0; + const uses = Math.floor(Number(s.uses) || 1); + s.error = ''; + if (!(sat >= 1)) { s.error = t('wlAmountInvalid'); render(); return; } + if (!(uses >= 1 && uses <= 1000)) { s.error = t('wlUsesInvalid'); render(); return; } + if (wallet.watchOnly) { s.error = t('lnWithdrawWatchOnly'); render(); return; } + if (!hook('arkReady')) { s.error = t('hatNeedSpending'); render(); return; } + const total = sat * uses; + if ((hook('arkSpendableSat') || 0) < total + Math.max(10, Math.ceil(total * 0.002))) { s.error = t('wlNoFunds', { amount: amt(total) }); render(); return; } + s.busy = true; render(); + try { + const inv = await call('POST', '/withdraw/new', { sat, uses, note: String(s.note || '').trim().slice(0, NOTE_MAX), net: net() }); + await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.totalSat }); + let link = null; + for (let i = 0; i < 10 && !link; i++) { + try { link = await call('POST', '/withdraw/funded', { id: inv.id }); } + catch (e) { if (!/not paid/i.test(e.message)) throw e; await new Promise((r) => setTimeout(r, 1500)); } + } + if (!link) throw new Error(t('wlNotConfirmed')); + s.list = [link, ...(s.list || []).filter((l) => l.id !== link.id)]; + s.made = link.id; s.open = link.id; + s.amount = ''; s.uses = '1'; s.note = ''; + } catch (e) { + s.error = e.message || String(e); + } finally { s.busy = false; render(); } + } + + async function takeBack(l) { + const s = st(); + s.taking = l.id; s.error = ''; render(); + let lnId = null; + try { + if (l.remainingSat > 0) { + const inv = await hook('arkLnInvoice', l.remainingSat, t('wlTakeBackMemo')); + if (!inv || !inv.invoice) throw new Error(t('lnWithdrawNoInvoice')); + lnId = inv.id; + // the wallet has to claim the held payment while the registrar pays it + hook('arkLnWatch', inv.id, () => {}); + const r = await call('POST', '/withdraw/reclaim', { id: l.id, invoice: inv.invoice }); + Object.assign(l, r); + toast(r.paid === false ? t('wlTakeBackFailed') : t('wlTakenBack', { amount: amt(inv.amountSat) })); + } else { + Object.assign(l, await call('POST', '/withdraw/reclaim', { id: l.id })); + } + } catch (e) { + if (lnId) hook('arkLnCancel', lnId); + s.error = e.message || String(e); + } finally { s.taking = null; render(); load(); } + } + + function linkCard(l) { + const s = st(); + const lnurl = lnurlOf(l); + let svg = null; + try { svg = qrSvg(lnurl, { ec: 'M', mode: 'Alphanumeric' }); } catch {} + const live = l.status === 'open' && l.remainingSat > 0 && l.used < l.uses; + return h('div', { class: 'card col', style: 'align-items:center;gap:10px' }, + h('h3', { style: 'align-self:stretch' }, s.made === l.id ? t('wlReady') : t('wlTitle')), + live && svg ? h('div', { html: svg }) : null, + l.note ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, l.note) : null, + h('div', { class: 'small muted', style: 'text-align:center' }, + l.uses > 1 ? t('wlTermsMany', { amount: amt(l.sat), n: l.uses - l.used, total: l.uses }) : t('wlTermsOne', { amount: amt(l.sat) }), + ' · ', t('wlLeft', { amount: amt(l.remainingSat) })), + live ? h('div', { class: 'addr-box break mono', style: 'width:100%;font-size:11px' }, lnurl) : h('div', { class: 'notice small' }, l.status === 'closed' ? t('wlClosed') : t('wlUsedUp')), + live ? h('div', { class: 'row gap6 wrap', style: 'width:100%' }, copyBtn(lnurl, t('wlCopy'))) : null, + live ? h('div', { class: 'small faint', style: 'text-align:center' }, t('wlBearer')) : null, + s.error && h('div', { class: 'notice err', style: 'width:100%' }, s.error), + live || (l.status === 'open' && l.remainingSat > 0) + ? (s.taking === l.id + ? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { class: 'btn-ghost btn-block', onClick: () => takeBack(l) }, t('wlTakeBack', { amount: amt(l.remainingSat) }))) + : null, + h('button', { class: 'btn-ghost btn-block', onClick: () => { s.open = null; s.made = null; s.error = ''; render(); } }, t('back'))); + } + + function formCard() { + const s = st(); + const sat = parseAmount(s.amount, getUnit()) || 0; + const uses = Math.max(1, Math.floor(Number(s.uses) || 1)); + return h('div', { class: 'card col gap6' }, + h('h3', {}, t('wlTitle')), + h('div', { class: 'small muted' }, t('wlDesc')), + h('div', { class: 'input-group' }, + h('input', { id: 'wl-amount', type: 'number', min: '0', inputmode: 'decimal', step: getUnit() === 'sats' ? '1' : '0.00000001', + placeholder: t('wlAmount'), value: s.amount, onInput: (e) => { s.amount = e.target.value; render(); } }), + h('div', { style: 'display:flex;align-items:center' }, unitTag())), + h('label', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small grow' }, t('wlUses')), + h('input', { id: 'wl-uses', type: 'number', min: '1', max: '1000', step: '1', inputmode: 'numeric', style: 'width:90px', + value: s.uses, onInput: (e) => { s.uses = e.target.value; render(); } })), + h('textarea', { class: 'gift-note-input', rows: 2, maxlength: NOTE_MAX, placeholder: t('wlNote'), value: s.note, onInput: (e) => { s.note = e.target.value; } }), + h('div', { class: 'small faint' }, sat >= 1 && uses > 1 ? t('wlPayNowMany', { total: amt(sat * uses), n: uses, amount: amt(sat) }) : t('wlPayNow')), + s.error && !s.open ? h('div', { class: 'notice err' }, s.error) : null, + s.busy + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { id: 'wl-create', class: 'btn-primary btn-block', onClick: create }, sat >= 1 ? t('wlCreateFor', { amount: amt(sat * uses) }) : t('wlCreate'))); + } + + function listCard() { + const s = st(); + if (s.list == null) return h('div', { class: 'card', style: 'text-align:center' }, h('span', { class: 'spinner sm' })); + if (!s.list.length) return s.listError ? h('div', { class: 'notice err small' }, s.listError) : null; + return h('div', { class: 'card col gap6' }, + h('span', { class: 'small muted' }, t('wlYours')), + ...s.list.map((l) => h('button', { class: 'row between wl-row', style: 'text-align:left', onClick: () => { s.open = l.id; s.made = null; s.error = ''; render(); } }, + h('span', { class: 'small' }, amt(l.sat), l.uses > 1 ? ` × ${l.uses}` : '', l.note ? h('span', { class: 'faint' }, ' · ' + l.note) : null), + h('span', { class: 'small faint' }, + l.status === 'closed' ? t('wlClosedTag') : l.remainingSat < 1 || l.used >= l.uses ? t('wlUsedUpTag') : t('wlLeft', { amount: amt(l.remainingSat) }))))); + } + + function view() { + const s = st(); + if (!available()) { + return h('div', { class: 'col', style: 'gap:12px' }, + h('div', { class: 'card col' }, h('h3', {}, t('wlTitle')), h('div', { class: 'small muted' }, t('wlMainnetOnly'))), + h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.giftMode = false; }) }, t('back'))); + } + const l = s.open && (s.list || []).find((x) => x.id === s.open); + if (l) return h('div', { class: 'col', style: 'gap:12px' }, linkCard(l)); + return h('div', { class: 'col', style: 'gap:12px' }, + formCard(), + listCard(), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.wl = null; goBack(() => { ui.giftMode = false; }); } }, t('back'))); + } + + const entry = () => h('button', { id: 'wl-open', class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: open }, '⚡ ' + t('wlTitle')); + + return { view, entry, open }; +} diff --git a/src/features/zaps.js b/src/features/zaps.js index 76296cf..8e29409 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -15,7 +15,7 @@ import { t } from '../i18n.js'; import { npubOf } from '../nostr.js'; import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin, submitWithdraw } from '../lnurl.js'; import { HDKey } from '@scure/bip32'; -import { mnemonicToSeedSync } from '@scure/bip39'; +import { seedSync } from '../seed.js'; import { decodeNoffer } from '../noffer.js'; import { requestOfferInvoice } from '../clink.js'; import { decodeOffer } from '../bolt12.js'; @@ -322,7 +322,7 @@ export function zapsFeature(ctx) { render(); } const masterKey = () => { - if (wallet.mnemonic) return HDKey.fromMasterSeed(mnemonicToSeedSync(wallet.mnemonic, wallet.passphrase || '')); + if (wallet.mnemonic) return HDKey.fromMasterSeed(seedSync(wallet.mnemonic, wallet.passphrase || '')); if (wallet.xprv) return HDKey.fromExtendedKey(wallet.xprv); return null; }; @@ -487,7 +487,7 @@ export function zapsFeature(ctx) { // what this payment needs (extra is one edit away). const typedSats = parseAmount(z.amount, getUnit()) || 0; const theirMaxSat = Math.floor(p.maxSendable / 1000); - const boardable = !!hook('arkReady') && ours != null; + const boardable = !!hook('arkReady') && ours != null && !!hook('arkSavingsCanBoard'); const renewing = hook('spendingRenewingSat') || 0; const waitingOnRenewal = renewing > 0 && broke && ours + renewing >= Math.max(min, p.fixedSat || typedSats); const needsBoard = !waitingOnRenewal && boardable && (broke || (typedSats > 0 && typedSats > ours && typedSats <= theirMaxSat)); @@ -533,7 +533,15 @@ export function zapsFeature(ctx) { // a login challenge needs no balance, only the wallet's key const auth = parseLnurlAuth(text); if (auth) { beginLogin(auth); return true; } - if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false; + if (!ui.send || ui.send.recipients.length !== 1) return false; + // An LNURL may be a withdraw — it pays US, so it needs no spendable + // balance, and right after a refresh (Spending still connecting) the + // check below turned a scanned withdraw link into a "Zap LNURL…" + // recipient (Adam, 2026-11-08). Only the GET can tell: take it now, + // and a pay one meets the pay checks where it's paid. + const early = parseZapTarget(text); + if (early && early.kind === 'lnurl' && !typed) { begin(early, zapDisplay(early, text), true); return true; } + if (!canPay()) return false; const tap = !typed; // scanned/pasted (a tap), not a keystroke const nof = maybeNoffer(text); if (nof) { diff --git a/src/i18n.js b/src/i18n.js index 5fb2cb1..194ce40 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -68,6 +68,20 @@ const STR = { logoutAll: 'Log out of all accounts', clearAllWarn: '⚠ This removes every account from this device. Any account whose recovery phrase or key you have not backed up will be lost for good.', removeWalletTitle: 'Remove account', + delAcctTitle: 'Delete account', + delAcctBlurb: 'Give up your Coinos name, have our servers forget this account, and remove it from this device.', + delAcctChecking: 'Checking your account…', + delAcctName: '{name} is released. Anyone can claim it afterwards, and payments sent to it will no longer reach you.', + delAcctRecords: 'Our servers delete what they hold for this account: its payment address, notification registrations, scheduled posts, hats, point-of-sale and receipt records.', + delAcctDevice: '“{name}” is removed from this device.', + delAcctStays: 'Not deleted: your coins, which only your recovery phrase controls, and what you have published to nostr relays (profile, posts, messages).', + delAcctOwed: '⚠ {sat} sats are still on their way to you or held in your withdraw links. Wait for payments to arrive, take back your withdraw links (Send → Withdraw link), then try again.', + delAcctFunds: '⚠ This account still holds {sat} sats. Deleting does not move them. Send them somewhere first, or make sure you have the recovery phrase: without it they are lost for good.', + delAcctSure: 'I understand this cannot be undone.', + delAcctSureFunds: 'I have moved my funds or saved my recovery phrase, and I understand this cannot be undone.', + delAcctGo: 'Delete account', + delAcctDone: 'Account deleted', + delAcctUnlockFirst: 'Lock Coinos and unlock it with your password first, then try again', removeWalletWarn: '⚠ Remove “{name}” from this device? It will be gone from this browser. Make sure you have its recovery phrase. Other accounts aren’t affected.', rename: 'Rename', remove: 'Remove', @@ -109,6 +123,17 @@ const STR = { profDisplayName: 'Display name', profPublicKey: 'Nostr public key', profKeyLink: 'Public key', + profHistDesc: 'If another app replaced your profile, an older copy may still be on a relay that keeps history. Using one fills only the fields that are empty here.', + profHistLook: 'Look for older copies', + profHistUse: 'Use', + profHistNoName: '(no name)', + profHistNone: 'No older copy has anything this one is missing.', + profHistNothing: 'Everything in that copy is already here.', + profHistFilled: 'Filled {n} fields from the older copy. Save to publish.', + profHist_name: 'name', + profHist_about: 'bio', + profHist_picture: 'picture', + profHist_banner: 'banner', followHistTitle: 'Follow list', followHistDesc: 'You follow {n} people. If another app replaced your follow list, an older copy may still be on a relay that keeps history. Restoring adds everyone from it back; nobody is removed.', followHistLook: 'Look for older copies', @@ -186,6 +211,19 @@ const STR = { skipVerification: 'Skip verification', confirmBackupIntro: 'Confirm your backup by entering the requested words.', wordN: 'Word #{n}', + seedIntroBody: 'Next you’ll see your seed phrase: {n} words that are your wallet. Write them down on paper, in order, and keep them somewhere safe. They’re the only way to get your coins back if you lose this device.', + seedIntroPrivate: 'Anyone who sees them can take your coins, so make sure no one is looking at your screen.', + seedIntroShow: 'Show seed phrase', + next: 'Next', + passStepTitle: 'Add a passphrase?', + passStepBody: 'A passphrase is an extra secret you choose yourself, on top of your {n} words. Anyone who finds your seed phrase still can’t open your wallet without it.', + passStepNote: 'It’s optional, but recommended. Write it down with your seed phrase: you’ll need both to recover your wallet, exactly as written, and nobody can reset it for you.', + passStepNone: 'Continue without one', + skip: 'Skip', + quizPrompt: 'Which one is word #{n}?', + quizWrong: 'Not that one. Check what you wrote down.', + quizBack: 'Show the words again', + onbBackupShow: 'Show seed phrase', reenterPassphrase: 'Re-enter passphrase', back: '← Back', cancel: 'Cancel', @@ -197,12 +235,15 @@ const STR = { seedPlaceholder: 'enter your seed words separated by spaces', passphrase: 'Passphrase', passWhatsThis: 'What’s this?', + addPassphrase: 'Add passphrase', + passphraseOptional: 'Passphrase (optional)', + passOptionalHint: 'Leave it empty unless you want one. Most wallets have none.', passInfo: 'An optional 13th word: if someone gets hold of your 12 seed words, they still can’t open your wallet without this one. It’s not a login password and it doesn’t encrypt anything: each passphrase opens its own separate wallet, so to recover your funds you’ll need your seed words and this passphrase, exactly as written.', show: 'Show', hide: 'Hide', invalidSeed: 'Invalid seed phrase. Check the spelling and word order.', - importLabel: 'Recovery phrase or key', - importPlaceholder: 'Your seed words, or an xpub / xprv', + importLabel: 'Seed phrase', + importPlaceholder: 'Your seed words', invalidImport: 'That is not a valid recovery phrase, xpub, or xprv.', importedKey: 'Imported key', importedKeyNote: 'This wallet was imported from an extended private key, so there is no recovery phrase. Back up that key itself to keep the wallet.', @@ -282,6 +323,8 @@ const STR = { clipsAll: 'All shapes', clipsToggleTitle: 'Which clips to show: the ones that fit the screen, or all', videoTapToPlay: "Tap to play", + videoPlay: "Play", + videoPause: "Pause", pagerFetchingVideo: "Fetching the next video…", pagerFetchingPictures: "Fetching more pictures…", pagerCaughtUp: "That’s everything for now", @@ -290,6 +333,7 @@ const STR = { pagerPrevPic: 'Previous picture', pagerNextPic: 'Next picture', videoNoPicture: "This browser can’t show this video’s picture", + videoGone: "This video couldn’t be loaded from its host", videoOpenFile: "Open the file", wnDevicesTitle: 'Group invitations', wnDevicesHelp: 'White Noise and other Marmot apps invite one of your devices, and only that device gets the group. Keep the invitations coming to the device you actually use.', @@ -315,6 +359,7 @@ const STR = { feedTitle: 'Feed', feedFollowingName: 'Following', noteReplyingTo: 'Replying to', + noteCommentOn: 'Comment on {where}', feedNew: 'New feed', feedNewName: 'New feed', feedEdit: 'Edit feed', @@ -438,6 +483,7 @@ const STR = { listNew: 'New list…', profLists: 'Lists', searchTopicRow: 'Posts on this topic', + searchPostsRow: 'Posts and replies that say this', searchTopicFor: 'Posts tagged #{q}', relayLinkOpen: 'See what’s posted on this relay', alertsTitle: 'Notifications', @@ -445,6 +491,8 @@ const STR = { alertsNew: '{n} new', alertsEmpty: 'Nothing yet. Zaps, reactions and replies to your posts will show up here.', alertZap: 'zapped you {sats}', + alertZapApp: 'zapped your app {name} {sats}', + alertZapAppAnon: 'zapped your app {sats}', alertReact: 'reacted {emoji}', alertBoost: 'boosted your post', alertReply: 'replied to you', @@ -666,12 +714,26 @@ const STR = { profComposePh: "What's on your mind?", profPostBtn: 'Post', profPosted: 'Posted', + schedToggle: 'Schedule for later', + schedAt: 'Post at', + schedBtn: 'Schedule', + schedDone: 'Scheduled for {when}', + schedTooSoon: 'Pick a time at least two minutes from now', + schedListTitle: 'Scheduled ({n})', + schedCancelled: 'Scheduled post cancelled', publicChatTitle: '{name} community', publicChatDesc: 'Live chat: read along, sign in to join the conversation', publicChatSignIn: 'Sign in to chat', - publicChatOpenApp: 'Open coinos', + publicChatDescMember: 'Live chat: open the chat in Coinos to join the conversation', + publicChatOpenApp: 'Join the chat', publicChatLoading: 'Loading messages…', msgDelete: 'Delete message', + msgBan: 'Remove {name} and their messages', + msgBanConfirm: 'Remove {name} for everyone? Tap again', + msgBanned: '{name} removed', + msgUnbanned: '{name} restored', + msgBannedHead: 'Removed from this community', + msgUnban: 'Restore', msgNoIdentity: "Your nostr signer isn't connected. Reconnect it to send as yourself", msgSendFailed: 'No relay accepted the message', msgLockedChat: 'Your wallet is locked, so it can’t sign messages.', @@ -685,8 +747,8 @@ const STR = { addrRead: '{min} min read', addrList: 'A list by {name}', msgSignerOff: "Your nostr signer isn't connected, so you can't post as yourself yet.", - msgAmberDms: 'Signed in with Amber, messages sent to you can’t be opened here, only ones you sent from this device.', - msgAmberConnect: 'Connect Amber', + msgAmberDms: 'Signed in with a signer app, messages sent to you can’t be opened here, only ones you sent from this device.', + msgAmberConnect: 'Connect signer', postRepostedBy: '{name} reposted', gifBtnTitle: 'Add a GIF', gifSearchPh: 'Search GIFs', @@ -728,6 +790,13 @@ const STR = { msgGroupProtoWn: 'Marmot (MLS)', msgGroupProtoCd: 'Cordn', msgGroupProtoCdHint: 'Cordn groups work with Amethyst and the Cordn app; messages travel through a coordinator server that cannot read them.', + msgGroupProtoN29: 'Relay group', + msgGroupProtoN29Hint: 'Join a NIP-29 group made in Chachi, Flotilla, 0xchat or another client: paste its address (relay\'id) or a link to it. Relay groups are not encrypted; the relay keeps the members and can read the messages.', + msgNip29Address: 'relay.example.com\'groupid or a link', + msgNip29BadAddress: 'That isn\'t a group address. It looks like relay.example.com\'groupid', + msgNip29JoinRefused: 'The relay didn\'t let you in yet ({why}). You can still read the group if it\'s open.', + msgNip29Info: 'Relay group {relay} · not encrypted · the relay keeps the members', + msgNip29CopyAddress: 'Copy group address', msgCordnInfo: 'Cordn group · end-to-end encrypted · delivered by a coordinator', msgCordnNoFiles: 'Attachments are not available in Cordn groups yet', msgRouteTitle: 'Send messages by', @@ -1057,6 +1126,10 @@ const STR = { giftWelcome: 'You’ve been sent Bitcoin', giftBackedUp: 'I’ve written it down', giftLink: 'Gift some Bitcoin', + giftNotePlaceholder: 'Add a note (optional)', + giftFromMe: 'Show it’s from me (whoever claims it follows you)', + giftFrom: 'From', + giftClaimFollows: 'Claiming it follows them', giftLinkDesc: 'Presign an amount as a link. Whoever opens it claims the Bitcoin into a brand-new wallet that only they control.', giftLinkWarn: '⚠ Anyone who opens this link can claim the amount, so share it only with the person you are gifting. Keep amounts small.', giftLinkReveal: 'Create gift link', @@ -1120,6 +1193,7 @@ const STR = { claimOr: 'or', goToWallet: 'Go to wallet', claimBtn: 'Claim it', + claimNowBtn: 'Claim it now', claimFeeNote: '~{n} fee', claimFailed: 'This gift has already been claimed or is no longer available.', claimNotVisibleBody: 'The coins funding this gift aren\u2019t visible from this wallet\u2019s network/data source. Make sure this wallet uses the same network and data source as the sender, then try again.', @@ -1150,6 +1224,9 @@ const STR = { explorerUrlHint: 'Esplora / electrs REST base, e.g. https://mempool.space/api', walletName: 'Account name', walletSettings: 'Account settings', + balanceSettingsTitle: 'Balance settings', + spendingSettingsDesc: 'Coins, renewals, who holds them, moving them out', + savingsSettingsDesc: 'Lock, password, recovery phrase, public key', securityTitle: 'Security', walletSettingsDesc: 'Name, recovery phrase, public key, and auto-logout for this account.', autolockTitle: 'Log out automatically', @@ -1208,7 +1285,10 @@ const STR = { arkRenewForecast: 'Coins renew in ~{when} for {fee}.', arkRenewForecastNow: 'Coins renew shortly for {fee}.', arkBoardStuck: 'Moving money hit a snag and will keep retrying: {error}', - arkRenewWarnPush: 'Your Spending balance is too small to renew itself and expires around {date}. Receive any amount or spend it to keep it alive.', + // the push's words are measured against Chrome's spam model (src/notify-text.js): + // 'too small to renew itself and expires around' scored 98, this 11 + arkRenewWarnTitle: 'Spending balance', + arkRenewWarnPush: 'Receive or spend sats before {date} to keep it alive.', giftArkOutstanding: '{n} unclaimed instant gift(s)', giftArkRevoked: 'Gift swept back into Spending.', claimArkFree: 'instant, no fee', @@ -1329,6 +1409,8 @@ const STR = { arkLnFromSavingsShort: 'Spending can’t cover this one. Move funds over from Savings first. That takes an on-chain confirmation, so ask for a fresh invoice afterwards.', arkLnUseSpending: 'Switch to Spending and continue', arkLnMoveFirst: 'Move Savings into Spending first', + arkLnSavingsTooSmall: 'Savings holds {have}, and a move into Spending needs at least {min} plus the on-chain fee. Receive over Lightning to fund Spending directly, or add to Savings first.', + arkLnReceiveInstead: 'Receive over Lightning instead', arkLnFee: 'Fee', arkLnPaying: 'Paying invoice…', arkLnRetrying: 'Route failed. Retrying with a bigger fee budget ({n}/{of})…', @@ -1369,6 +1451,13 @@ const STR = { backupReminder: 'Your recovery phrase is still unwritten.', backupReminderGo: 'Write it down', onbSeedTitle: 'Set up your wallet', + onbRestoreTitle: 'Enter seed phrase', + onbRestoreDoor: 'Seed phrase', + doorPasskey: 'Passkey', + doorGoogle: 'Google', + doorNostr: 'Nostr', + addAccountTitle: 'Add an account', + onbSignInExisting: 'Sign in to an existing account', onbSigninTitle: 'Sign in', onbLegacyTitle: 'Move your coinos.io account', onbLegacyBody: 'The original coinos. We can move your balance over and point your username here. Your old account keeps working, it just stops receiving payments.', @@ -1386,6 +1475,8 @@ const STR = { onbSpendBody: 'Claim a username and get your own payment address. People can send you bitcoin instantly, with just your name.', onbNameWait: 'Getting things ready…', onbNotNow: 'Not now', + onbGiftNameTitle: 'Now pick a username', + onbGiftNameBody: 'Claim a name and get your own payment address, so people can send you bitcoin with just your name. Then choose a picture and a cover.', onbAvatarTitle: 'Choose your look', onbAvatarBody: 'Pick a cover and an avatar, or upload your own. You can change them anytime.', onbCover: 'Cover image', @@ -1409,7 +1500,7 @@ const STR = { nlSignInNostr: 'Sign in with Nostr', nlDesc: 'Use your Nostr account to open your wallet, on any device, with the same account.', nlExtension: 'Use browser extension', - nlAmber: 'Sign in with Amber (Android)', + nlAmber: 'Sign in with a signer app (Android)', nlAmberWait: 'Waiting for your signer app… approve there, then come back here.', nlAmberTap: 'Your signer app has another request to answer. Open it to continue.', nlAmberOpen: 'Open signer app', @@ -1421,7 +1512,7 @@ const STR = { nlPasskey: 'Sign in with a passkey', nlKeyOrBunker: 'nsec, private key, or bunker://…', nlGo: 'Log in', - nlHint: 'Works with Amber, Amethyst and other remote signers (paste their bunker:// address), or a browser extension. A pasted key is used once and never stored.', + nlHint: 'Works with remote signer apps (paste their bunker:// address) or a browser extension. A pasted key is used once and never stored.', nlApprove: 'Approve the request in your signer →', nlUnrecognized: 'That doesn’t look like a private key or a bunker:// address.', nlNewTitle: 'Create a wallet for this account', @@ -1442,7 +1533,7 @@ const STR = { nlReconnectDesc: "Your signer isn't connected, so this app can't sign as you. Reconnect it to post and update your profile. Your wallet and balance aren't affected.", nlReconnectTitle: 'Reconnect your signer', nlAmberUpgradeTitle: 'Read your messages', - nlAmberUpgradeBody: 'Amber can only open messages here when it’s connected as a remote signer. Tap below, approve in Amber (allow decrypting), and your messages will load. Signing works as before.', + nlAmberUpgradeBody: 'Your signer app can only open messages here when it’s connected as a remote signer. Tap below, approve in the signer app (allow decrypting), and your messages will load. Signing works as before.', nlReconnectBody: 'Your Nostr signer isn’t connected on this device. Reconnect to keep acting as {npub}…', nlWrongAccount: 'That signer is a different Nostr account. Reconnect with {npub}.', nlLinked: 'Wallet linked to your Nostr account', @@ -1474,6 +1565,7 @@ const STR = { namesClaim: 'Claim', namesClaimed: 'You are now {name}', namesCopy: 'Copy address', + namesChangeTo: 'Change to {addr}', namesMoreOpen: 'More options', namesMoreTitle: 'More ways to get paid', namesMoreBolt11: 'Lightning invoice (BOLT 11)', @@ -1597,6 +1689,9 @@ const STR = { notifChatHint: 'Community chat is opt-in per room. Turn it on from a room’s menu.', notifPaySent: 'Payments sent by connected apps', notifPaySentHint: 'A heads-up when a connected app (a nostr client zapping, for example) spends from this wallet while it’s closed.', + nwcWorkerLog: 'Background activity log', + nwcWorkerLogHint: 'What the wallet did while it was closed: wake-ups, payments, notifications. Copy it when reporting a problem.', + nwcWorkerLogEmpty: 'Nothing recorded yet', nwcNotifHint: 'You’ll be asked to allow notifications. The connection uses them to answer when Coinos is closed.', nwcNotifNeeded: 'Without notifications the connection couldn’t answer when Coinos is closed, so it wasn’t added. Allow notifications and try again.', nwcNotifBlocked: 'Notifications are blocked for this site. Allow them in your browser settings, then add the connection again.', @@ -1682,6 +1777,35 @@ const STR = { lnWithdrawPaid: 'Received {amount}', lnWithdrawNotPaid: 'They took the invoice but the payment didn’t arrive. Check with them before trying again.', lnWithdrawNoInvoice: 'Couldn’t make an invoice to be paid to.', + wlTitle: 'Withdraw link', + wlReady: 'Your withdraw link is ready', + wlDesc: 'A Lightning link or QR code that anyone can withdraw sats from, like a voucher. It works with most Lightning wallets, even while your phone is off. You pay for it up front; Coinos holds the sats until they’re claimed, and you can take back whatever is left at any time.', + wlAmount: 'Amount per withdrawal', + wlUses: 'How many withdrawals', + wlNote: 'Description (optional)', + wlPayNow: 'Paid now from Spending.', + wlPayNowMany: '{total} paid now from Spending: {n} withdrawals of up to {amount}.', + wlCreate: 'Create withdraw link', + wlCreateFor: 'Create link for {amount}', + wlAmountInvalid: 'Enter an amount.', + wlUsesInvalid: 'Between 1 and 1000 withdrawals.', + wlNoFunds: 'Not enough in Spending for {amount}.', + wlNotConfirmed: 'Paid, but the link isn’t confirmed yet. It will appear under your links in a moment.', + wlTermsOne: 'Up to {amount}, once', + wlTermsMany: 'Up to {amount} each, {n} of {total} withdrawals left', + wlLeft: '{amount} left', + wlCopy: 'Copy LNURL', + wlBearer: 'Anyone with this link or QR code can withdraw from it. Share it only with whoever it’s for.', + wlTakeBack: 'Take back {amount}', + wlTakeBackMemo: 'Withdraw link taken back', + wlTakenBack: '{amount} is back in Spending', + wlTakeBackFailed: 'Couldn’t pay it back just now. The link stays open; try again in a moment.', + wlClosed: 'This link is closed.', + wlUsedUp: 'This link has been used up.', + wlClosedTag: 'closed', + wlUsedUpTag: 'used up', + wlYours: 'Your withdraw links', + wlMainnetOnly: 'Withdraw links are only available on mainnet.', lnWithdrawWatchOnly: 'This device can’t receive: the wallet is watch-only here.', lnWithdrawMemo: 'Withdrawal from {domain}', lnPayAmount: 'Amount', diff --git a/src/locales/ar.json b/src/locales/ar.json index f112568..a60f6ba 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -364,6 +364,10 @@ "giftWelcome": "لقد أُرسلت إليك عملة Bitcoin", "giftBackedUp": "لقد دوّنتها", "giftLink": "أهدِ بعض عملات Bitcoin", + "giftNotePlaceholder": "أضف ملاحظة (اختياري)", + "giftFromMe": "قل إنها مني (المطالبة بها تتابعك)", + "giftFrom": "من", + "giftClaimFollows": "المطالبة بها تعني متابعة المُرسِل", "giftLinkDesc": "وقّع مبلغاً مسبقاً كرابط. من يفتحه يطالب بعملات Bitcoin في محفظة جديدة تماماً يتحكم بها وحده.", "giftLinkWarn": "⚠ أي شخص يفتح هذا الرابط يمكنه المطالبة بالمبلغ، لذا شاركه فقط مع الشخص الذي تهديه إياه. أبقِ المبالغ صغيرة.", "giftLinkReveal": "إنشاء رابط الإهداء", @@ -425,6 +429,7 @@ "claimOr": "أو", "goToWallet": "اذهب إلى المحفظة", "claimBtn": "طالب بها", + "claimNowBtn": "استلمها الآن", "claimFeeNote": "♪{n} الرسوم", "claimFailed": "تمت المطالبة بهذه الهدية بالفعل أو لم تعد متاحة.", "claimNotVisibleBody": "والعملات التي تمول هذه الهدية غير مرئية من مصدر هذه المحفظة/البيانات. تأكد من استخدام هذه المحفظة نفس الشبكة ومصدر البيانات كمرسل، ثم محاولة مرة أخرى.", @@ -618,6 +623,8 @@ "onbSpendBody": "اطالب بلقب مستخدم والحصول على عنوان الدفع الخاص بك - الناس يمكن أن ترسل لك قليلا على الفور، مع مجرد اسمك.", "onbNameWait": "جهز الأشياء", "onbNotNow": "ليس الآن", + "onbGiftNameTitle": "الآن اختر اسم مستخدم", + "onbGiftNameBody": "احجز اسمًا واحصل على عنوان دفع خاص بك، ليتمكن الناس من إرسال البيتكوين إليك باسمك فقط. ثم اختر صورة وغلافًا.", "onbAvatarTitle": "اختر مظهرك", "onbAvatarBody": "اختر صورة رمزية أو ارفع صورتك. يمكنك تغييرها في أي وقت", "onbUpload": "تحميل الصورة", @@ -678,6 +685,7 @@ "namesClaim": "المطالبة", "namesClaimed": "أنت الآن {name}", "namesCopy": "عنوان الرسالة", + "namesChangeTo": "غيّره إلى {addr}", "namesMoreOpen": "المزيد من الخيارات", "namesMoreTitle": "المزيد من الطرق للحصول على أجر", "namesMoreBolt11": "فاتورة البرق (BOLT 11)", @@ -878,6 +886,17 @@ "followHistNone": "لا توجد نسخة أقدم فيها أحد تفتقده قائمتك.", "followHistNothing": "كل من في تلك النسخة موجود في قائمتك بالفعل.", "followHistRestored": "تمت استعادة {n} متابَعًا.", + "profHistDesc": "إذا استبدل تطبيق آخر ملفك الشخصي، فقد تبقى نسخة أقدم على مرحّل يحتفظ بالسجل. استخدامها يملأ فقط الحقول الفارغة هنا.", + "profHistLook": "البحث عن نسخ أقدم", + "profHistUse": "استخدام", + "profHistNoName": "(بلا اسم)", + "profHistNone": "لا توجد نسخة أقدم فيها شيء ينقص هنا.", + "profHistNothing": "كل ما في تلك النسخة موجود هنا بالفعل.", + "profHistFilled": "تم ملء {n} حقول من النسخة الأقدم. احفظ للنشر.", + "profHist_name": "الاسم", + "profHist_about": "النبذة", + "profHist_picture": "الصورة", + "profHist_banner": "الغلاف", "securityTitle": "الأمان", "arkCoinsNone": "لا توجد عملات في الإنفاق بعد.", "profKeyLink": "المفتاح العام", @@ -910,6 +929,8 @@ "mediaNoImages": "لا صور في هذه الخلاصة بعد.", "mediaNoVideos": "لا فيديوهات عمودية في هذه الخلاصة بعد.", "videoTapToPlay": "انقر للتشغيل", + "videoPlay": "تشغيل", + "videoPause": "إيقاف مؤقت", "videoLargeTitle": "الفيديوهات", "videoLargeToggle": "تشغيل الفيديوهات الكبيرة تلقائيًا", "videoLargeHelp": "الفيديوهات التي تزيد على {mb} ميغابايت أو تتجاوز 1080p تنتظر نقرة، حتى لا تستهلك الخلاصة بياناتك. الحد أقل على الاتصال البطيء أو عند توفير البيانات.", @@ -924,5 +945,12 @@ "arkRenewViewTx": "عرض معاملة الجولة", "pagerFetchingVideo": "جارٍ جلب الفيديو التالي…", "pagerFetchingPictures": "جارٍ جلب المزيد من الصور…", - "pagerCaughtUp": "هذا كل شيء الآن" + "pagerCaughtUp": "هذا كل شيء الآن", + "schedToggle": "جدولة لوقت لاحق", + "schedAt": "النشر في", + "schedBtn": "جدولة", + "schedDone": "مجدول في {when}", + "schedTooSoon": "اختر وقتًا بعد دقيقتين على الأقل من الآن", + "schedListTitle": "مجدولة ({n})", + "schedCancelled": "أُلغي المنشور المجدول" } diff --git a/src/locales/bn.json b/src/locales/bn.json index 85ce58a..0f627b2 100644 --- a/src/locales/bn.json +++ b/src/locales/bn.json @@ -364,6 +364,10 @@ "giftWelcome": "আপনাকে Bitcoin পাঠানো হয়েছে", "giftBackedUp": "আমি এটি লিখে রেখেছি", "giftLink": "কিছু Bitcoin উপহার দিন", + "giftNotePlaceholder": "একটি নোট যোগ করুন (ঐচ্ছিক)", + "giftFromMe": "জানাও এটা আমার কাছ থেকে (দাবি করলে তোমাকে ফলো করবে)", + "giftFrom": "পাঠিয়েছেন", + "giftClaimFollows": "দাবি করলে তাঁকে ফলো করা হবে", "giftLinkDesc": "একটি পরিমাণ লিঙ্ক হিসেবে আগে থেকে সই করুন। যে এটি খুলবে সে Bitcoin-টি একটি একদম নতুন ওয়ালেটে দাবি করে, যা কেবল সে-ই নিয়ন্ত্রণ করে।", "giftLinkWarn": "⚠ যে কেউ এই লিঙ্ক খুললে পরিমাণটি দাবি করতে পারে, তাই কেবল যাকে উপহার দিচ্ছেন তার সাথেই এটি শেয়ার করুন। পরিমাণ কম রাখুন।", "giftLinkReveal": "লিংক নির্মাণ করুন", @@ -425,6 +429,7 @@ "claimOr": "অথবা", "goToWallet": "ওয়ালেট যাও", "claimBtn": "দাবি করুন", + "claimNowBtn": "এখনই নিন", "claimFeeNote": "একটিও নয়{n} কাগজ", "claimFailed": "এই উপহারটি আগেই দাবি করা হয়েছে অথবা আর পাওয়া যাচ্ছে না।", "claimNotVisibleBody": "এই দানের অর্থ এই ওয়ালেটের নেটওয়ার্ক/ডাটা উৎস থেকে দেখা যাচ্ছে না। নিশ্চিত করুন যে এই ওয়ালেট একই নেটওয়ার্ক এবং ডাটা উৎস ব্যবহার করে, অতঃপর পুনরায় চেষ্টা করুন ।", @@ -618,6 +623,8 @@ "onbSpendBody": "আপনার নাম শুধু আপনার নামের সঙ্গে সঙ্গে সঙ্গে সঙ্গে আপনি আপনার পেকেন পাঠাতে পারেন ।", "onbNameWait": "জিনিস প্রস্তুত করা হচ্ছে...", "onbNotNow": "এই মুহূর্তে নয়", + "onbGiftNameTitle": "এবার একটি ইউজারনেম বেছে নিন", + "onbGiftNameBody": "একটি নাম নিন এবং নিজের পেমেন্ট ঠিকানা পান, যাতে লোকে শুধু আপনার নাম দিয়েই আপনাকে বিটকয়েন পাঠাতে পারে। তারপর একটি ছবি আর একটি কভার বেছে নিন।", "onbAvatarTitle": "আপনার চেহারা পছন্দ করুন", "onbAvatarBody": "একটি অবতার বেছে নাও অথবা নিজের ছবি আপলোড করো। যেকোনো সময় এটি বদলাতে পারো।", "onbUpload": "একটি ছবি আপলোড করুন", @@ -678,6 +685,7 @@ "namesClaim": "মুছে ফেলুন (_D)", "namesClaimed": "তুমি এখন {name}", "namesCopy": "ঠিকানা কপি করো", + "namesChangeTo": "{addr}-এ বদলান", "namesMoreOpen": "অতিরিক্ত বিকল্প", "namesMoreTitle": "আরো টাকা পেতে", "namesMoreBolt11": "(বিওএল ১১)", @@ -878,6 +886,17 @@ "followHistNone": "কোনো পুরোনো কপিতে এমন কেউ নেই যিনি আপনার তালিকায় নেই।", "followHistNothing": "ওই কপির সবাই ইতিমধ্যে আপনার তালিকায় আছেন।", "followHistRestored": "{n} জনকে আবার ফলো করা হয়েছে।", + "profHistDesc": "অন্য কোনো অ্যাপ আপনার প্রোফাইল বদলে দিলে, ইতিহাস রাখে এমন কোনো রিলেতে পুরোনো কপি এখনও থাকতে পারে। সেটি ব্যবহার করলে এখানে শুধু খালি ঘরগুলো পূরণ হয়।", + "profHistLook": "পুরোনো কপি খুঁজুন", + "profHistUse": "ব্যবহার করুন", + "profHistNoName": "(নাম নেই)", + "profHistNone": "কোনো পুরোনো কপিতে এমন কিছু নেই যা এখানে নেই।", + "profHistNothing": "ওই কপির সবকিছু এখানে আগে থেকেই আছে।", + "profHistFilled": "পুরোনো কপি থেকে {n}টি ঘর পূরণ হয়েছে। প্রকাশ করতে সংরক্ষণ করুন।", + "profHist_name": "নাম", + "profHist_about": "পরিচিতি", + "profHist_picture": "ছবি", + "profHist_banner": "ব্যানার", "securityTitle": "নিরাপত্তা", "arkCoinsNone": "স্পেন্ডিং-এ এখনও কোনো কয়েন নেই।", "profKeyLink": "পাবলিক কী", @@ -910,6 +929,8 @@ "mediaNoImages": "এই ফিডে এখনও কোনো ছবি নেই।", "mediaNoVideos": "এই ফিডে এখনও কোনো খাড়া ভিডিও নেই।", "videoTapToPlay": "চালাতে ট্যাপ করুন", + "videoPlay": "চালান", + "videoPause": "বিরতি", "videoLargeTitle": "ভিডিও", "videoLargeToggle": "বড় ভিডিও নিজে থেকে চালান", "videoLargeHelp": "{mb} MB-এর বেশি বা 1080p-এর ওপরের ভিডিও ট্যাপের অপেক্ষায় থাকে, যাতে ফিড আপনার ডেটা না খায়। ধীর বা ডেটা-সেভার সংযোগে সীমা কম।", @@ -924,5 +945,12 @@ "arkRenewViewTx": "রাউন্ডের লেনদেন দেখুন", "pagerFetchingVideo": "পরের ভিডিও আনা হচ্ছে…", "pagerFetchingPictures": "আরও ছবি আনা হচ্ছে…", - "pagerCaughtUp": "আপাতত এটুকুই" + "pagerCaughtUp": "আপাতত এটুকুই", + "schedToggle": "পরে পোস্ট করার সময় ঠিক করুন", + "schedAt": "পোস্টের সময়", + "schedBtn": "সময় ঠিক করুন", + "schedDone": "{when}-এ নির্ধারিত", + "schedTooSoon": "এখন থেকে অন্তত দুই মিনিট পরের সময় বেছে নিন", + "schedListTitle": "নির্ধারিত ({n})", + "schedCancelled": "নির্ধারিত পোস্ট বাতিল হয়েছে" } diff --git a/src/locales/de.json b/src/locales/de.json index 0210332..11fde22 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -364,6 +364,10 @@ "giftWelcome": "Dir wurde Bitcoin gesendet", "giftBackedUp": "Ich habe sie aufgeschrieben", "giftLink": "Bitcoin verschenken", + "giftNotePlaceholder": "Notiz hinzufügen (optional)", + "giftFromMe": "Zeigen, dass es von mir ist (wer es einlöst, folgt dir)", + "giftFrom": "Von", + "giftClaimFollows": "Beim Einlösen folgst du dieser Person", "giftLinkDesc": "Signiere einen Betrag im Voraus als Link. Wer ihn öffnet, beansprucht das Bitcoin in einer brandneuen Wallet, die nur er selbst kontrolliert.", "giftLinkWarn": "⚠ Wer diesen Link öffnet, kann den Betrag beanspruchen. Teile ihn daher nur mit der Person, die du beschenkst. Halte die Beträge klein.", "giftLinkReveal": "Geschenk-Link erstellen", @@ -425,6 +429,7 @@ "claimOr": "oder", "goToWallet": "Zur Brieftasche gehen", "claimBtn": "Beanspruchen", + "claimNowBtn": "Jetzt einlösen", "claimFeeNote": "~{n} Gebühr", "claimFailed": "Dieses Geschenk wurde bereits beansprucht oder ist nicht mehr verfügbar.", "claimNotVisibleBody": "Die Münzen finanzieren dieses Geschenk sind nicht sichtbar aus dieser Brieftasche Netzwerk / Datenquelle. Stellen Sie sicher, dass diese Brieftasche die gleiche Netzwerk- und Datenquelle wie der Absender verwendet, dann versuchen Sie es erneut.", @@ -618,6 +623,8 @@ "onbSpendBody": "Fordern Sie einen Benutzernamen an und erhalten Sie Ihre eigene Zahlungsadresse Die Leute können Ihnen Bitcoin sofort mit Ihrem Namen senden.", "onbNameWait": "Bereitmachen...", "onbNotNow": "Nicht jetzt", + "onbGiftNameTitle": "Wähle jetzt einen Benutzernamen", + "onbGiftNameBody": "Sichere dir einen Namen und deine eigene Zahlungsadresse, damit man dir Bitcoin einfach an deinen Namen senden kann. Danach wählst du ein Bild und ein Titelbild.", "onbAvatarTitle": "Wählen Sie Ihren Look", "onbAvatarBody": "Wählen Sie einen Avatar oder laden Sie Ihr eigenes Bild hoch. Sie können ihn jederzeit ändern.", "onbUpload": "Foto hochladen", @@ -640,7 +647,7 @@ "nlPasskey": "Anmelden mit einem Passkey", "nlKeyOrBunker": "nsec, private Schlüssel oder Bunker://...", "nlGo": "Anmelden", - "nlHint": "Funktioniert mit Amber, Amethyst und anderen Remote-Signern (paste ihre Bunker://-Adresse), oder einer Browser-Erweiterung. Ein geklebter Schlüssel wird einmal verwendet und nie gespeichert.", + "nlHint": "Funktioniert mit Remote-Signer-Apps (füge ihre bunker://-Adresse ein) oder einer Browser-Erweiterung. Ein eingefügter Schlüssel wird einmal verwendet und nie gespeichert.", "nlApprove": "billigt den Antrag in Ihrem Schilder →", "nlUnrecognized": "Das sieht nicht aus wie ein privater Schlüssel oder eine Bunkeradresse.", "nlNewTitle": "Erstellen Sie eine Brieftasche für dieses Konto", @@ -678,6 +685,7 @@ "namesClaim": "Anspruch", "namesClaimed": "Du bist jetzt {name}", "namesCopy": "Adresse kopieren", + "namesChangeTo": "Ändern zu {addr}", "namesMoreOpen": "Weitere Optionen", "namesMoreTitle": "Mehr Möglichkeiten, um bezahlt zu werden", "namesMoreBolt11": "Blitzrechnung (BOLT 11)", @@ -878,6 +886,17 @@ "followHistNone": "Keine ältere Kopie enthält jemanden, der in deiner Liste fehlt.", "followHistNothing": "Alle aus dieser Kopie sind schon in deiner Liste.", "followHistRestored": "{n} Follows wiederhergestellt.", + "profHistDesc": "Hat eine andere App dein Profil ersetzt, liegt vielleicht noch eine ältere Kopie auf einem Relay, das den Verlauf aufbewahrt. Eine zu übernehmen füllt nur die Felder, die hier leer sind.", + "profHistLook": "Nach älteren Kopien suchen", + "profHistUse": "Übernehmen", + "profHistNoName": "(kein Name)", + "profHistNone": "Keine ältere Kopie enthält etwas, das hier fehlt.", + "profHistNothing": "Alles aus dieser Kopie ist schon hier.", + "profHistFilled": "{n} Felder aus der älteren Kopie gefüllt. Speichern, um zu veröffentlichen.", + "profHist_name": "Name", + "profHist_about": "Bio", + "profHist_picture": "Bild", + "profHist_banner": "Banner", "securityTitle": "Sicherheit", "arkCoinsNone": "Noch keine Coins in Ausgaben.", "profKeyLink": "Öffentlicher Schlüssel", @@ -910,6 +929,8 @@ "mediaNoImages": "Noch keine Bilder in diesem Feed.", "mediaNoVideos": "Noch keine Hochkant-Videos in diesem Feed.", "videoTapToPlay": "Zum Abspielen tippen", + "videoPlay": "Abspielen", + "videoPause": "Pause", "videoLargeTitle": "Videos", "videoLargeToggle": "Große Videos automatisch abspielen", "videoLargeHelp": "Videos über {mb} MB oder über 1080p warten auf einen Tipp, damit der Feed nicht dein Datenvolumen frisst. Bei langsamer Verbindung oder Datensparmodus ist die Grenze niedriger.", @@ -924,5 +945,12 @@ "arkRenewViewTx": "Transaktion der Runde ansehen", "pagerFetchingVideo": "Nächstes Video wird geladen …", "pagerFetchingPictures": "Weitere Bilder werden geladen …", - "pagerCaughtUp": "Das ist vorerst alles" + "pagerCaughtUp": "Das ist vorerst alles", + "schedToggle": "Für später planen", + "schedAt": "Posten am", + "schedBtn": "Planen", + "schedDone": "Geplant für {when}", + "schedTooSoon": "Wähle eine Zeit mindestens zwei Minuten ab jetzt", + "schedListTitle": "Geplant ({n})", + "schedCancelled": "Geplanter Beitrag abgebrochen" } diff --git a/src/locales/es.json b/src/locales/es.json index 9637b0a..28e2489 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -364,6 +364,10 @@ "giftWelcome": "Te han enviado Bitcoin", "giftBackedUp": "Ya la anoté", "giftLink": "Regala algo de Bitcoin", + "giftNotePlaceholder": "Añade una nota (opcional)", + "giftFromMe": "Decir que es mío (quien lo reclame te seguirá)", + "giftFrom": "De", + "giftClaimFollows": "Al reclamarlo, seguirás a esta persona", "giftLinkDesc": "Prefirma un importe como enlace. Quien lo abra reclama el Bitcoin en una billetera totalmente nueva que solo esa persona controla.", "giftLinkWarn": "⚠ Cualquiera que abra este enlace puede reclamar el importe, así que compártelo solo con la persona a quien se lo regalas. Mantén los importes pequeños.", "giftLinkReveal": "Crear enlace de regalo", @@ -425,6 +429,7 @@ "claimOr": "o", "goToWallet": "Ve a la cartera.", "claimBtn": "Reclamarlo", + "claimNowBtn": "Reclámalo ahora", "claimFeeNote": "~{n} tarifa", "claimFailed": "Este regalo ya ha sido reclamado o ya no está disponible.", "claimNotVisibleBody": "Las monedas que financian este regalo no son visibles desde la fuente de red/datos de esta cartera. Asegúrese de que esta cartera utiliza la misma red y fuente de datos que el remitente, a continuación, inténtelo de nuevo.", @@ -618,6 +623,8 @@ "onbSpendBody": "Reclame un nombre de usuario y obtenga su propia dirección de pago, la gente puede enviarle bitcoin al instante, con sólo su nombre.", "onbNameWait": "Preparando las cosas...", "onbNotNow": "Ahora no.", + "onbGiftNameTitle": "Ahora elige un nombre de usuario", + "onbGiftNameBody": "Reclama un nombre y obtén tu propia dirección de pago, para que te puedan enviar bitcoin solo con tu nombre. Después elige una foto y una portada.", "onbAvatarTitle": "Elige tu look", "onbAvatarBody": "Escoge un avatar o sube tu propia imagen. Puedes cambiarlo en cualquier momento.", "onbUpload": "Sube una foto", @@ -640,7 +647,7 @@ "nlPasskey": "Inicie sesión con un passkey", "nlKeyOrBunker": "nsec, private key, or bunker://...", "nlGo": "Iniciar sesión", - "nlHint": "Trabaja con Amber, Amethyst y otros firmantes remotos (pasar su bunker:// dirección), o una extensión del navegador. Una llave pegada se utiliza una vez y nunca se almacena.", + "nlHint": "Funciona con apps firmantes remotas (pega su dirección bunker://) o una extensión del navegador. Una clave pegada se usa una vez y nunca se guarda.", "nlApprove": "Aprobar la solicitud en su firmante →", "nlUnrecognized": "Eso no parece una llave privada o una dirección bunker://.", "nlNewTitle": "Crear una cartera para esta cuenta", @@ -678,6 +685,7 @@ "namesClaim": "Reclamación", "namesClaimed": "Ahora sí. {name}", "namesCopy": "Dirección de copia", + "namesChangeTo": "Cambiar a {addr}", "namesMoreOpen": "Más opciones", "namesMoreTitle": "Más formas de pagar", "namesMoreBolt11": "Factura de relámpago (BOLT 11)", @@ -878,6 +886,17 @@ "followHistNone": "Ninguna copia anterior tiene a alguien que falte en tu lista.", "followHistNothing": "Todos los de esa copia ya están en tu lista.", "followHistRestored": "Se restauraron {n} seguidos.", + "profHistDesc": "Si otra app reemplazó tu perfil, puede que aún haya una copia anterior en un relay que guarda el historial. Usarla solo rellena los campos que aquí están vacíos.", + "profHistLook": "Buscar copias anteriores", + "profHistUse": "Usar", + "profHistNoName": "(sin nombre)", + "profHistNone": "Ninguna copia anterior tiene algo que falte aquí.", + "profHistNothing": "Todo lo de esa copia ya está aquí.", + "profHistFilled": "{n} campos rellenados desde la copia anterior. Guarda para publicar.", + "profHist_name": "nombre", + "profHist_about": "bio", + "profHist_picture": "foto", + "profHist_banner": "banner", "securityTitle": "Seguridad", "arkCoinsNone": "Aún no hay monedas en Gastos.", "profKeyLink": "Clave pública", @@ -910,6 +929,8 @@ "mediaNoImages": "Aún no hay imágenes en este feed.", "mediaNoVideos": "Aún no hay vídeos verticales en este feed.", "videoTapToPlay": "Toca para reproducir", + "videoPlay": "Reproducir", + "videoPause": "Pausa", "videoLargeTitle": "Vídeos", "videoLargeToggle": "Reproducir vídeos grandes automáticamente", "videoLargeHelp": "Los vídeos de más de {mb} MB o por encima de 1080p esperan a que los toques, para que el feed no se coma tus datos. El límite es menor con una conexión lenta o en ahorro de datos.", @@ -924,5 +945,12 @@ "arkRenewViewTx": "Ver la transacción de la ronda", "pagerFetchingVideo": "Cargando el siguiente vídeo…", "pagerFetchingPictures": "Cargando más imágenes…", - "pagerCaughtUp": "Eso es todo por ahora" + "pagerCaughtUp": "Eso es todo por ahora", + "schedToggle": "Programar para más tarde", + "schedAt": "Publicar el", + "schedBtn": "Programar", + "schedDone": "Programado para {when}", + "schedTooSoon": "Elige una hora al menos dos minutos a partir de ahora", + "schedListTitle": "Programadas ({n})", + "schedCancelled": "Publicación programada cancelada" } diff --git a/src/locales/fr.json b/src/locales/fr.json index 280be04..54c6eda 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -364,6 +364,10 @@ "giftWelcome": "Vous avez reçu des Bitcoin", "giftBackedUp": "Je l’ai notée", "giftLink": "Offrir des Bitcoin", + "giftNotePlaceholder": "Ajouter un mot (facultatif)", + "giftFromMe": "Dire que ça vient de moi (qui le réclame vous suivra)", + "giftFrom": "De", + "giftClaimFollows": "En le réclamant, vous suivrez cette personne", "giftLinkDesc": "Présignez un montant sous forme de lien. Celui qui l’ouvre récupère les Bitcoin dans un tout nouveau portefeuille qu’il est seul à contrôler.", "giftLinkWarn": "⚠ Quiconque ouvre ce lien peut récupérer le montant, alors ne le partagez qu’avec la personne à qui vous l’offrez. Gardez les montants faibles.", "giftLinkReveal": "Créer le lien cadeau", @@ -425,6 +429,7 @@ "claimOr": "ou", "goToWallet": "Allez dans le portefeuille", "claimBtn": "Récupérer", + "claimNowBtn": "Récupérer maintenant", "claimFeeNote": "~{n} Taxe", "claimFailed": "Ce cadeau a déjà été réclamé ou n’est plus disponible.", "claimNotVisibleBody": "Les pièces de monnaie qui financent ce cadeau ne sont pas visibles à partir de ce portefeuille. Assurez-vous que ce portefeuille utilise le même réseau et la même source de données que l'expéditeur, puis essayez à nouveau.", @@ -618,6 +623,8 @@ "onbSpendBody": "Réclamez un nom d'utilisateur et obtenez votre propre adresse de paiement, les gens peuvent vous envoyer Bitcoin instantanément, avec juste votre nom.", "onbNameWait": "Préparer les choses...", "onbNotNow": "Pas maintenant.", + "onbGiftNameTitle": "Choisissez maintenant un nom d'utilisateur", + "onbGiftNameBody": "Réservez un nom et obtenez votre propre adresse de paiement, pour qu'on puisse vous envoyer des bitcoins avec votre seul nom. Choisissez ensuite une photo et une couverture.", "onbAvatarTitle": "Choisissez votre look", "onbAvatarBody": "Choisissez un avatar ou téléchargez votre propre photo. Vous pouvez le changer à tout moment.", "onbUpload": "Télécharger une photo", @@ -640,7 +647,7 @@ "nlPasskey": "Connectez-vous avec un mot de passe", "nlKeyOrBunker": "nsec, clé privée, ou bunker://...", "nlGo": "Connexion", - "nlHint": "Fonctionne avec Amber, Amethyst et d'autres signataires distants (collez leur adresse de bunker://), ou une extension de navigateur. Une clé collée est utilisée une fois et jamais stockée.", + "nlHint": "Fonctionne avec les applis de signature à distance (collez leur adresse bunker://) ou une extension de navigateur. Une clé collée est utilisée une fois et jamais enregistrée.", "nlApprove": "Approuver la demande dans votre signataire →", "nlUnrecognized": "Ça ne ressemble pas à une clé privée ou à une adresse de bunker://.", "nlNewTitle": "Créer un portefeuille pour ce compte", @@ -678,6 +685,7 @@ "namesClaim": "Réclamation", "namesClaimed": "Vous êtes maintenant {name}", "namesCopy": "Copier l'adresse", + "namesChangeTo": "Passer à {addr}", "namesMoreOpen": "Autres options", "namesMoreTitle": "Plus de moyens de se faire payer", "namesMoreBolt11": "Facture de foudre (BOLT 11)", @@ -878,6 +886,17 @@ "followHistNone": "Aucune copie plus ancienne ne contient quelqu’un qui manque à votre liste.", "followHistNothing": "Tout le monde dans cette copie est déjà dans votre liste.", "followHistRestored": "{n} abonnements restaurés.", + "profHistDesc": "Si une autre app a remplacé votre profil, une copie plus ancienne est peut-être encore sur un relais qui garde l’historique. En utiliser une ne remplit que les champs vides ici.", + "profHistLook": "Chercher des copies plus anciennes", + "profHistUse": "Utiliser", + "profHistNoName": "(sans nom)", + "profHistNone": "Aucune copie plus ancienne n’a quelque chose qui manque ici.", + "profHistNothing": "Tout ce que contient cette copie est déjà ici.", + "profHistFilled": "{n} champs remplis depuis la copie plus ancienne. Enregistrez pour publier.", + "profHist_name": "nom", + "profHist_about": "bio", + "profHist_picture": "photo", + "profHist_banner": "bannière", "securityTitle": "Sécurité", "arkCoinsNone": "Aucune pièce dans Dépenses pour l’instant.", "profKeyLink": "Clé publique", @@ -910,6 +929,8 @@ "mediaNoImages": "Pas encore d’images dans ce fil.", "mediaNoVideos": "Pas encore de vidéos verticales dans ce fil.", "videoTapToPlay": "Touchez pour lire", + "videoPlay": "Lire", + "videoPause": "Pause", "videoLargeTitle": "Vidéos", "videoLargeToggle": "Lire automatiquement les grosses vidéos", "videoLargeHelp": "Les vidéos de plus de {mb} Mo ou au-delà de 1080p attendent un toucher, pour que le fil ne dévore pas vos données. La limite est plus basse sur une connexion lente ou en économie de données.", @@ -924,5 +945,12 @@ "arkRenewViewTx": "Voir la transaction du tour", "pagerFetchingVideo": "Chargement de la vidéo suivante…", "pagerFetchingPictures": "Chargement d’autres images…", - "pagerCaughtUp": "C’est tout pour le moment" + "pagerCaughtUp": "C’est tout pour le moment", + "schedToggle": "Programmer pour plus tard", + "schedAt": "Publier le", + "schedBtn": "Programmer", + "schedDone": "Programmé pour {when}", + "schedTooSoon": "Choisis une heure au moins deux minutes après maintenant", + "schedListTitle": "Programmés ({n})", + "schedCancelled": "Publication programmée annulée" } diff --git a/src/locales/hi.json b/src/locales/hi.json index 8057344..527eb27 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -364,6 +364,10 @@ "giftWelcome": "आपको Bitcoin भेजा गया है", "giftBackedUp": "मैंने इसे लिख लिया है", "giftLink": "कुछ Bitcoin उपहार में दें", + "giftNotePlaceholder": "एक नोट जोड़ें (वैकल्पिक)", + "giftFromMe": "बताएं कि यह मेरी ओर से है (दावा करने पर आपको फ़ॉलो करेंगे)", + "giftFrom": "भेजने वाले", + "giftClaimFollows": "दावा करने पर आप उन्हें फ़ॉलो करेंगे", "giftLinkDesc": "एक राशि को लिंक के रूप में पहले से साइन करें। जो भी इसे खोलेगा, वह Bitcoin को एक बिल्कुल नए वॉलेट में प्राप्त कर लेगा जिसे केवल वही नियंत्रित करता है।", "giftLinkWarn": "⚠ जो भी इस लिंक को खोलेगा वह राशि प्राप्त कर सकता है, इसलिए इसे केवल उसी व्यक्ति के साथ साझा करें जिसे आप उपहार दे रहे हैं। राशि कम रखें।", "giftLinkReveal": "उपहार लिंक बनाएँ", @@ -425,6 +429,7 @@ "claimOr": "या", "goToWallet": "वॉलेट पर जाएं", "claimBtn": "इसे प्राप्त करें", + "claimNowBtn": "अभी लें", "claimFeeNote": "~{n} शुल्क", "claimFailed": "इस उपहार का दावा पहले ही किया जा चुका है या यह अब उपलब्ध नहीं है।", "claimNotVisibleBody": "इस उपहार को वित्तपोषित करने वाले सिक्के इस वॉलेट के नेटवर्क / डेटा स्रोत से दिखाई नहीं देते हैं। सुनिश्चित करें कि यह वॉलेट प्रेषक के समान नेटवर्क और डेटा स्रोत का उपयोग करता है, फिर फिर से प्रयास करें।", @@ -618,6 +623,8 @@ "onbSpendBody": "एक उपयोगकर्ता नाम का दावा करें और अपना खुद का भुगतान पता प्राप्त करें - लोग तुरंत अपने नाम के साथ बिटकॉइन भेज सकते हैं।", "onbNameWait": "चीजों को तैयार करना", "onbNotNow": "अब नहीं", + "onbGiftNameTitle": "अब एक यूज़रनेम चुनें", + "onbGiftNameBody": "एक नाम लें और अपना भुगतान पता पाएं, ताकि लोग सिर्फ़ आपके नाम से आपको बिटकॉइन भेज सकें। फिर एक तस्वीर और एक कवर चुनें।", "onbAvatarTitle": "अपना लुक चुनें", "onbAvatarBody": "एक अवतार चुनें या अपनी तस्वीर अपलोड करें। आप इसे कभी भी बदल सकते हैं।", "onbUpload": "फ़ोटो अपलोड करें", @@ -678,6 +685,7 @@ "namesClaim": "दावा", "namesClaimed": "अब {name}", "namesCopy": "पता कॉपी करें", + "namesChangeTo": "{addr} में बदलें", "namesMoreOpen": "अधिक विकल्प", "namesMoreTitle": "भुगतान करने के तरीके", "namesMoreBolt11": "बिजली चालान (BOLT 11)", @@ -878,6 +886,17 @@ "followHistNone": "किसी पुरानी कॉपी में ऐसा कोई नहीं जो आपकी सूची में न हो।", "followHistNothing": "उस कॉपी के सभी लोग पहले से आपकी सूची में हैं।", "followHistRestored": "{n} फ़ॉलो बहाल किए गए।", + "profHistDesc": "अगर किसी दूसरे ऐप ने आपकी प्रोफ़ाइल बदल दी है, तो इतिहास रखने वाले किसी रिले पर पुरानी प्रति अब भी हो सकती है। उसे इस्तेमाल करने से यहाँ सिर्फ़ खाली फ़ील्ड भरते हैं।", + "profHistLook": "पुरानी प्रतियाँ खोजें", + "profHistUse": "इस्तेमाल करें", + "profHistNoName": "(बिना नाम)", + "profHistNone": "किसी पुरानी प्रति में ऐसा कुछ नहीं जो यहाँ न हो।", + "profHistNothing": "उस प्रति की हर चीज़ यहाँ पहले से है।", + "profHistFilled": "पुरानी प्रति से {n} फ़ील्ड भरे गए। प्रकाशित करने के लिए सहेजें।", + "profHist_name": "नाम", + "profHist_about": "परिचय", + "profHist_picture": "फ़ोटो", + "profHist_banner": "बैनर", "securityTitle": "सुरक्षा", "arkCoinsNone": "स्पेंडिंग में अभी कोई कॉइन नहीं।", "profKeyLink": "पब्लिक की", @@ -910,6 +929,8 @@ "mediaNoImages": "इस फ़ीड में अभी कोई तस्वीर नहीं।", "mediaNoVideos": "इस फ़ीड में अभी कोई वर्टिकल वीडियो नहीं।", "videoTapToPlay": "चलाने के लिए टैप करें", + "videoPlay": "चलाएँ", + "videoPause": "रोकें", "videoLargeTitle": "वीडियो", "videoLargeToggle": "बड़े वीडियो अपने आप चलाएँ", "videoLargeHelp": "{mb} MB से बड़े या 1080p से ऊपर के वीडियो टैप का इंतज़ार करते हैं, ताकि फ़ीड आपका डेटा न खाए। धीमे या डेटा-सेवर कनेक्शन पर सीमा कम होती है।", @@ -924,5 +945,12 @@ "arkRenewViewTx": "राउंड का लेनदेन देखें", "pagerFetchingVideo": "अगला वीडियो लाया जा रहा है…", "pagerFetchingPictures": "और तस्वीरें लाई जा रही हैं…", - "pagerCaughtUp": "अभी के लिए बस इतना ही" + "pagerCaughtUp": "अभी के लिए बस इतना ही", + "schedToggle": "बाद के लिए शेड्यूल करें", + "schedAt": "पोस्ट करने का समय", + "schedBtn": "शेड्यूल करें", + "schedDone": "{when} के लिए शेड्यूल किया गया", + "schedTooSoon": "अभी से कम से कम दो मिनट बाद का समय चुनें", + "schedListTitle": "शेड्यूल किए गए ({n})", + "schedCancelled": "शेड्यूल की गई पोस्ट रद्द हुई" } diff --git a/src/locales/id.json b/src/locales/id.json index 6773d96..e2402eb 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -137,6 +137,10 @@ "giftSkip": "Lewati dulu", "giftBackedUp": "Saya sudah mencatatnya", "giftLink": "Hadiahkan Bitcoin", + "giftNotePlaceholder": "Tambahkan catatan (opsional)", + "giftFromMe": "Tunjukkan ini dari saya (yang mengklaim akan mengikuti Anda)", + "giftFrom": "Dari", + "giftClaimFollows": "Mengklaimnya akan mengikuti pengirim", "giftLinkDesc": "Tandatangani sejumlah dana sebagai tautan. Siapa pun yang membukanya mengklaim Bitcoin itu ke dompet baru yang hanya dia kendalikan.", "giftLinkWarn": "⚠ Siapa pun yang membuka tautan ini bisa mengklaim jumlahnya, jadi bagikan hanya kepada orang yang Anda beri hadiah. Jaga jumlahnya tetap kecil.", "giftAmountLabel": "Jumlah yang dihadiahkan", @@ -145,6 +149,7 @@ "giftReserved": "Tautan hadiah belum diklaim ({n})", "claimBody": "Klaim ke dompet baru yang hanya Anda kendalikan.", "claimBtn": "Klaim", + "claimNowBtn": "Klaim sekarang", "claimFailed": "Hadiah ini sudah diklaim atau tidak lagi tersedia.", "claimedTitle": "Diklaim!", "claimedBody": "Bitcoin Anda sedang dalam perjalanan. Cadangkan frasa pemulihan Anda agar tetap aman.", @@ -215,6 +220,17 @@ "followHistNone": "Tidak ada salinan lama yang berisi orang yang hilang dari daftarmu.", "followHistNothing": "Semua orang di salinan itu sudah ada di daftarmu.", "followHistRestored": "{n} ikutan dipulihkan.", + "profHistDesc": "Jika aplikasi lain mengganti profil Anda, salinan lama mungkin masih ada di relay yang menyimpan riwayat. Memakainya hanya mengisi kolom yang kosong di sini.", + "profHistLook": "Cari salinan lama", + "profHistUse": "Pakai", + "profHistNoName": "(tanpa nama)", + "profHistNone": "Tidak ada salinan lama yang punya sesuatu yang kurang di sini.", + "profHistNothing": "Semua isi salinan itu sudah ada di sini.", + "profHistFilled": "{n} kolom diisi dari salinan lama. Simpan untuk menerbitkan.", + "profHist_name": "nama", + "profHist_about": "bio", + "profHist_picture": "foto", + "profHist_banner": "spanduk", "securityTitle": "Keamanan", "arkCoinsNone": "Belum ada koin di Belanja.", "profKeyLink": "Kunci publik", @@ -247,6 +263,8 @@ "mediaNoImages": "Belum ada gambar di feed ini.", "mediaNoVideos": "Belum ada video vertikal di feed ini.", "videoTapToPlay": "Ketuk untuk memutar", + "videoPlay": "Putar", + "videoPause": "Jeda", "videoLargeTitle": "Video", "videoLargeToggle": "Putar otomatis video besar", "videoLargeHelp": "Video di atas {mb} MB atau lebih dari 1080p menunggu ketukan, agar feed tidak menghabiskan kuota. Batasnya lebih rendah di koneksi lambat atau mode hemat data.", @@ -261,5 +279,15 @@ "arkRenewViewTx": "Lihat transaksi ronde", "pagerFetchingVideo": "Mengambil video berikutnya…", "pagerFetchingPictures": "Mengambil gambar lainnya…", - "pagerCaughtUp": "Sementara itu saja" + "pagerCaughtUp": "Sementara itu saja", + "onbGiftNameTitle": "Sekarang pilih nama pengguna", + "onbGiftNameBody": "Klaim sebuah nama dan dapatkan alamat pembayaran sendiri, agar orang bisa mengirimimu bitcoin hanya dengan namamu. Lalu pilih foto dan sampul.", + "namesChangeTo": "Ganti ke {addr}", + "schedToggle": "Jadwalkan untuk nanti", + "schedAt": "Posting pada", + "schedBtn": "Jadwalkan", + "schedDone": "Dijadwalkan untuk {when}", + "schedTooSoon": "Pilih waktu setidaknya dua menit dari sekarang", + "schedListTitle": "Terjadwal ({n})", + "schedCancelled": "Postingan terjadwal dibatalkan" } diff --git a/src/locales/it.json b/src/locales/it.json index 7ac8b00..1844905 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -28,6 +28,20 @@ "logoutOneBlurbSeed": "Esce solo questo account. Gli altri restano connessi. Verrà rimosso da questo dispositivo, quindi assicurati di avere la sua frase di recupero.", "logoutAll": "Esci da tutti gli account", "clearAllWarn": "⚠ Questo rimuove tutti gli account da questo dispositivo. Ogni account di cui non hai salvato la frase di recupero o la chiave andrà perso per sempre.", + "delAcctTitle": "Elimina account", + "delAcctBlurb": "Rinuncia al tuo nome Coinos, fai dimenticare questo account ai nostri server e rimuovilo da questo dispositivo.", + "delAcctChecking": "Controllo del tuo account…", + "delAcctName": "{name} viene rilasciato. Chiunque potrà richiederlo in seguito e i pagamenti inviati a quel nome non ti arriveranno più.", + "delAcctRecords": "I nostri server eliminano ciò che conservano per questo account: indirizzo di pagamento, registrazioni delle notifiche, post programmati, cappelli, dati del punto vendita e delle ricevute.", + "delAcctDevice": "“{name}” viene rimosso da questo dispositivo.", + "delAcctStays": "Non eliminati: le tue monete, controllate solo dalla tua frase di recupero, e ciò che hai pubblicato sui relay nostr (profilo, post, messaggi).", + "delAcctOwed": "⚠ {sat} sat sono ancora in arrivo o trattenuti nei tuoi link di prelievo. Attendi i pagamenti, riprendi i link di prelievo (Invia → Link di prelievo) e riprova.", + "delAcctFunds": "⚠ Questo account contiene ancora {sat} sat. L'eliminazione non li sposta. Inviali altrove prima, oppure assicurati di avere la frase di recupero: senza di essa sono persi per sempre.", + "delAcctSure": "Ho capito che non si può annullare.", + "delAcctSureFunds": "Ho spostato i miei fondi o salvato la frase di recupero, e ho capito che non si può annullare.", + "delAcctGo": "Elimina account", + "delAcctDone": "Account eliminato", + "delAcctUnlockFirst": "Blocca Coinos e sbloccalo con la tua password, poi riprova", "removeWalletTitle": "Rimuovi account", "removeWalletWarn": "⚠ Rimuovere “{name}” da questo dispositivo? Sparirà da questo browser. Assicurati di avere la sua frase di recupero. Gli altri account non vengono toccati.", "rename": "Rinomina", @@ -96,6 +110,17 @@ "relaysReload": "Ricarica", "followHistNothing": "Tutti quelli presenti in quella copia sono già nella tua lista.", "followHistRestored": "Ripristinati {n} seguiti.", + "profHistDesc": "Se un’altra app ha sostituito il tuo profilo, una copia più vecchia potrebbe essere ancora su un relay che conserva la cronologia. Usarne una riempie solo i campi qui vuoti.", + "profHistLook": "Cerca copie più vecchie", + "profHistUse": "Usa", + "profHistNoName": "(senza nome)", + "profHistNone": "Nessuna copia più vecchia ha qualcosa che manca qui.", + "profHistNothing": "Tutto ciò che c’è in quella copia è già qui.", + "profHistFilled": "{n} campi riempiti dalla copia più vecchia. Salva per pubblicare.", + "profHist_name": "nome", + "profHist_about": "bio", + "profHist_picture": "foto", + "profHist_banner": "banner", "profFollowing": "Seguiti", "profFollowers": "Follower", "profFollowsNone": "Non segui ancora nessuno.", @@ -222,6 +247,8 @@ "playVideo": "Riproduci", "videoUnmute": "Attiva audio", "videoTapToPlay": "Tocca per riprodurre", + "videoPlay": "Riproduci", + "videoPause": "Pausa", "videoNoPicture": "Questo browser non riesce a mostrare l'immagine di questo video", "videoOpenFile": "Apri il file", "videoLargeTitle": "Video", @@ -473,9 +500,16 @@ "publicChatTitle": "Community {name}", "publicChatDesc": "Chat dal vivo: leggi, accedi per partecipare alla conversazione", "publicChatSignIn": "Accedi per chattare", - "publicChatOpenApp": "Apri coinos", + "publicChatDescMember": "Chat dal vivo: apri la chat in Coinos per partecipare alla conversazione", + "publicChatOpenApp": "Partecipa alla chat", "publicChatLoading": "Caricamento messaggi…", "msgDelete": "Elimina messaggio", + "msgBan": "Rimuovi {name} e i suoi messaggi", + "msgBanConfirm": "Rimuovere {name} per tutti? Tocca di nuovo", + "msgBanned": "{name} rimosso", + "msgUnbanned": "{name} ripristinato", + "msgBannedHead": "Rimossi da questa community", + "msgUnban": "Ripristina", "msgNoIdentity": "Il tuo signer nostr non è connesso. Riconnettilo per inviare a tuo nome", "msgSendFailed": "Nessun relay ha accettato il messaggio", "msgLockedChat": "Il tuo portafoglio è bloccato, quindi non può firmare i messaggi.", @@ -488,8 +522,8 @@ "addrRead": "Lettura di {min} min", "addrList": "Una lista di {name}", "msgSignerOff": "Il tuo signer nostr non è connesso, quindi non puoi ancora pubblicare a tuo nome.", - "msgAmberDms": "Hai effettuato l’accesso con Amber: i messaggi inviati a te non possono essere aperti qui, solo quelli che hai inviato da questo dispositivo.", - "msgAmberConnect": "Connetti Amber", + "msgAmberDms": "Hai effettuato l’accesso con un’app di firma: i messaggi inviati a te non possono essere aperti qui, solo quelli inviati da questo dispositivo.", + "msgAmberConnect": "Connetti il firmatario", "postRepostedBy": "{name} ha ripubblicato", "gifBtnTitle": "Aggiungi una GIF", "gifSearchPh": "Cerca GIF", @@ -825,6 +859,10 @@ "giftWelcome": "Ti hanno inviato dei Bitcoin", "giftBackedUp": "L'ho scritta", "giftLink": "Regala dei Bitcoin", + "giftNotePlaceholder": "Aggiungi un messaggio (facoltativo)", + "giftFromMe": "Di’ che è da parte mia (chi lo riscatta ti segue)", + "giftFrom": "Da", + "giftClaimFollows": "Riscattandolo seguirai questa persona", "giftLinkDesc": "Prefirma un importo sotto forma di link. Chi lo apre riscuote i Bitcoin in un portafoglio nuovo di zecca che controlla solo lui.", "giftLinkWarn": "⚠ Chiunque apra questo link può riscuotere l'importo, quindi condividilo solo con la persona a cui fai il regalo. Mantieni importi piccoli.", "giftLinkReveal": "Crea link regalo", @@ -888,6 +926,7 @@ "claimOr": "oppure", "goToWallet": "Vai al portafoglio", "claimBtn": "Riscuoti", + "claimNowBtn": "Riscatta ora", "claimFeeNote": "~{n} di commissione", "claimFailed": "Questo regalo è già stato riscosso o non è più disponibile.", "claimNotVisibleBody": "Le monete che finanziano questo regalo non sono visibili dalla rete/fonte dati di questo portafoglio. Assicurati che questo portafoglio usi la stessa rete e fonte dati del mittente, poi riprova.", @@ -1154,6 +1193,8 @@ "onbSpendBody": "Scegli un nome utente e ottieni il tuo indirizzo di pagamento. Chiunque potrà inviarti bitcoin all’istante, solo con il tuo nome.", "onbNameWait": "Preparazione in corso…", "onbNotNow": "Non ora", + "onbGiftNameTitle": "Ora scegli un nome utente", + "onbGiftNameBody": "Prendi un nome e ottieni il tuo indirizzo di pagamento, così ti possono inviare bitcoin solo con il tuo nome. Poi scegli una foto e una copertina.", "onbAvatarTitle": "Scegli il tuo aspetto", "onbAvatarBody": "Scegli una copertina e un avatar, oppure carica i tuoi. Puoi cambiarli quando vuoi.", "onbCover": "Immagine di copertina", @@ -1177,7 +1218,7 @@ "nlSignInNostr": "Accedi con Nostr", "nlDesc": "Usa il tuo account Nostr per aprire il portafoglio, su qualsiasi dispositivo, con lo stesso account.", "nlExtension": "Usa l’estensione del browser", - "nlAmber": "Accedi con Amber (Android)", + "nlAmber": "Accedi con un’app di firma (Android)", "nlAmberWait": "In attesa dell’app di firma… approva lì, poi torna qui.", "nlAmberPasteHint": "La risposta viene letta dagli appunti al tuo ritorno. Se non succede nulla, incollala qui:", "nlAmberPastePh": "Incolla la risposta del firmatario", @@ -1187,7 +1228,7 @@ "nlPasskey": "Accedi con una passkey", "nlKeyOrBunker": "nsec, chiave privata o bunker://…", "nlGo": "Accedi", - "nlHint": "Funziona con Amber, Amethyst e altri firmatari remoti (incolla il loro indirizzo bunker://), o con un’estensione del browser. Una chiave incollata viene usata una sola volta e mai salvata.", + "nlHint": "Funziona con le app di firma remota (incolla il loro indirizzo bunker://) o con un’estensione del browser. Una chiave incollata viene usata una volta e mai salvata.", "nlApprove": "Approva la richiesta nel tuo firmatario →", "nlUnrecognized": "Non sembra una chiave privata né un indirizzo bunker://.", "nlNewTitle": "Crea un portafoglio per questo account", @@ -1208,7 +1249,7 @@ "nlReconnectDesc": "Il tuo signer non è connesso, quindi questa app non può firmare per te. Ricollegalo per pubblicare e aggiornare il profilo. Il portafoglio e il saldo non ne risentono.", "nlReconnectTitle": "Ricollega il signer", "nlAmberUpgradeTitle": "Leggi i tuoi messaggi", - "nlAmberUpgradeBody": "Amber può aprire i messaggi qui solo se è connesso come signer remoto. Tocca qui sotto, approva in Amber (consenti la decifratura) e i messaggi verranno caricati. La firma funziona come prima.", + "nlAmberUpgradeBody": "La tua app di firma può aprire i messaggi qui solo se è connessa come firmatario remoto. Tocca qui sotto, approva nell’app di firma (consenti la decifratura) e i messaggi verranno caricati. La firma funziona come prima.", "nlReconnectBody": "Il tuo signer Nostr non è connesso su questo dispositivo. Ricollegalo per continuare ad agire come {npub}…", "nlWrongAccount": "Quel signer è di un altro account Nostr. Ricollega {npub}.", "nlLinked": "Portafoglio collegato al tuo account Nostr", @@ -1240,6 +1281,7 @@ "namesClaim": "Richiedi", "namesClaimed": "Ora sei {name}", "namesCopy": "Copia indirizzo", + "namesChangeTo": "Cambia in {addr}", "namesMoreOpen": "Altre opzioni", "namesMoreTitle": "Altri modi per farti pagare", "namesMoreBolt11": "Fattura Lightning (BOLT 11)", @@ -1458,5 +1500,12 @@ "lockPwOrPassword": "oppure imposta una password:", "pagerFetchingVideo": "Caricamento del prossimo video…", "pagerFetchingPictures": "Caricamento di altre immagini…", - "pagerCaughtUp": "Per ora è tutto" + "pagerCaughtUp": "Per ora è tutto", + "schedToggle": "Programma per dopo", + "schedAt": "Pubblica il", + "schedBtn": "Programma", + "schedDone": "Programmato per {when}", + "schedTooSoon": "Scegli un orario almeno due minuti da adesso", + "schedListTitle": "Programmati ({n})", + "schedCancelled": "Post programmato annullato" } diff --git a/src/locales/ja.json b/src/locales/ja.json index b8968cc..27970db 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -364,6 +364,10 @@ "giftWelcome": "Bitcoin が届きました", "giftBackedUp": "書き留めました", "giftLink": "Bitcoin を贈る", + "giftNotePlaceholder": "メッセージを添える(任意)", + "giftFromMe": "自分からだと伝える(受け取った人があなたをフォローします)", + "giftFrom": "送り主", + "giftClaimFollows": "受け取ると送り主をフォローします", "giftLinkDesc": "金額をリンクとして事前署名します。リンクを開いた人が、その人だけが管理できる真新しいウォレットに Bitcoin を受け取ります。", "giftLinkWarn": "⚠ このリンクを開いた人は誰でもその金額を受け取れるので、贈る相手とだけ共有してください。金額は少なめにしましょう。", "giftLinkReveal": "ギフトリンクを作成", @@ -425,6 +429,7 @@ "claimOr": "または", "goToWallet": "ウォレットに行く", "claimBtn": "受け取る", + "claimNowBtn": "今すぐ受け取る", "claimFeeNote": "ふりがな{n} 料金について", "claimFailed": "このギフトはすでに受け取られたか、現在利用できません。", "claimNotVisibleBody": "このギフトに資金を調達するコインは、このウォレットのネットワーク/データソースから表示されません。 このウォレットは、送信者と同じネットワークとデータソースを使用していて、もう一度お試しください。", @@ -618,6 +623,8 @@ "onbSpendBody": "ユーザー名を主張し、独自の支払いアドレスを取得する, 人々は、あなたの名前だけで、即座にビットコインを送ることができます。", "onbNameWait": "物事を準備する...", "onbNotNow": "今は今", + "onbGiftNameTitle": "次にユーザー名を選びましょう", + "onbGiftNameBody": "名前を取得して自分の支払いアドレスを手に入れましょう。名前だけでビットコインを受け取れます。そのあと写真とカバーを選びます。", "onbAvatarTitle": "お問い合わせ", "onbAvatarBody": "アバターを選ぶか、自分の写真をアップロードしてください。いつでも変更できます。", "onbUpload": "写真をアップロードする", @@ -640,7 +647,7 @@ "nlPasskey": "パスキーでサインイン", "nlKeyOrBunker": "nsec, プライベートキー, またはバンカー://...", "nlGo": "ログイン", - "nlHint": "Amber、Amethyst、その他のリモートサイダー(Bunker://アドレスを貼り付ける)、またはブラウザ拡張で動作します。 過去のキーは一度使用され、貯えられません。", + "nlHint": "リモート署名アプリ(bunker:// アドレスを貼り付け)またはブラウザ拡張機能で使えます。貼り付けた鍵は一度だけ使われ、保存されません。", "nlApprove": "申請書の承認 →", "nlUnrecognized": "つまり、秘密鍵や2段のアドレスは見えません。", "nlNewTitle": "このアカウントのウォレットを作成する", @@ -678,6 +685,7 @@ "namesClaim": "ログイン", "namesClaimed": "これから {name}", "namesCopy": "コピーアドレス", + "namesChangeTo": "{addr} に変更", "namesMoreOpen": "その他のオプション", "namesMoreTitle": "支払われるより多くの方法", "namesMoreBolt11": "ライトニングインボイス(BOLT 11)", @@ -878,6 +886,17 @@ "followHistNone": "あなたのリストに欠けている人を含む古いコピーはありません。", "followHistNothing": "そのコピーの全員がすでにリストにいます。", "followHistRestored": "{n}人のフォローを復元しました。", + "profHistDesc": "別のアプリがプロフィールを置き換えた場合、履歴を保持するリレーに古いコピーが残っていることがあります。使うと、ここで空欄の項目だけが埋まります。", + "profHistLook": "古いコピーを探す", + "profHistUse": "使う", + "profHistNoName": "(名前なし)", + "profHistNone": "ここに足りないものを持つ古いコピーはありません。", + "profHistNothing": "そのコピーの内容はすべてここにあります。", + "profHistFilled": "古いコピーから{n}項目を埋めました。公開するには保存してください。", + "profHist_name": "名前", + "profHist_about": "自己紹介", + "profHist_picture": "写真", + "profHist_banner": "バナー", "securityTitle": "セキュリティ", "arkCoinsNone": "支出にはまだコインがありません。", "profKeyLink": "公開鍵", @@ -910,6 +929,8 @@ "mediaNoImages": "このフィードにはまだ画像がありません。", "mediaNoVideos": "このフィードにはまだ縦型動画がありません。", "videoTapToPlay": "タップして再生", + "videoPlay": "再生", + "videoPause": "一時停止", "videoLargeTitle": "動画", "videoLargeToggle": "大きな動画を自動再生", "videoLargeHelp": "{mb} MBを超える動画や1080pを超える動画は、データを使いすぎないようタップするまで再生しません。低速回線やデータセーバー時は上限が下がります。", @@ -924,5 +945,12 @@ "arkRenewViewTx": "ラウンドのトランザクションを見る", "pagerFetchingVideo": "次の動画を取得中…", "pagerFetchingPictures": "画像をさらに取得中…", - "pagerCaughtUp": "今のところ以上です" + "pagerCaughtUp": "今のところ以上です", + "schedToggle": "あとで投稿する", + "schedAt": "投稿日時", + "schedBtn": "予約する", + "schedDone": "{when} に予約しました", + "schedTooSoon": "今から2分以上先の時刻を選んでください", + "schedListTitle": "予約済み({n})", + "schedCancelled": "予約投稿を取り消しました" } diff --git a/src/locales/ko.json b/src/locales/ko.json index e4bf342..94f9f0e 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -363,6 +363,10 @@ "giftWelcome": "Bitcoin을 받았습니다", "giftBackedUp": "적어 두었습니다", "giftLink": "Bitcoin 선물하기", + "giftNotePlaceholder": "메시지 추가 (선택)", + "giftFromMe": "내가 보냈다고 알리기 (받는 사람이 나를 팔로우함)", + "giftFrom": "보낸 사람", + "giftClaimFollows": "받으면 보낸 사람을 팔로우합니다", "giftLinkDesc": "금액을 링크로 미리 서명하세요. 링크를 여는 사람은 그 Bitcoin을 자신만 통제하는 완전히 새로운 지갑으로 받게 됩니다.", "giftLinkWarn": "⚠ 이 링크를 여는 사람은 누구나 금액을 받을 수 있으니, 선물할 상대에게만 공유하세요. 금액은 적게 유지하세요.", "giftLinkReveal": "선물 링크 만들기", @@ -424,6 +428,7 @@ "claimOr": "또는", "goToWallet": "지갑 바로가기", "claimBtn": "받기", + "claimNowBtn": "지금 받기", "claimFeeNote": "·{n} 요금안내", "claimFailed": "이 선물은 이미 수령되었거나 더 이상 이용할 수 없습니다.", "claimNotVisibleBody": "이 선물은 이 지갑의 네트워크/데이터 소스에서 볼 수 없습니다. 이 지갑이 같은 네트워크와 데이터 소스를 보낸 후 다시 시도하십시오.", @@ -617,6 +622,8 @@ "onbSpendBody": "사용자 이름 및 자신의 지불 주소를 얻기, 사람들은 즉시 Bitcoin을 보낼 수 있습니다, 그냥 당신의 이름.", "onbNameWait": "준비하는 것들 ...", "onbNotNow": "현재 위치", + "onbGiftNameTitle": "이제 사용자 이름을 고르세요", + "onbGiftNameBody": "이름을 등록하고 나만의 결제 주소를 받으세요. 이름만으로 비트코인을 받을 수 있어요. 그다음 사진과 커버를 고르세요.", "onbAvatarTitle": "자주 묻는 질문", "onbAvatarBody": "아바타를 선택하거나 사진을 업로드하세요. 언제든지 변경할 수 있습니다.", "onbUpload": "사진 업로드", @@ -639,7 +646,7 @@ "nlPasskey": "패스키로 로그인", "nlKeyOrBunker": "nsec, 개인 키, 또는 벙커://...", "nlGo": "로그인", - "nlHint": "Amber, Amethyst 및 기타 원격 Signers와 함께 작동합니다. (이층 http:// 주소) 또는 브라우저 확장. 과거의 열쇠는 한 번 이용되고 결코 저장되지 않습니다.", + "nlHint": "원격 서명 앱(bunker:// 주소를 붙여넣기) 또는 브라우저 확장 프로그램과 함께 작동합니다. 붙여넣은 키는 한 번만 사용되며 저장되지 않습니다.", "nlApprove": "당신의 signer에 있는 요구에 찬성하십시오 →", "nlUnrecognized": "그것은 개인 키 또는 벙커처럼 보이지 않습니다.:// 주소.", "nlNewTitle": "이 계정의 지갑 만들기", @@ -677,6 +684,7 @@ "namesClaim": "계정 만들기", "namesClaimed": "현재 위치 {name}", "namesCopy": "본문 바로가기", + "namesChangeTo": "{addr}(으)로 변경", "namesMoreOpen": "더 많은 옵션", "namesMoreTitle": "지불하는 방법", "namesMoreBolt11": "번개 송장 (BOLT 11)", @@ -877,6 +885,17 @@ "followHistNone": "목록에 빠진 사람이 들어 있는 예전 사본이 없습니다.", "followHistNothing": "그 사본의 모든 사람이 이미 목록에 있습니다.", "followHistRestored": "팔로우 {n}명을 복원했습니다.", + "profHistDesc": "다른 앱이 프로필을 덮어썼다면, 기록을 보관하는 릴레이에 이전 사본이 남아 있을 수 있습니다. 사용하면 여기서 비어 있는 항목만 채웁니다.", + "profHistLook": "이전 사본 찾기", + "profHistUse": "사용", + "profHistNoName": "(이름 없음)", + "profHistNone": "여기에 없는 내용을 가진 이전 사본이 없습니다.", + "profHistNothing": "그 사본의 내용은 모두 이미 여기에 있습니다.", + "profHistFilled": "이전 사본에서 {n}개 항목을 채웠습니다. 게시하려면 저장하세요.", + "profHist_name": "이름", + "profHist_about": "소개", + "profHist_picture": "사진", + "profHist_banner": "배너", "securityTitle": "보안", "arkCoinsNone": "지출에 아직 코인이 없습니다.", "profKeyLink": "공개 키", @@ -909,6 +928,8 @@ "mediaNoImages": "이 피드에는 아직 이미지가 없습니다.", "mediaNoVideos": "이 피드에는 아직 세로 동영상이 없습니다.", "videoTapToPlay": "탭하여 재생", + "videoPlay": "재생", + "videoPause": "일시정지", "videoLargeTitle": "동영상", "videoLargeToggle": "큰 동영상 자동 재생", "videoLargeHelp": "{mb}MB를 넘거나 1080p보다 높은 동영상은 데이터를 아끼기 위해 탭할 때까지 기다립니다. 느린 연결이나 데이터 절약 모드에서는 한도가 더 낮습니다.", @@ -923,5 +944,12 @@ "arkRenewViewTx": "라운드 거래 보기", "pagerFetchingVideo": "다음 동영상을 가져오는 중…", "pagerFetchingPictures": "사진을 더 가져오는 중…", - "pagerCaughtUp": "지금은 여기까지예요" + "pagerCaughtUp": "지금은 여기까지예요", + "schedToggle": "나중에 게시 예약", + "schedAt": "게시 시간", + "schedBtn": "예약", + "schedDone": "{when}에 예약됨", + "schedTooSoon": "지금부터 최소 2분 뒤의 시간을 고르세요", + "schedListTitle": "예약됨 ({n})", + "schedCancelled": "예약 게시물이 취소되었습니다" } diff --git a/src/locales/mr.json b/src/locales/mr.json index 17ee1d5..a7ad2b4 100644 --- a/src/locales/mr.json +++ b/src/locales/mr.json @@ -137,6 +137,10 @@ "giftSkip": "आत्ता वगळा", "giftBackedUp": "मी ते लिहून ठेवले आहे", "giftLink": "थोडे Bitcoin भेट द्या", + "giftNotePlaceholder": "एक नोंद जोडा (पर्यायी)", + "giftFromMe": "हे माझ्याकडून आहे असे सांगा (दावा करणारे तुम्हाला फॉलो करतील)", + "giftFrom": "पाठवणारे", + "giftClaimFollows": "दावा केल्यावर तुम्ही त्यांना फॉलो कराल", "giftLinkDesc": "एखादी रक्कम लिंक म्हणून आधीच स्वाक्षरित करा. ती उघडणारा कोणीही ते Bitcoin फक्त त्याच्याच नियंत्रणातील एका नव्या कोऱ्या वॉलेटमध्ये घेतो.", "giftLinkWarn": "⚠ ही लिंक उघडणारा कोणीही ती रक्कम घेऊ शकतो, म्हणून ज्याला भेट देत आहात फक्त त्याच्यासोबतच ती शेअर करा. रक्कम कमी ठेवा.", "giftAmountLabel": "भेट देण्याची रक्कम", @@ -145,6 +149,7 @@ "giftReserved": "दावा न केलेल्या भेट लिंक ({n})", "claimBody": "ते फक्त तुमच्याच नियंत्रणातील एका नव्या कोऱ्या वॉलेटमध्ये घ्या.", "claimBtn": "ते घ्या", + "claimNowBtn": "आता घ्या", "claimFailed": "ही भेट आधीच घेतली गेली आहे किंवा आता उपलब्ध नाही.", "claimedTitle": "घेतले!", "claimedBody": "तुमचे Bitcoin येत आहे. ते सुरक्षित ठेवण्यासाठी तुमच्या रिकव्हरी वाक्यांशाचा बॅकअप घ्या.", @@ -215,6 +220,17 @@ "followHistNone": "तुमच्या यादीत नसलेले कोणी असलेली जुनी प्रत नाही.", "followHistNothing": "त्या प्रतीतील सर्वजण आधीच तुमच्या यादीत आहेत.", "followHistRestored": "{n} फॉलो पुनर्संचयित केले.", + "profHistDesc": "दुसऱ्या अॅपने तुमची प्रोफाइल बदलली असेल, तर इतिहास ठेवणाऱ्या रिलेवर जुनी प्रत अजूनही असू शकते. ती वापरल्यास येथे फक्त रिकामी फील्ड भरली जातात.", + "profHistLook": "जुन्या प्रती शोधा", + "profHistUse": "वापरा", + "profHistNoName": "(नाव नाही)", + "profHistNone": "कोणत्याही जुन्या प्रतीत येथे नसलेले काही नाही.", + "profHistNothing": "त्या प्रतीतील सर्व काही येथे आधीच आहे.", + "profHistFilled": "जुन्या प्रतीतून {n} फील्ड भरली. प्रकाशित करण्यासाठी जतन करा.", + "profHist_name": "नाव", + "profHist_about": "परिचय", + "profHist_picture": "फोटो", + "profHist_banner": "बॅनर", "securityTitle": "सुरक्षा", "arkCoinsNone": "स्पेंडिंगमध्ये अद्याप कोणतेही कॉइन नाहीत.", "profKeyLink": "सार्वजनिक की", @@ -247,6 +263,8 @@ "mediaNoImages": "या फीडमध्ये अजून चित्रे नाहीत.", "mediaNoVideos": "या फीडमध्ये अजून उभे व्हिडिओ नाहीत.", "videoTapToPlay": "चालवण्यासाठी टॅप करा", + "videoPlay": "चालवा", + "videoPause": "थांबवा", "videoLargeTitle": "व्हिडिओ", "videoLargeToggle": "मोठे व्हिडिओ आपोआप चालवा", "videoLargeHelp": "{mb} MB पेक्षा मोठे किंवा 1080p पेक्षा वरचे व्हिडिओ तुमचा डेटा वाचवण्यासाठी टॅपची वाट पाहतात. धीम्या किंवा डेटा-सेव्हर कनेक्शनवर मर्यादा कमी असते.", @@ -261,5 +279,15 @@ "arkRenewViewTx": "राउंडचा व्यवहार पहा", "pagerFetchingVideo": "पुढचा व्हिडिओ आणत आहे…", "pagerFetchingPictures": "आणखी चित्रे आणत आहे…", - "pagerCaughtUp": "सध्या एवढेच" + "pagerCaughtUp": "सध्या एवढेच", + "onbGiftNameTitle": "आता एक युजरनेम निवडा", + "onbGiftNameBody": "एक नाव घ्या आणि स्वतःचा पेमेंट पत्ता मिळवा, म्हणजे लोक फक्त तुमच्या नावाने तुम्हाला बिटकॉइन पाठवू शकतील. मग एक फोटो आणि कव्हर निवडा.", + "namesChangeTo": "{addr} मध्ये बदला", + "schedToggle": "नंतरसाठी वेळ ठरवा", + "schedAt": "पोस्टची वेळ", + "schedBtn": "वेळ ठरवा", + "schedDone": "{when} साठी ठरवले", + "schedTooSoon": "आतापासून किमान दोन मिनिटांनंतरची वेळ निवडा", + "schedListTitle": "ठरवलेल्या ({n})", + "schedCancelled": "ठरवलेली पोस्ट रद्द झाली" } diff --git a/src/locales/pt.json b/src/locales/pt.json index 11ff22c..56e8cda 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -364,6 +364,10 @@ "giftWelcome": "Você recebeu Bitcoin", "giftBackedUp": "Já anotei", "giftLink": "Presentear Bitcoin", + "giftNotePlaceholder": "Adicione um recado (opcional)", + "giftFromMe": "Dizer que é meu (quem resgatar vai te seguir)", + "giftFrom": "De", + "giftClaimFollows": "Ao resgatar, você passa a seguir essa pessoa", "giftLinkDesc": "Pré-assine um valor como um link. Quem o abrir resgata os Bitcoin para uma carteira novinha que só essa pessoa controla.", "giftLinkWarn": "⚠ Qualquer pessoa que abrir este link pode resgatar o valor, então compartilhe-o apenas com a pessoa que você está presenteando. Mantenha os valores baixos.", "giftLinkReveal": "Criar link de presente", @@ -425,6 +429,7 @@ "claimOr": "ou", "goToWallet": "Ir para a carteira", "claimBtn": "Resgatar", + "claimNowBtn": "Resgatar agora", "claimFeeNote": "~{n} taxa", "claimFailed": "Este presente já foi resgatado ou não está mais disponível.", "claimNotVisibleBody": "As moedas que financiam este presente não são visíveis da rede/fonte de dados desta carteira. Certifique-se de que esta carteira usa a mesma rede e fonte de dados que o remetente e tente novamente.", @@ -618,6 +623,8 @@ "onbSpendBody": "Reclame um nome de usuário e obtenha seu próprio endereço de pagamento, as pessoas podem enviar bitcoin instantaneamente, com apenas seu nome.", "onbNameWait": "Preparando as coisas...", "onbNotNow": "Agora não.", + "onbGiftNameTitle": "Agora escolha um nome de usuário", + "onbGiftNameBody": "Reserve um nome e ganhe seu próprio endereço de pagamento, para que possam te enviar bitcoin só com o seu nome. Depois escolha uma foto e uma capa.", "onbAvatarTitle": "Escolha o seu look", "onbAvatarBody": "Escolha um avatar ou carregue a sua própria foto. Podes mudá-lo quando quiseres.", "onbUpload": "Enviar uma foto", @@ -640,7 +647,7 @@ "nlPasskey": "Entrar com uma chave-mestra", "nlKeyOrBunker": "Nsec, chave privada, ou bunker://...", "nlGo": "Entrar", - "nlHint": "Funciona com Amber, Amethyst e outros sinalizadores remotos (colar seu bunker://endereço), ou uma extensão do navegador. Uma chave colada é usada uma vez e nunca armazenada.", + "nlHint": "Funciona com apps de assinatura remota (cole o endereço bunker:// delas) ou uma extensão do navegador. Uma chave colada é usada uma vez e nunca guardada.", "nlApprove": "Aprovar o pedido em seu assinante →", "nlUnrecognized": "Isso não se parece com uma chave privada ou um endereço bunker://.", "nlNewTitle": "Criar uma carteira para esta conta", @@ -678,6 +685,7 @@ "namesClaim": "Alegação", "namesClaimed": "Agora és. {name}", "namesCopy": "Copiar endereço", + "namesChangeTo": "Mudar para {addr}", "namesMoreOpen": "Mais opções", "namesMoreTitle": "Mais formas de ser pago", "namesMoreBolt11": "Factura relâmpago (BOLT 11)", @@ -878,6 +886,17 @@ "followHistNone": "Nenhuma cópia antiga tem alguém que falte na sua lista.", "followHistNothing": "Todos dessa cópia já estão na sua lista.", "followHistRestored": "{n} seguidos restaurados.", + "profHistDesc": "Se outro app substituiu seu perfil, uma cópia mais antiga ainda pode estar em um relay que guarda histórico. Usá-la preenche apenas os campos que estão vazios aqui.", + "profHistLook": "Procurar cópias mais antigas", + "profHistUse": "Usar", + "profHistNoName": "(sem nome)", + "profHistNone": "Nenhuma cópia mais antiga tem algo que falte aqui.", + "profHistNothing": "Tudo dessa cópia já está aqui.", + "profHistFilled": "{n} campos preenchidos da cópia mais antiga. Salve para publicar.", + "profHist_name": "nome", + "profHist_about": "bio", + "profHist_picture": "foto", + "profHist_banner": "capa", "securityTitle": "Segurança", "arkCoinsNone": "Ainda não há moedas em Gastos.", "profKeyLink": "Chave pública", @@ -910,6 +929,8 @@ "mediaNoImages": "Ainda não há imagens neste feed.", "mediaNoVideos": "Ainda não há vídeos verticais neste feed.", "videoTapToPlay": "Toque para reproduzir", + "videoPlay": "Reproduzir", + "videoPause": "Pausar", "videoLargeTitle": "Vídeos", "videoLargeToggle": "Reproduzir vídeos grandes automaticamente", "videoLargeHelp": "Vídeos acima de {mb} MB ou de 1080p esperam um toque, para o feed não consumir seus dados. O limite é menor em conexões lentas ou com economia de dados.", @@ -924,5 +945,12 @@ "arkRenewViewTx": "Ver a transação da rodada", "pagerFetchingVideo": "Buscando o próximo vídeo…", "pagerFetchingPictures": "Buscando mais imagens…", - "pagerCaughtUp": "Por enquanto é só isso" + "pagerCaughtUp": "Por enquanto é só isso", + "schedToggle": "Agendar para depois", + "schedAt": "Publicar em", + "schedBtn": "Agendar", + "schedDone": "Agendado para {when}", + "schedTooSoon": "Escolha um horário pelo menos dois minutos a partir de agora", + "schedListTitle": "Agendados ({n})", + "schedCancelled": "Publicação agendada cancelada" } diff --git a/src/locales/ru.json b/src/locales/ru.json index 0f2936d..7ec23b3 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -364,6 +364,10 @@ "giftWelcome": "Вам отправили Bitcoin", "giftBackedUp": "Я записал(а)", "giftLink": "Подарить немного Bitcoin", + "giftNotePlaceholder": "Добавьте записку (необязательно)", + "giftFromMe": "Указать, что это от меня (получатель подпишется на вас)", + "giftFrom": "От", + "giftClaimFollows": "Получив подарок, вы подпишетесь на отправителя", "giftLinkDesc": "Заранее подпишите сумму в виде ссылки. Тот, кто откроет её, получит Bitcoin в совершенно новый кошелёк, которым управляет только он.", "giftLinkWarn": "⚠ Любой, кто откроет эту ссылку, может получить эту сумму, поэтому делитесь ею только с тем, кому дарите. Держите суммы небольшими.", "giftLinkReveal": "Создать ссылку-подарок", @@ -425,6 +429,7 @@ "claimOr": "или", "goToWallet": "Иди к кошельку", "claimBtn": "Получить", + "claimNowBtn": "Получить сейчас", "claimFeeNote": "~{n} взнос", "claimFailed": "Этот подарок уже получен или больше недоступен.", "claimNotVisibleBody": "Монеты, финансирующие этот подарок, не видны из сети / источника данных этого кошелька. Убедитесь, что этот кошелек использует ту же сеть и источник данных, что и отправитель.", @@ -618,6 +623,8 @@ "onbSpendBody": "Заявите имя пользователя и получите свой собственный платежный адрес, люди могут отправить вам биткойн мгновенно, только с вашим именем.", "onbNameWait": "Готовить вещи...", "onbNotNow": "Не сейчас", + "onbGiftNameTitle": "Теперь выберите имя пользователя", + "onbGiftNameBody": "Займите имя и получите свой платёжный адрес — тогда вам смогут отправлять биткоин просто по имени. Потом выберите фото и обложку.", "onbAvatarTitle": "Выберите свой взгляд", "onbAvatarBody": "Выберите аватар или загрузите свою фотографию. Его можно изменить в любое время.", "onbUpload": "Загрузить фото", @@ -640,7 +647,7 @@ "nlPasskey": "Зарегистрируйтесь с Passkey", "nlKeyOrBunker": "nsec, закрытый ключ или бункер:", "nlGo": "Входите.", - "nlHint": "Работает с Amber, Amethyst и другими удаленными подписчиками (вставьте их бункер://адрес) или расширение браузера. Вставленный ключ используется один раз и никогда не хранится.", + "nlHint": "Работает с приложениями удалённой подписи (вставьте их адрес bunker://) или с расширением браузера. Вставленный ключ используется один раз и никогда не сохраняется.", "nlApprove": "Утвердить запрос в вашем подписчике", "nlUnrecognized": "Это не похоже на закрытый ключ или бункер:", "nlNewTitle": "Создайте кошелек для этого аккаунта", @@ -678,6 +685,7 @@ "namesClaim": "Претензия", "namesClaimed": "Ты теперь {name}", "namesCopy": "Адрес копии", + "namesChangeTo": "Сменить на {addr}", "namesMoreOpen": "Больше вариантов", "namesMoreTitle": "Больше способов получить оплату", "namesMoreBolt11": "Инвойс молнии (BOLT 11)", @@ -878,6 +886,17 @@ "followHistNone": "Нет старых копий с людьми, которых не хватает в вашем списке.", "followHistNothing": "Все из этой копии уже есть в вашем списке.", "followHistRestored": "Восстановлено подписок: {n}.", + "profHistDesc": "Если другое приложение заменило ваш профиль, более старая копия может ещё храниться на релее с историей. Её использование заполняет только пустые здесь поля.", + "profHistLook": "Поискать старые копии", + "profHistUse": "Использовать", + "profHistNoName": "(без имени)", + "profHistNone": "Ни в одной старой копии нет того, чего здесь не хватает.", + "profHistNothing": "Всё из этой копии уже здесь.", + "profHistFilled": "Заполнено полей из старой копии: {n}. Сохраните, чтобы опубликовать.", + "profHist_name": "имя", + "profHist_about": "о себе", + "profHist_picture": "фото", + "profHist_banner": "баннер", "securityTitle": "Безопасность", "arkCoinsNone": "В расходах пока нет монет.", "profKeyLink": "Открытый ключ", @@ -910,6 +929,8 @@ "mediaNoImages": "В этой ленте пока нет фото.", "mediaNoVideos": "В этой ленте пока нет вертикальных видео.", "videoTapToPlay": "Нажмите, чтобы воспроизвести", + "videoPlay": "Воспроизвести", + "videoPause": "Пауза", "videoLargeTitle": "Видео", "videoLargeToggle": "Автовоспроизведение больших видео", "videoLargeHelp": "Видео больше {mb} МБ или выше 1080p ждут нажатия, чтобы лента не съедала трафик. На медленном соединении или в режиме экономии лимит ниже.", @@ -924,5 +945,12 @@ "arkRenewViewTx": "Посмотреть транзакцию раунда", "pagerFetchingVideo": "Загружаем следующее видео…", "pagerFetchingPictures": "Загружаем ещё картинки…", - "pagerCaughtUp": "Пока это всё" + "pagerCaughtUp": "Пока это всё", + "schedToggle": "Запланировать", + "schedAt": "Опубликовать", + "schedBtn": "Запланировать", + "schedDone": "Запланировано на {when}", + "schedTooSoon": "Выберите время не раньше чем через две минуты", + "schedListTitle": "Запланировано ({n})", + "schedCancelled": "Запланированный пост отменён" } diff --git a/src/locales/sw.json b/src/locales/sw.json index b130faf..e5aba2e 100644 --- a/src/locales/sw.json +++ b/src/locales/sw.json @@ -137,6 +137,10 @@ "giftSkip": "Ruka kwa sasa", "giftBackedUp": "Nimeshakiandika", "giftLink": "Toa Bitcoin kama zawadi", + "giftNotePlaceholder": "Ongeza ujumbe (si lazima)", + "giftFromMe": "Sema ni kutoka kwangu (atakayedai atakufuata)", + "giftFrom": "Kutoka kwa", + "giftClaimFollows": "Ukidai utamfuata", "giftLinkDesc": "Tia saini kiasi mapema kama kiungo. Yeyote anayekifungua hudai Bitcoin kwenye pochi mpya kabisa anayoidhibiti yeye pekee.", "giftLinkWarn": "⚠ Yeyote anayefungua kiungo hiki anaweza kudai kiasi hicho, kwa hivyo kishiriki tu na mtu unayempa zawadi. Weka kiasi kidogo.", "giftAmountLabel": "Kiasi cha kutoa zawadi", @@ -145,6 +149,7 @@ "giftReserved": "Viungo vya zawadi visivyodaiwa ({n})", "claimBody": "Idai kwenye pochi mpya kabisa unayoidhibiti wewe pekee.", "claimBtn": "Idai", + "claimNowBtn": "Dai sasa", "claimFailed": "Zawadi hii tayari imedaiwa au haipatikani tena.", "claimedTitle": "Imedaiwa!", "claimedBody": "Bitcoin yako inakuja. Hifadhi kifungu chako cha kurejesha ili kuilinda.", @@ -215,6 +220,17 @@ "followHistNone": "Hakuna nakala ya zamani yenye mtu anayekosekana kwenye orodha yako.", "followHistNothing": "Wote kwenye nakala hiyo tayari wamo kwenye orodha yako.", "followHistRestored": "Umerejesha unaowafuata {n}.", + "profHistDesc": "Ikiwa programu nyingine ilibadilisha wasifu wako, nakala ya zamani huenda bado iko kwenye relay inayohifadhi historia. Kuitumia hujaza tu sehemu zilizo tupu hapa.", + "profHistLook": "Tafuta nakala za zamani", + "profHistUse": "Tumia", + "profHistNoName": "(hakuna jina)", + "profHistNone": "Hakuna nakala ya zamani iliyo na kitu kinachokosekana hapa.", + "profHistNothing": "Kila kitu kwenye nakala hiyo tayari kipo hapa.", + "profHistFilled": "Sehemu {n} zimejazwa kutoka nakala ya zamani. Hifadhi ili kuchapisha.", + "profHist_name": "jina", + "profHist_about": "wasifu", + "profHist_picture": "picha", + "profHist_banner": "bango", "securityTitle": "Usalama", "arkCoinsNone": "Bado hakuna sarafu kwenye Matumizi.", "profKeyLink": "Ufunguo wa umma", @@ -247,6 +263,8 @@ "mediaNoImages": "Bado hakuna picha kwenye mlisho huu.", "mediaNoVideos": "Bado hakuna video wima kwenye mlisho huu.", "videoTapToPlay": "Gusa ili kucheza", + "videoPlay": "Cheza", + "videoPause": "Sitisha", "videoLargeTitle": "Video", "videoLargeToggle": "Cheza video kubwa zenyewe", "videoLargeHelp": "Video zaidi ya MB {mb} au juu ya 1080p husubiri uguse, ili mlisho usimalize data yako. Kikomo ni cha chini kwenye muunganisho wa polepole au wa kuokoa data.", @@ -261,5 +279,15 @@ "arkRenewViewTx": "Tazama muamala wa raundi", "pagerFetchingVideo": "Inaleta video inayofuata…", "pagerFetchingPictures": "Inaleta picha zaidi…", - "pagerCaughtUp": "Hayo ndiyo yote kwa sasa" + "pagerCaughtUp": "Hayo ndiyo yote kwa sasa", + "onbGiftNameTitle": "Sasa chagua jina la mtumiaji", + "onbGiftNameBody": "Chukua jina upate anwani yako ya malipo, ili watu wakutumie bitcoin kwa jina lako tu. Kisha chagua picha na jalada.", + "namesChangeTo": "Badilisha kuwa {addr}", + "schedToggle": "Panga kwa baadaye", + "schedAt": "Chapisha saa", + "schedBtn": "Panga", + "schedDone": "Imepangwa {when}", + "schedTooSoon": "Chagua muda angalau dakika mbili kuanzia sasa", + "schedListTitle": "Zilizopangwa ({n})", + "schedCancelled": "Chapisho lililopangwa limeghairiwa" } diff --git a/src/locales/ta.json b/src/locales/ta.json index da4e5b0..a15043d 100644 --- a/src/locales/ta.json +++ b/src/locales/ta.json @@ -137,6 +137,10 @@ "giftSkip": "இப்போதைக்குத் தவிர்", "giftBackedUp": "நான் எழுதி வைத்துவிட்டேன்", "giftLink": "கொஞ்சம் Bitcoin பரிசளி", + "giftNotePlaceholder": "ஒரு குறிப்பைச் சேர்க்கவும் (விருப்பம்)", + "giftFromMe": "இது என்னிடமிருந்து என்று சொல் (பெறுபவர் உங்களைப் பின்தொடர்வார்)", + "giftFrom": "அனுப்பியவர்", + "giftClaimFollows": "பெற்றால் அவரைப் பின்தொடர்வீர்கள்", "giftLinkDesc": "ஒரு தொகையை இணைப்பாக முன்கூட்டியே கையொப்பமிடுங்கள். இதைத் திறப்பவர், அவர்கள் மட்டுமே கட்டுப்படுத்தும் புத்தம்புதிய வாலட்டிற்குள் Bitcoin-ஐ உரிமை கொள்வார்கள்.", "giftLinkWarn": "⚠ இந்த இணைப்பைத் திறக்கும் எவரும் தொகையை உரிமை கொள்ளலாம், எனவே நீங்கள் பரிசளிக்கும் நபருடன் மட்டுமே இதைப் பகிரவும். தொகையைக் குறைவாக வைத்திருங்கள்.", "giftAmountLabel": "பரிசளிக்கும் தொகை", @@ -145,6 +149,7 @@ "giftReserved": "கோரப்படாத பரிசு இணைப்புகள் ({n})", "claimBody": "நீங்கள் மட்டுமே கட்டுப்படுத்தும் புத்தம்புதிய வாலட்டிற்குள் இதை உரிமை கொள்ளுங்கள்.", "claimBtn": "உரிமை கொள்", + "claimNowBtn": "இப்போதே பெறுங்கள்", "claimFailed": "இந்தப் பரிசு ஏற்கனவே உரிமை கொள்ளப்பட்டுவிட்டது அல்லது இனி கிடைக்கவில்லை.", "claimedTitle": "உரிமை கொள்ளப்பட்டது!", "claimedBody": "உங்கள் Bitcoin வந்துகொண்டிருக்கிறது. அதைப் பாதுகாப்பாக வைக்க உங்கள் மீட்பு சொற்றொடரைக் காப்புப்பிரதி எடுங்கள்.", @@ -215,6 +220,17 @@ "followHistNone": "உங்கள் பட்டியலில் இல்லாதவர் உள்ள பழைய நகல் இல்லை.", "followHistNothing": "அந்த நகலில் உள்ள அனைவரும் ஏற்கனவே உங்கள் பட்டியலில் உள்ளனர்.", "followHistRestored": "{n} பின்தொடர்வுகள் மீட்டெடுக்கப்பட்டன.", + "profHistDesc": "வேறு ஒரு செயலி உங்கள் சுயவிவரத்தை மாற்றியிருந்தால், வரலாற்றை வைத்திருக்கும் ரிலேயில் பழைய நகல் இன்னும் இருக்கலாம். அதைப் பயன்படுத்தினால் இங்கே காலியாக உள்ள புலங்கள் மட்டும் நிரப்பப்படும்.", + "profHistLook": "பழைய நகல்களைத் தேடு", + "profHistUse": "பயன்படுத்து", + "profHistNoName": "(பெயர் இல்லை)", + "profHistNone": "இங்கே இல்லாத எதுவும் எந்தப் பழைய நகலிலும் இல்லை.", + "profHistNothing": "அந்த நகலில் உள்ள அனைத்தும் ஏற்கனவே இங்கே உள்ளன.", + "profHistFilled": "பழைய நகலிலிருந்து {n} புலங்கள் நிரப்பப்பட்டன. வெளியிட சேமிக்கவும்.", + "profHist_name": "பெயர்", + "profHist_about": "அறிமுகம்", + "profHist_picture": "படம்", + "profHist_banner": "பேனர்", "securityTitle": "பாதுகாப்பு", "arkCoinsNone": "செலவுக் கணக்கில் இன்னும் நாணயங்கள் இல்லை.", "profKeyLink": "பொது விசை", @@ -247,6 +263,8 @@ "mediaNoImages": "இந்த ஊட்டத்தில் இன்னும் படங்கள் இல்லை.", "mediaNoVideos": "இந்த ஊட்டத்தில் இன்னும் செங்குத்து வீடியோக்கள் இல்லை.", "videoTapToPlay": "இயக்கத் தட்டவும்", + "videoPlay": "இயக்கு", + "videoPause": "இடைநிறுத்து", "videoLargeTitle": "வீடியோக்கள்", "videoLargeToggle": "பெரிய வீடியோக்களைத் தானாக இயக்கு", "videoLargeHelp": "{mb} MB-க்கு மேல் அல்லது 1080p-க்கு மேலான வீடியோக்கள் உங்கள் டேட்டாவைச் சேமிக்க, தட்டும் வரை காத்திருக்கும். மெதுவான அல்லது டேட்டா சேமிப்பு இணைப்பில் வரம்பு குறைவு.", @@ -261,5 +279,15 @@ "arkRenewViewTx": "சுற்றின் பரிவர்த்தனையைக் காண்க", "pagerFetchingVideo": "அடுத்த வீடியோவைப் பெறுகிறது…", "pagerFetchingPictures": "மேலும் படங்களைப் பெறுகிறது…", - "pagerCaughtUp": "இப்போதைக்கு இவ்வளவுதான்" + "pagerCaughtUp": "இப்போதைக்கு இவ்வளவுதான்", + "onbGiftNameTitle": "இப்போது ஒரு பயனர்பெயரைத் தேர்ந்தெடுங்கள்", + "onbGiftNameBody": "ஒரு பெயரைப் பெற்று உங்கள் சொந்த பணம் பெறும் முகவரியைப் பெறுங்கள்; உங்கள் பெயரை மட்டும் வைத்தே மற்றவர்கள் பிட்காயின் அனுப்பலாம். பிறகு ஒரு படத்தையும் அட்டைப்படத்தையும் தேர்ந்தெடுங்கள்.", + "namesChangeTo": "{addr} ஆக மாற்று", + "schedToggle": "பின்னர் இடுகையிட திட்டமிடு", + "schedAt": "இடுகை நேரம்", + "schedBtn": "திட்டமிடு", + "schedDone": "{when} க்கு திட்டமிடப்பட்டது", + "schedTooSoon": "இப்போதிலிருந்து குறைந்தது இரண்டு நிமிடங்களுக்குப் பிந்தைய நேரத்தைத் தேர்ந்தெடுக்கவும்", + "schedListTitle": "திட்டமிடப்பட்டவை ({n})", + "schedCancelled": "திட்டமிட்ட இடுகை ரத்து செய்யப்பட்டது" } diff --git a/src/locales/te.json b/src/locales/te.json index 7e60eaf..2f6c298 100644 --- a/src/locales/te.json +++ b/src/locales/te.json @@ -137,6 +137,10 @@ "giftSkip": "ప్రస్తుతానికి దాటవేయి", "giftBackedUp": "నేను దాన్ని రాసుకున్నాను", "giftLink": "కొంత Bitcoin బహుమతిగా ఇవ్వండి", + "giftNotePlaceholder": "ఒక సందేశం జోడించండి (ఐచ్ఛికం)", + "giftFromMe": "ఇది నా నుండి అని చెప్పు (క్లెయిమ్ చేసినవారు మిమ్మల్ని ఫాలో అవుతారు)", + "giftFrom": "పంపినవారు", + "giftClaimFollows": "క్లెయిమ్ చేస్తే వారిని ఫాలో అవుతారు", "giftLinkDesc": "ఒక మొత్తాన్ని ముందుగా సంతకం చేసి లింక్గా ఇవ్వండి. దాన్ని తెరిచినవారు ఆ Bitcoinను తాము మాత్రమే నియంత్రించే సరికొత్త వాలెట్లోకి క్లెయిమ్ చేసుకుంటారు.", "giftLinkWarn": "⚠ ఈ లింక్ తెరిచిన ఎవరైనా ఆ మొత్తాన్ని క్లెయిమ్ చేయగలరు, కాబట్టి మీరు బహుమతి ఇస్తున్న వ్యక్తికి మాత్రమే దీన్ని పంచుకోండి. మొత్తాలను తక్కువగా ఉంచండి.", "giftAmountLabel": "బహుమతిగా ఇచ్చే మొత్తం", @@ -145,6 +149,7 @@ "giftReserved": "దావా చేయని బహుమతి లింకులు ({n})", "claimBody": "మీరు మాత్రమే నియంత్రించే సరికొత్త వాలెట్లోకి దాన్ని క్లెయిమ్ చేసుకోండి.", "claimBtn": "క్లెయిమ్ చేసుకోండి", + "claimNowBtn": "ఇప్పుడే పొందండి", "claimFailed": "ఈ బహుమతి ఇప్పటికే క్లెయిమ్ చేయబడింది లేదా ఇక అందుబాటులో లేదు.", "claimedTitle": "క్లెయిమ్ అయింది!", "claimedBody": "మీ Bitcoin మీ వద్దకు వస్తోంది. దాన్ని సురక్షితంగా ఉంచడానికి మీ రికవరీ ఫ్రేజ్ను బ్యాకప్ చేయండి.", @@ -213,6 +218,17 @@ "followHistNone": "మీ జాబితాలో లేని వ్యక్తి ఉన్న పాత కాపీ ఏదీ లేదు.", "followHistNothing": "ఆ కాపీలోని అందరూ ఇప్పటికే మీ జాబితాలో ఉన్నారు.", "followHistRestored": "{n} ఫాలోలు పునరుద్ధరించబడ్డాయి.", + "profHistDesc": "మరో యాప్ మీ ప్రొఫైల్ను మార్చేస్తే, చరిత్రను ఉంచే రిలేలో పాత కాపీ ఇంకా ఉండవచ్చు. దాన్ని వాడితే ఇక్కడ ఖాళీగా ఉన్న ఫీల్డ్లు మాత్రమే నిండుతాయి.", + "profHistLook": "పాత కాపీలను వెతకండి", + "profHistUse": "వాడండి", + "profHistNoName": "(పేరు లేదు)", + "profHistNone": "ఇక్కడ లేనిది ఏ పాత కాపీలోనూ లేదు.", + "profHistNothing": "ఆ కాపీలో ఉన్నదంతా ఇప్పటికే ఇక్కడ ఉంది.", + "profHistFilled": "పాత కాపీ నుండి {n} ఫీల్డ్లు నింపబడ్డాయి. ప్రచురించడానికి సేవ్ చేయండి.", + "profHist_name": "పేరు", + "profHist_about": "పరిచయం", + "profHist_picture": "ఫోటో", + "profHist_banner": "బ్యానర్", "securityTitle": "భద్రత", "arkCoinsNone": "ఖర్చు ఖాతాలో ఇంకా నాణేలు లేవు.", "profKeyLink": "పబ్లిక్ కీ", @@ -245,6 +261,8 @@ "mediaNoImages": "ఈ ఫీడ్లో ఇంకా చిత్రాలు లేవు.", "mediaNoVideos": "ఈ ఫీడ్లో ఇంకా నిలువు వీడియోలు లేవు.", "videoTapToPlay": "ప్లే చేయడానికి నొక్కండి", + "videoPlay": "ప్లే", + "videoPause": "పాజ్", "videoLargeTitle": "వీడియోలు", "videoLargeToggle": "పెద్ద వీడియోలను ఆటోప్లే చేయి", "videoLargeHelp": "{mb} MB కంటే పెద్దవి లేదా 1080p పైబడిన వీడియోలు మీ డేటా ఆదా కోసం నొక్కే వరకు ఆగుతాయి. నెమ్మదైన లేదా డేటా సేవర్ కనెక్షన్లో పరిమితి తక్కువ.", @@ -259,5 +277,15 @@ "arkRenewViewTx": "రౌండ్ లావాదేవీని చూడండి", "pagerFetchingVideo": "తదుపరి వీడియోను తెస్తోంది…", "pagerFetchingPictures": "మరిన్ని చిత్రాలను తెస్తోంది…", - "pagerCaughtUp": "ప్రస్తుతానికి ఇంతే" + "pagerCaughtUp": "ప్రస్తుతానికి ఇంతే", + "onbGiftNameTitle": "ఇప్పుడు ఒక యూజర్నేమ్ ఎంచుకోండి", + "onbGiftNameBody": "ఒక పేరు తీసుకుని మీ సొంత చెల్లింపు చిరునామా పొందండి, అప్పుడు మీ పేరుతోనే ఎవరైనా మీకు బిట్కాయిన్ పంపవచ్చు. తర్వాత ఒక ఫోటో, ఒక కవర్ ఎంచుకోండి.", + "namesChangeTo": "{addr}కి మార్చు", + "schedToggle": "తర్వాత కోసం షెడ్యూల్ చేయండి", + "schedAt": "పోస్ట్ సమయం", + "schedBtn": "షెడ్యూల్ చేయండి", + "schedDone": "{when}కి షెడ్యూల్ చేయబడింది", + "schedTooSoon": "ఇప్పటి నుండి కనీసం రెండు నిమిషాల తర్వాతి సమయాన్ని ఎంచుకోండి", + "schedListTitle": "షెడ్యూల్ చేసినవి ({n})", + "schedCancelled": "షెడ్యూల్ చేసిన పోస్ట్ రద్దయింది" } diff --git a/src/locales/tr.json b/src/locales/tr.json index 1e60eeb..407bcc0 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -363,6 +363,10 @@ "giftWelcome": "Size Bitcoin gönderildi", "giftBackedUp": "Yazdım", "giftLink": "Bitcoin hediye et", + "giftNotePlaceholder": "Not ekle (isteğe bağlı)", + "giftFromMe": "Benden olduğunu söyle (alan seni takip eder)", + "giftFrom": "Gönderen", + "giftClaimFollows": "Alınca gönderen kişiyi takip edersin", "giftLinkDesc": "Bir tutarı önceden imzalayıp bağlantı olarak verin. Bağlantıyı açan, Bitcoin’i yalnızca kendi kontrolündeki yepyeni bir cüzdana alır.", "giftLinkWarn": "⚠ Bu bağlantıyı açan herkes tutarı talep edebilir, bu yüzden yalnızca hediye ettiğiniz kişiyle paylaşın. Tutarları küçük tutun.", "giftLinkReveal": "Hediye bağlantısı oluştur", @@ -424,6 +428,7 @@ "claimOr": "veya", "goToWallet": "Cüzdana git", "claimBtn": "Talep et", + "claimNowBtn": "Şimdi al", "claimFeeNote": "~{n} ücret ücreti", "claimFailed": "Bu hediye zaten talep edilmiş veya artık kullanılamıyor.", "claimNotVisibleBody": "Paralar bu hediyeyi finanse ediyor bu cüzdanın ağ/data kaynağından görünür değil. Bu cüzdanın aynı ağ ve veri kaynağını gönderileyici olarak kullandığından emin olun, sonra tekrar deneyin.", @@ -617,6 +622,8 @@ "onbSpendBody": "Bir kullanıcı adı talep edin ve kendi ödeme adresinizi alın - insanlar size anında Bitcoin gönderebilir, sadece adınızla.", "onbNameWait": "İşler hazır olsun...", "onbNotNow": "Şimdi Değil", + "onbGiftNameTitle": "Şimdi bir kullanıcı adı seç", + "onbGiftNameBody": "Bir isim al ve kendi ödeme adresine sahip ol; insanlar sana sadece adınla bitcoin gönderebilsin. Sonra bir fotoğraf ve kapak seç.", "onbAvatarTitle": "görünümünü seçin", "onbAvatarBody": "Bir avatar seçin veya kendi resminizi yükleyin. İstediğiniz zaman değiştirebilirsiniz.", "onbUpload": "Bir fotoğraf", @@ -639,7 +646,7 @@ "nlPasskey": "Sign in with a passkey", "nlKeyOrBunker": "nsec, private key, or bunker:", "nlGo": "Giriş yapın", - "nlHint": "Amber, Amethyst ve diğer uzaktan işaretçileri ile çalışır (bunker'ı http adrese atlar), veya bir tarayıcı uzatması. Bir geçmiş anahtar bir kez kullanılır ve asla depolanmaz.", + "nlHint": "Uzaktan imzalama uygulamalarıyla (bunker:// adreslerini yapıştırın) veya bir tarayıcı eklentisiyle çalışır. Yapıştırılan anahtar bir kez kullanılır ve asla saklanmaz.", "nlApprove": "Approve the request in your signer", "nlUnrecognized": "Bu özel bir anahtar ya da bir bunker gibi görünmüyor:", "nlNewTitle": "Bu hesap için bir cüzdan oluşturun", @@ -677,6 +684,7 @@ "namesClaim": "Talep Davası", "namesClaimed": "You are now {name}", "namesCopy": "Kopya adresi", + "namesChangeTo": "{addr} olarak değiştir", "namesMoreOpen": "Daha fazla seçenek", "namesMoreTitle": "Ödeme yapmak için daha fazla yol", "namesMoreBolt11": "Yıldırım Fatura (BOLT 11)", @@ -877,6 +885,17 @@ "followHistNone": "Hiçbir eski kopyada listende eksik olan biri yok.", "followHistNothing": "O kopyadaki herkes zaten listende.", "followHistRestored": "{n} takip geri yüklendi.", + "profHistDesc": "Başka bir uygulama profilinizi değiştirdiyse, geçmişi saklayan bir relayde daha eski bir kopya hâlâ olabilir. Birini kullanmak yalnızca burada boş olan alanları doldurur.", + "profHistLook": "Eski kopyaları ara", + "profHistUse": "Kullan", + "profHistNoName": "(adsız)", + "profHistNone": "Hiçbir eski kopyada burada eksik olan bir şey yok.", + "profHistNothing": "O kopyadaki her şey zaten burada.", + "profHistFilled": "Eski kopyadan {n} alan dolduruldu. Yayınlamak için kaydedin.", + "profHist_name": "ad", + "profHist_about": "hakkında", + "profHist_picture": "fotoğraf", + "profHist_banner": "kapak", "securityTitle": "Güvenlik", "arkCoinsNone": "Harcama’da henüz coin yok.", "profKeyLink": "Açık anahtar", @@ -909,6 +928,8 @@ "mediaNoImages": "Bu akışta henüz görsel yok.", "mediaNoVideos": "Bu akışta henüz dikey video yok.", "videoTapToPlay": "Oynatmak için dokun", + "videoPlay": "Oynat", + "videoPause": "Duraklat", "videoLargeTitle": "Videolar", "videoLargeToggle": "Büyük videoları otomatik oynat", "videoLargeHelp": "{mb} MB’den büyük veya 1080p’nin üzerindeki videolar, akış verini tüketmesin diye dokunmanı bekler. Yavaş ya da veri tasarruflu bağlantıda sınır daha düşüktür.", @@ -923,5 +944,12 @@ "arkRenewViewTx": "Turun işlemini görüntüle", "pagerFetchingVideo": "Sonraki video getiriliyor…", "pagerFetchingPictures": "Daha fazla resim getiriliyor…", - "pagerCaughtUp": "Şimdilik bu kadar" + "pagerCaughtUp": "Şimdilik bu kadar", + "schedToggle": "Sonrası için zamanla", + "schedAt": "Paylaşım zamanı", + "schedBtn": "Zamanla", + "schedDone": "{when} için zamanlandı", + "schedTooSoon": "Şu andan en az iki dakika sonrasını seç", + "schedListTitle": "Zamanlanmış ({n})", + "schedCancelled": "Zamanlanmış gönderi iptal edildi" } diff --git a/src/locales/ur.json b/src/locales/ur.json index 6efb6ec..bdcfa7a 100644 --- a/src/locales/ur.json +++ b/src/locales/ur.json @@ -137,6 +137,10 @@ "giftSkip": "ابھی چھوڑیں", "giftBackedUp": "میں نے اسے لکھ لیا ہے", "giftLink": "کچھ Bitcoin تحفے میں دیں", + "giftNotePlaceholder": "ایک نوٹ شامل کریں (اختیاری)", + "giftFromMe": "بتائیں کہ یہ میری طرف سے ہے (وصول کرنے والا آپ کو فالو کرے گا)", + "giftFrom": "بھیجنے والا", + "giftClaimFollows": "وصول کرنے پر آپ انہیں فالو کریں گے", "giftLinkDesc": "ایک رقم کو بطور لنک پہلے سے دستخط کریں۔ جو بھی اسے کھولے وہ Bitcoin کو ایک بالکل نئے والیٹ میں وصول کر لیتا ہے جو صرف اسی کے کنٹرول میں ہوتا ہے۔", "giftLinkWarn": "⚠ جو بھی یہ لنک کھولے وہ اس رقم کا دعویٰ کر سکتا ہے، اس لیے اسے صرف اسی شخص کے ساتھ شیئر کریں جسے آپ تحفہ دے رہے ہیں۔ رقم کم رکھیں۔", "giftLinkReveal": "تحفہ لنک بنائیں", @@ -147,6 +151,7 @@ "giftReserved": "غیر دعویٰ شدہ تحفہ لنکس ({n})", "claimBody": "اسے ایک بالکل نئے والیٹ میں وصول کریں جو صرف آپ کے کنٹرول میں ہو۔", "claimBtn": "وصول کریں", + "claimNowBtn": "ابھی حاصل کریں", "claimFailed": "اس تحفے کا پہلے ہی دعویٰ کیا جا چکا ہے یا یہ اب دستیاب نہیں۔", "claimedTitle": "وصول ہو گیا!", "claimedBody": "آپ کا Bitcoin راستے میں ہے۔ اسے محفوظ رکھنے کے لیے اپنے بحالی فقرے کا بیک اپ بنا لیں۔", @@ -215,6 +220,17 @@ "followHistNone": "کسی پرانی کاپی میں ایسا کوئی نہیں جو آپ کی فہرست میں نہ ہو۔", "followHistNothing": "اس کاپی کے سب لوگ پہلے سے آپ کی فہرست میں ہیں۔", "followHistRestored": "{n} فالو بحال ہو گئے۔", + "profHistDesc": "اگر کسی اور ایپ نے آپ کا پروفائل بدل دیا ہے تو تاریخ محفوظ رکھنے والے کسی ریلے پر پرانی نقل اب بھی ہو سکتی ہے۔ اسے استعمال کرنے سے یہاں صرف خالی خانے بھرتے ہیں۔", + "profHistLook": "پرانی نقلیں تلاش کریں", + "profHistUse": "استعمال کریں", + "profHistNoName": "(بغیر نام)", + "profHistNone": "کسی پرانی نقل میں ایسا کچھ نہیں جو یہاں نہ ہو۔", + "profHistNothing": "اس نقل کی ہر چیز پہلے سے یہاں موجود ہے۔", + "profHistFilled": "پرانی نقل سے {n} خانے بھرے گئے۔ شائع کرنے کے لیے محفوظ کریں۔", + "profHist_name": "نام", + "profHist_about": "تعارف", + "profHist_picture": "تصویر", + "profHist_banner": "بینر", "securityTitle": "سیکیورٹی", "arkCoinsNone": "خرچ میں ابھی کوئی کوائن نہیں۔", "profKeyLink": "پبلک کی", @@ -247,6 +263,8 @@ "mediaNoImages": "اس فیڈ میں ابھی کوئی تصویر نہیں۔", "mediaNoVideos": "اس فیڈ میں ابھی کوئی عمودی ویڈیو نہیں۔", "videoTapToPlay": "چلانے کے لیے ٹیپ کریں", + "videoPlay": "چلائیں", + "videoPause": "روکیں", "videoLargeTitle": "ویڈیوز", "videoLargeToggle": "بڑی ویڈیوز خود چلائیں", "videoLargeHelp": "{mb} MB سے بڑی یا 1080p سے اوپر کی ویڈیوز ٹیپ کا انتظار کرتی ہیں، تاکہ فیڈ آپ کا ڈیٹا نہ کھائے۔ سست یا ڈیٹا بچت والے کنکشن پر حد کم ہوتی ہے۔", @@ -261,5 +279,15 @@ "arkRenewViewTx": "راؤنڈ کی ٹرانزیکشن دیکھیں", "pagerFetchingVideo": "اگلی ویڈیو لائی جا رہی ہے…", "pagerFetchingPictures": "مزید تصاویر لائی جا رہی ہیں…", - "pagerCaughtUp": "فی الحال بس اتنا ہی" + "pagerCaughtUp": "فی الحال بس اتنا ہی", + "onbGiftNameTitle": "اب ایک یوزرنیم چنیں", + "onbGiftNameBody": "ایک نام لیں اور اپنا ادائیگی کا پتہ پائیں، تاکہ لوگ صرف آپ کے نام سے آپ کو بٹ کوائن بھیج سکیں۔ پھر ایک تصویر اور ایک کور چنیں۔", + "namesChangeTo": "{addr} میں بدلیں", + "schedToggle": "بعد کے لیے شیڈول کریں", + "schedAt": "پوسٹ کا وقت", + "schedBtn": "شیڈول کریں", + "schedDone": "{when} کے لیے شیڈول", + "schedTooSoon": "ابھی سے کم از کم دو منٹ بعد کا وقت چنیں", + "schedListTitle": "شیڈول شدہ ({n})", + "schedCancelled": "شیڈول شدہ پوسٹ منسوخ ہو گئی" } diff --git a/src/locales/vi.json b/src/locales/vi.json index 415f4e7..5490c34 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -137,6 +137,10 @@ "giftSkip": "Bỏ qua lúc này", "giftBackedUp": "Tôi đã ghi lại rồi", "giftLink": "Tặng một ít Bitcoin", + "giftNotePlaceholder": "Thêm lời nhắn (không bắt buộc)", + "giftFromMe": "Cho biết là từ tôi (người nhận sẽ theo dõi bạn)", + "giftFrom": "Từ", + "giftClaimFollows": "Nhận quà sẽ theo dõi người gửi", "giftLinkDesc": "Ký trước một số tiền thành một liên kết. Ai mở liên kết sẽ nhận số Bitcoin đó vào một ví hoàn toàn mới mà chỉ riêng họ kiểm soát.", "giftLinkWarn": "⚠ Bất kỳ ai mở liên kết này đều có thể nhận số tiền, vì vậy chỉ chia sẻ với người bạn muốn tặng. Hãy giữ số tiền nhỏ.", "giftAmountLabel": "Số tiền muốn tặng", @@ -145,6 +149,7 @@ "giftReserved": "Liên kết quà chưa nhận ({n})", "claimBody": "Nhận nó vào một ví hoàn toàn mới mà chỉ riêng bạn kiểm soát.", "claimBtn": "Nhận ngay", + "claimNowBtn": "Nhận ngay", "claimFailed": "Món quà này đã được nhận hoặc không còn khả dụng.", "claimedTitle": "Đã nhận!", "claimedBody": "Bitcoin của bạn đang trên đường tới. Hãy sao lưu cụm từ khôi phục để giữ an toàn.", @@ -215,6 +220,17 @@ "followHistNone": "Không bản cũ nào có người mà danh sách của bạn đang thiếu.", "followHistNothing": "Mọi người trong bản đó đã có trong danh sách của bạn.", "followHistRestored": "Đã khôi phục {n} người theo dõi.", + "profHistDesc": "Nếu một ứng dụng khác đã thay thế hồ sơ của bạn, bản cũ hơn có thể vẫn còn trên relay lưu lịch sử. Dùng một bản chỉ điền vào các ô đang trống ở đây.", + "profHistLook": "Tìm bản cũ hơn", + "profHistUse": "Dùng", + "profHistNoName": "(không tên)", + "profHistNone": "Không bản cũ nào có thứ còn thiếu ở đây.", + "profHistNothing": "Mọi thứ trong bản đó đã có ở đây.", + "profHistFilled": "Đã điền {n} ô từ bản cũ hơn. Lưu để đăng.", + "profHist_name": "tên", + "profHist_about": "tiểu sử", + "profHist_picture": "ảnh", + "profHist_banner": "ảnh bìa", "securityTitle": "Bảo mật", "arkCoinsNone": "Chưa có coin nào trong Chi tiêu.", "profKeyLink": "Khóa công khai", @@ -247,6 +263,8 @@ "mediaNoImages": "Bảng tin này chưa có ảnh.", "mediaNoVideos": "Bảng tin này chưa có video dọc.", "videoTapToPlay": "Chạm để phát", + "videoPlay": "Phát", + "videoPause": "Tạm dừng", "videoLargeTitle": "Video", "videoLargeToggle": "Tự động phát video lớn", "videoLargeHelp": "Video trên {mb} MB hoặc cao hơn 1080p sẽ chờ bạn chạm, để bảng tin không ngốn dữ liệu. Giới hạn thấp hơn khi kết nối chậm hoặc tiết kiệm dữ liệu.", @@ -261,5 +279,15 @@ "arkRenewViewTx": "Xem giao dịch của vòng", "pagerFetchingVideo": "Đang tải video tiếp theo…", "pagerFetchingPictures": "Đang tải thêm ảnh…", - "pagerCaughtUp": "Tạm thời chỉ có vậy" + "pagerCaughtUp": "Tạm thời chỉ có vậy", + "onbGiftNameTitle": "Giờ hãy chọn tên người dùng", + "onbGiftNameBody": "Nhận một cái tên và địa chỉ thanh toán của riêng bạn, để mọi người gửi bitcoin cho bạn chỉ bằng tên. Sau đó chọn ảnh đại diện và ảnh bìa.", + "namesChangeTo": "Đổi thành {addr}", + "schedToggle": "Hẹn giờ đăng", + "schedAt": "Đăng lúc", + "schedBtn": "Hẹn giờ", + "schedDone": "Đã hẹn lúc {when}", + "schedTooSoon": "Chọn thời điểm ít nhất hai phút kể từ bây giờ", + "schedListTitle": "Đã hẹn ({n})", + "schedCancelled": "Đã huỷ bài hẹn giờ" } diff --git a/src/locales/zh.json b/src/locales/zh.json index 6b3cc49..4fc2f4b 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -364,6 +364,10 @@ "giftWelcome": "有人给你转了 Bitcoin", "giftBackedUp": "我已经抄写下来了", "giftLink": "赠送一些 Bitcoin", + "giftNotePlaceholder": "添加留言(可选)", + "giftFromMe": "显示是我送的(领取者会关注你)", + "giftFrom": "来自", + "giftClaimFollows": "领取后会关注对方", "giftLinkDesc": "将一笔金额预签为一个链接。打开链接的人可将这笔 Bitcoin 领取到一个仅由他们自己掌控的全新钱包中。", "giftLinkWarn": "⚠ 任何打开此链接的人都能领取这笔金额,所以请仅分享给你要赠送的对象。请保持金额较小。", "giftLinkReveal": "创建赠送链接", @@ -425,6 +429,7 @@ "claimOr": "或者说", "goToWallet": "转到钱包", "claimBtn": "领取", + "claimNowBtn": "立即领取", "claimFeeNote": "~ (法语){n} 收费", "claimFailed": "此礼物已被领取或不再可用。", "claimNotVisibleBody": "从这个钱包的网络/数据来源看, 确保这个钱包使用与发送者相同的网络和数据源,然后再次尝试.", @@ -618,6 +623,8 @@ "onbSpendBody": "要求一个用户名并获得你自己的付款地址,人们可以立刻发送你比特币,只要你的名字.", "onbNameWait": "准备...", "onbNotNow": "现在不行", + "onbGiftNameTitle": "现在选一个用户名", + "onbGiftNameBody": "领取一个名字,获得你自己的收款地址,别人只用你的名字就能给你发比特币。然后选一张头像和封面。", "onbAvatarTitle": "选择你的外观", "onbAvatarBody": "选择一个头像或上传自己的照片。你可以随时更改", "onbUpload": "上传相片", @@ -640,7 +647,7 @@ "nlPasskey": "用密码签名", "nlKeyOrBunker": "nsec,私人钥匙,或地堡://.", "nlGo": "登录", - "nlHint": "与Amber,Amethyst等远程签名者(打上他们的地堡/地址)合作,或是一个浏览器扩展. 粘贴的密钥使用一次, 并且从未被存储 。", + "nlHint": "支持远程签名应用(粘贴其 bunker:// 地址)或浏览器扩展。粘贴的密钥只使用一次,从不保存。", "nlApprove": "批准您签名人的请求 。", "nlUnrecognized": "这不是私人钥匙或地堡地址。", "nlNewTitle": "为此账户创建钱包", @@ -678,6 +685,7 @@ "namesClaim": "索赔", "namesClaimed": "你现在是 {name}", "namesCopy": "复制地址", + "namesChangeTo": "改为 {addr}", "namesMoreOpen": "更多选项", "namesMoreTitle": "更多赚钱的方法", "namesMoreBolt11": "闪电发票(BOLT 11)", @@ -878,6 +886,17 @@ "followHistNone": "没有旧副本包含你列表中缺少的人。", "followHistNothing": "该副本中的所有人都已在你的列表中。", "followHistRestored": "已恢复 {n} 个关注。", + "profHistDesc": "如果其他应用替换了你的资料,保留历史的中继上可能还有旧副本。使用它只会填入这里为空的字段。", + "profHistLook": "查找旧副本", + "profHistUse": "使用", + "profHistNoName": "(无名称)", + "profHistNone": "没有旧副本包含这里缺少的内容。", + "profHistNothing": "那份副本里的内容这里都已有了。", + "profHistFilled": "已从旧副本填入 {n} 个字段。保存即可发布。", + "profHist_name": "名称", + "profHist_about": "简介", + "profHist_picture": "头像", + "profHist_banner": "横幅", "securityTitle": "安全", "arkCoinsNone": "支出账户中还没有币。", "profKeyLink": "公钥", @@ -910,6 +929,8 @@ "mediaNoImages": "此动态中还没有图片。", "mediaNoVideos": "此动态中还没有竖屏视频。", "videoTapToPlay": "点按播放", + "videoPlay": "播放", + "videoPause": "暂停", "videoLargeTitle": "视频", "videoLargeToggle": "自动播放大视频", "videoLargeHelp": "超过 {mb} MB 或高于 1080p 的视频会等你点按再播放,以免动态耗尽流量。在慢速或省流量连接下,上限更低。", @@ -924,5 +945,12 @@ "arkRenewViewTx": "查看该轮交易", "pagerFetchingVideo": "正在获取下一个视频…", "pagerFetchingPictures": "正在获取更多图片…", - "pagerCaughtUp": "暂时就这些了" + "pagerCaughtUp": "暂时就这些了", + "schedToggle": "定时发布", + "schedAt": "发布时间", + "schedBtn": "定时", + "schedDone": "已定时于 {when}", + "schedTooSoon": "请选择至少两分钟之后的时间", + "schedListTitle": "已定时({n})", + "schedCancelled": "已取消定时帖子" } diff --git a/src/marmot-client.js b/src/marmot-client.js index b514216..707003f 100644 --- a/src/marmot-client.js +++ b/src/marmot-client.js @@ -16,7 +16,7 @@ import { dlog } from './debug.js'; // Where the groups we found live. Signed into group state, so every member // reads and writes the same list. Not relay.damus.io or relay.nostr.band: // White Noise (MDK's RETIRED_RELAY_HOSTS) never dials those. -export const GROUP_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net']; +export const GROUP_RELAYS = ['wss://messages.coinos.io', 'wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net']; // Where White Noise looks people up. It will not invite an account whose // relay lists (kinds 10002 and 10050) it cannot find, and one whose profile diff --git a/src/media-timeline.js b/src/media-timeline.js index 8b6045e..d8ae008 100644 --- a/src/media-timeline.js +++ b/src/media-timeline.js @@ -85,7 +85,7 @@ export function markAt(seconds, duration) { // with closures pointing at a detached player. What was said, reacted or // zapped at a moment sits on the bar at that moment; replies, reactions and // zaps made while a clip plays carry its time on their own (see momentOf). -export function mediaTimeline(player, url, { h, event, moments, glyph, label }) { +export function mediaTimeline(player, url, { h, event, moments, glyph, label, onMarks = null }) { const audio = player.tagName === 'AUDIO'; const box = h('div', { class: 'media-timeline', onClick: e => e.stopPropagation(), onPointerdown: e => e.stopPropagation() }); box._skipMorph = true; @@ -123,6 +123,10 @@ export function mediaTimeline(player, url, { h, event, moments, glyph, label }) } const items = ev && finite ? moments(ev.id, url) : []; const next = items.map(x => x.id).join(',') + '@' + (finite ? player.duration : 0); + // a video has its own seek bar: this one shows under it only to carry + // what was said at a moment (two bars read as a glitch on a phone) + box.classList.toggle('has-marks', items.length > 0); + if (onMarks) onMarks(items.length > 0); if (signature !== next) { signature = next; marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark', title: mediaClock(x.moment.seconds) + ' ' + x.text, diff --git a/src/media-upload.js b/src/media-upload.js index f851943..7f020cf 100644 --- a/src/media-upload.js +++ b/src/media-upload.js @@ -73,6 +73,8 @@ function putWithProgress(url, { body, headers, signal }, onProgress) { }); } +const hostOf = (server) => { try { return new URL(server).hostname.replace(/^blossom\./, ''); } catch { return server; } }; + // BUD-02 upload. `signEvent` is deliberately injected: the app can use its // signed-in Nostr identity, while tests need no secret key. export async function uploadPublicMedia(file, signEvent, { @@ -97,7 +99,7 @@ export async function uploadPublicMedia(file, signEvent, { // upload timeout. Any HTTP answer, even an error, means it's there. if (i < servers.length - 1) { try { await fetcher(server.replace(/\/$/, '') + '/upload', { method: 'HEAD', signal: AbortSignal.timeout(5000) }); } - catch (error) { failures.push({ status: 0, reason: 'unreachable' }); continue; } + catch (error) { failures.push({ host: hostOf(server), status: 0, reason: 'unreachable' }); continue; } } try { const init = { @@ -116,12 +118,12 @@ export async function uploadPublicMedia(file, signEvent, { try { body = await response.json(); } catch {} const url = response.ok && responseUrl(body); if (url) return mediaUrl(url, server, file); - failures.push({ status: response.status, reason: response.headers.get('x-reason') || body?.message || '' }); + failures.push({ host: hostOf(server), status: response.status, reason: response.headers.get('x-reason') || body?.message || '' }); } catch (error) { - failures.push({ status: 0, reason: error?.message || '' }); + failures.push({ host: hostOf(server), status: 0, reason: error?.message || '' }); } } - const details = failures.map((x) => [x.status, x.reason].filter(Boolean).join(' ')).filter(Boolean).join('; '); + const details = failures.map((x) => x.host + ': ' + ([x.status, x.reason].filter(Boolean).join(' ') || '?')).join('; '); if (failures.length && failures.every((x) => x.status === 401 || x.status === 403)) throw new MediaUploadError('unauthorized', details); if (failures.length && failures.every((x) => x.status === 413)) throw new MediaUploadError('too-big', details); if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details); diff --git a/src/nip29-client.js b/src/nip29-client.js new file mode 100644 index 0000000..040733c --- /dev/null +++ b/src/nip29-client.js @@ -0,0 +1,325 @@ +// NIP-29 relay-based groups in the app: the fourth way a group chat can +// travel, beside White Noise, Cordn and Concord. A group lives on ONE relay, +// which keeps its membership and signs its metadata (kinds 39000 name, +// 39001 admins, 39002 members); its messages are ordinary signed events +// (kind 9 chat, 7 reactions, 5 deletions) carrying an `h` tag with the +// group's id. Nothing is encrypted: the relay (and, in an open group, +// anyone) can read it. Membership is asked for with kind 9021 and given up +// with 9022; the relay decides. +// +// The same interface as marmot-client.js and cordn-client.js, so the group +// screens in features/messages.js drive all three (`g.nip29` says which). +// Groups joined here and in other clients are one list: NIP-51 kind 10009 +// ("simple groups"), read at start and written back on join and leave — +// replaceable, so never written without first reading the newest copy. + +import { subscribeAuthed, publishOn, queryOn, finalizeEvent } from './nostr.js'; +import { decode as nip19decode } from 'nostr-tools/nip19'; +import { getEventHash } from 'nostr-tools/pure'; +import { marmotStore } from './marmot-store.js'; +import { dlog } from './debug.js'; + +const LIST = 10009; +const CHAT_KINDS = [9, 7, 5, 9000, 9001, 9021, 9022]; +const META_KINDS = [39000, 39001, 39002]; +const LOG_MAX = 600; // events held per group in memory +const KEEP = 200; // ...and on disk, so a reload paints at once +const now = () => Math.floor(Date.now() / 1000); +const GID = /^[A-Za-z0-9_-]{1,128}$/; + +// The relay's URL, normalized: wss://host[/path], no trailing slash. +export function normRelay(u) { + let x = String(u || '').trim(); + if (!x) return null; + if (!/^wss?:\/\//i.test(x)) x = 'wss://' + x.replace(/^\/+/, ''); + try { + const url = new URL(x); + if (!/^wss?:$/.test(url.protocol) || !url.hostname.includes('.') && url.hostname !== 'localhost' && !/^\d/.test(url.hostname)) return null; + return (url.protocol + '//' + url.host + url.pathname).replace(/\/+$/, '').toLowerCase(); + } catch { return null; } +} + +// A group's address in whatever form someone pastes it: host'id (the +// NIP-29 form), wss://host'id, an naddr of its kind 39000, or a web link +// from a client (chachi.chat/<host>/<id>, flotilla's /spaces/<host>/<id>, …). +export function parseGroupAddress(input) { + let s = String(input || '').trim().replace(/^nostr:/i, ''); + if (!s) return null; + if (/^naddr1/i.test(s)) { + try { + const d = nip19decode(s); + if (d.type === 'naddr' && d.data.kind === 39000 && GID.test(d.data.identifier) && d.data.relays?.length) { + const relay = normRelay(d.data.relays[0]); + return relay ? { relay, gid: d.data.identifier } : null; + } + } catch {} + return null; + } + const q = s.match(/^(wss?:\/\/)?([^'\s/]+(?:\/[^'\s]*)?)'([A-Za-z0-9_-]+)$/); + if (q) { + const relay = normRelay((q[1] || 'wss://') + q[2]); + return relay && GID.test(q[3]) ? { relay, gid: q[3] } : null; + } + if (/^https?:\/\//i.test(s)) { + try { + const u = new URL(s); + const parts = u.pathname.split('/').filter(Boolean).map(decodeURIComponent); + for (let i = 0; i < parts.length - 1; i++) { + const host = parts[i].replace(/^wss?:\/\//i, ''); + if (host.includes('.') && GID.test(parts[i + 1])) { + const relay = normRelay(host); + if (relay) return { relay, gid: parts[i + 1] }; + } + } + } catch {} + } + return null; +} + +const keyOf = (relay, gid) => relay + "'" + gid; + +// listRelays(deep): where our kind 10009 is read and written. +export function nip29Client({ scope, pubkey, identity, on, listRelays, store = marmotStore(scope + '|nip29') }) { + const groups = new Map(); // key -> record + let loaded = null, stopped = false, list = null; // list: { tags, at } + const subs = new Map(); // key -> unsubscribe + const leftNow = new Set(); // left this session: a list read still in flight mustn't bring them back + const emit = (type, g, data) => { try { on(type, g, data); } catch (e) { dlog('nip29: listener', e); } }; + + // ---- signing ---- + async function sign(tmpl, { interactive = true } = {}) { + const id = await identity(); + if (!id || !id.signer || id.pubkey !== pubkey) throw new Error('no signer'); + if (id.signer instanceof Uint8Array) return finalizeEvent(tmpl, id.signer); + if (id.signer.interactive && !interactive) throw new Error('signer is interactive'); + return id.signer.signEvent(tmpl); + } + // NIP-42: a private group shows itself, and any group takes a post, only + // from a signed-in member. Never a trip through another app unasked. + const onauth = (tmpl) => sign(tmpl, { interactive: false }); + + // ---- storage ---- + function record(relay, gid) { + const id = keyOf(relay, gid); + let g = groups.get(id); + if (!g) { + g = { id, nip29: true, relay, gid, log: [], at: now(), meta: {}, admins: [], members: [], metaAt: {} }; + groups.set(id, g); + } + return g; + } + let flushTimer = 0; + const dirty = new Set(); + const touch = (g) => { dirty.add(g.id); clearTimeout(flushTimer); flushTimer = setTimeout(flush, 600); }; + async function flush() { + clearTimeout(flushTimer); + const ids = [...dirty]; + dirty.clear(); + await Promise.all(ids.map((id) => { + const g = groups.get(id); + if (!g) return store.del('g:' + id); + const { log, ...rest } = g; + return store.put('g:' + id, { ...rest, log: log.filter((e) => !e.pending).slice(-KEEP) }); + })).catch((e) => dlog('nip29: save', e)); + } + function load() { + if (!loaded) + loaded = store.all().then((all) => { + for (const [k, v] of all) if (k.startsWith('g:') && v && v.relay && v.gid) groups.set(v.id, { ...v, log: v.log || [] }); + const l = all.get('list'); + if (l && Array.isArray(l.tags)) list = l; + }).catch((e) => dlog('nip29: store', e)); + return loaded; + } + + // ---- events in ---- + // A reply in NIP-29 chat quotes what it answers (`q`); the group screens + // read a reply's target from its first `e`, so one is added for display. + function display(ev) { + if (ev.kind !== 9 || ev.tags.some((x) => x[0] === 'e')) return ev; + const q = ev.tags.find((x) => x[0] === 'q' && /^[0-9a-f]{64}$/.test(x[1] || '')); + return q ? { ...ev, tags: [...ev.tags, ['e', q[1]]] } : ev; + } + function take(g, ev) { + if (META_KINDS.includes(ev.kind)) { + if ((g.metaAt[ev.kind] || 0) >= ev.created_at) return; + g.metaAt[ev.kind] = ev.created_at; + const tag = (k) => (ev.tags.find((x) => x[0] === k) || [])[1]; + if (ev.kind === 39000) { + g.meta = { name: tag('name') || '', about: tag('about') || '', picture: tag('picture') || '', + closed: ev.tags.some((x) => x[0] === 'closed'), private: ev.tags.some((x) => x[0] === 'private') }; + } else { + const pks = [...new Set(ev.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))]; + if (ev.kind === 39001) g.admins = pks; else g.members = pks; + } + touch(g); + emit('meta', g); + return; + } + if (g.log.some((e) => e.id === ev.id && !e.pending)) return; + const i = g.log.findIndex((e) => e.id === ev.id); + if (i >= 0) g.log.splice(i, 1); // our own, confirmed by the relay + const shown = display(ev); + g.log.push(shown); + g.log.sort((a, b) => a.created_at - b.created_at); + if (g.log.length > LOG_MAX) g.log.splice(0, g.log.length - LOG_MAX); + if (!ev.pending) g.seen = Math.max(g.seen || 0, ev.created_at); + touch(g); + emit('message', g, shown); + } + + function subscribe(g) { + if (subs.has(g.id) || stopped) return; + const since = g.seen ? g.seen - 60 : undefined; + const a = subscribeAuthed([g.relay], { kinds: CHAT_KINDS, '#h': [g.gid], limit: 300, ...(since ? { since } : {}) }, + (ev) => { if (ev.tags.some((x) => x[0] === 'h' && x[1] === g.gid)) take(g, ev); }, { onauth }); + // metadata: only what the group's own relay signed (the copies other + // relays carry are read nowhere) + const b = subscribeAuthed([g.relay], { kinds: META_KINDS, '#d': [g.gid] }, + (ev) => { if (ev.tags.some((x) => x[0] === 'd' && x[1] === g.gid)) take(g, ev); }, { onauth }); + subs.set(g.id, () => { a(); b(); }); + } + function unsubscribe(g) { const u = subs.get(g.id); if (u) { u(); subs.delete(g.id); } } + + // ---- the joined-groups list (kind 10009) ---- + const listGroups = (tags) => (tags || []).filter((x) => x[0] === 'group' && GID.test(x[1] || '') && normRelay(x[2])) + .map((x) => ({ gid: x[1], relay: normRelay(x[2]), name: x[3] || '' })); + async function readList(deep = false) { + const relays = await listRelays(deep); + const evs = await queryOn(relays, { kinds: [LIST], authors: [pubkey] }, deep ? 7000 : 5000); + const newest = (evs || []).filter((e) => e.pubkey === pubkey).sort((a, b) => b.created_at - a.created_at)[0]; + if (newest && (!list || newest.created_at > list.at)) { + list = { tags: newest.tags || [], content: newest.content || '', at: newest.created_at }; + store.put('list', list).catch(() => {}); + } + return list; + } + // Apply one change to the newest list the relays hold (never to a copy + // we only assume is current), then publish it. + async function writeList(change) { + const cur = await readList(!list).catch(() => list); + const base = cur || { tags: [], content: '', at: 0 }; + const tags = change(base.tags); + if (!tags) return; + const created_at = Math.max(now(), base.at + 1); + const ev = await sign({ kind: LIST, created_at, tags, content: base.content || '' }); + const ok = await publishOn(await listRelays(false), ev); + if (ok) { list = { tags, content: base.content || '', at: created_at }; store.put('list', list).catch(() => {}); } + } + async function adoptList() { + try { + await readList(!list); + const l = list; // the newest, a leave's own write included + if (!l || stopped) return; + for (const x of listGroups(l.tags)) { + if (groups.has(keyOf(x.relay, x.gid)) || leftNow.has(keyOf(x.relay, x.gid))) continue; + const g = record(x.relay, x.gid); + if (x.name && !g.meta.name) g.meta = { ...g.meta, name: x.name }; + touch(g); + subscribe(g); + emit('loaded', g); + } + } catch (e) { dlog('nip29: list', e); } + } + + // ---- actions ---- + // Ask the relay to let us in, and hold the group here either way: an + // open group can be read before (or without) the relay's yes. + async function join(address, { code = '' } = {}) { + const a = typeof address === 'string' ? parseGroupAddress(address) : address; + if (!a) throw new Error('bad address'); + await load(); + leftNow.delete(keyOf(a.relay, a.gid)); + const g = record(a.relay, a.gid); + touch(g); + subscribe(g); + emit('joined', g); + let refused = ''; + const req = await sign({ kind: 9021, created_at: now(), content: '', tags: [['h', a.gid], ...(code ? [['code', code]] : [])] }); + const ok = await publishOn([a.relay], req, { onauth, onrefused: (_, why) => { refused = why; } }); + // "duplicate"/"already a member" is a yes + if (!ok && !/already|duplicate/i.test(refused)) g.joinRefused = refused || 'unreachable'; + else g.joinRefused = ''; + touch(g); + writeList((tags) => tags.some((x) => x[0] === 'group' && x[1] === a.gid && normRelay(x[2]) === a.relay) ? null + : [...tags, ['group', a.gid, a.relay, g.meta.name || ''].filter((v, i) => i < 3 || v)]).catch((e) => dlog('nip29: list write', e)); + return g; + } + + // The NIP-29 "previous" tags: the first 8 characters of a few recent + // events this relay sent us, so it can tell we've seen the timeline. + const previous = (g) => g.log.filter((e) => !e.pending && e.pubkey !== pubkey).slice(-3).map((e) => ['previous', e.id.slice(0, 8)]); + + function event(kind, content, tags = []) { + const ev = { kind, content, tags, created_at: now(), pubkey }; + return { ...ev, id: getEventHash(ev) }; + } + // A reply's `e` becomes the `q` NIP-29 chat clients read. + function outTags(g, inner) { + let tags = inner.tags || []; + if (inner.kind === 9) tags = tags.map((x) => { + if (x[0] !== 'e') return x; + const target = g.log.find((e) => e.id === x[1]); + return ['q', x[1], g.relay, ...(target ? [target.pubkey] : [])]; + }); + return [['h', g.gid], ...tags.filter((x) => x[0] !== 'h'), ...previous(g)]; + } + async function send(g, inner) { + const ev = await sign({ kind: inner.kind, content: inner.content || '', created_at: inner.created_at || now(), tags: outTags(g, inner) }); + take(g, { ...ev, pending: true }); + let refused = ''; + const ok = await publishOn([g.relay], ev, { onauth, onrefused: (_, why) => { refused = why; } }); + const i = g.log.findIndex((e) => e.id === ev.id); + if (!ok) { + if (i >= 0) g.log.splice(i, 1); + g.sendRefused = refused; + emit('message', g, ev); + return false; + } + if (i >= 0) { const { pending, ...clean } = g.log[i]; g.log[i] = clean; } + g.sendRefused = ''; + touch(g); + emit('message', g, ev); + return true; + } + + async function leave(g) { + unsubscribe(g); + leftNow.add(g.id); + groups.delete(g.id); + touch(g); + emit('left', g); + try { + const ev = await sign({ kind: 9022, created_at: now(), content: '', tags: [['h', g.gid]] }); + publishOn([g.relay], ev, { onauth }).catch(() => {}); + } catch {} + await writeList((tags) => { + const next = tags.filter((x) => !(x[0] === 'group' && x[1] === g.gid && normRelay(x[2]) === g.relay)); + return next.length === tags.length ? null : next; + }).catch((e) => dlog('nip29: list write', e)); + } + + async function start() { + await load(); + if (stopped) return; + for (const g of groups.values()) subscribe(g); + emit('loaded', null); + adoptList(); + } + function resubscribe() { for (const g of groups.values()) { unsubscribe(g); subscribe(g); } } + function stop() { stopped = true; for (const g of groups.values()) unsubscribe(g); flush(); } + const no = async () => { throw new Error('not supported'); }; + + return { + pubkey, groups, load, start, stop, resubscribe, join, send, leave, + forget: leave, accept: () => {}, focus: () => {}, ready: () => {}, poll: () => {}, lookup: async () => null, + create: no, add: no, kick: no, rename: no, + event, + view: (g) => ({ name: g.meta.name || g.gid, about: g.meta.about || '', picture: g.meta.picture || '', admins: g.admins, closed: !!g.meta.closed, private: !!g.meta.private }), + peer: () => null, + accounts: (g) => g.members.length ? g.members : [...new Set(g.log.filter((e) => e.kind === 9).map((e) => e.pubkey))], + isAdmin: () => false, + groupRef: (g) => g.relay.replace(/^wss:\/\//, '') + "'" + g.gid, + isSetUp: () => true, + }; +} diff --git a/src/nostr.js b/src/nostr.js index c77fc27..4b7f5c3 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -9,7 +9,8 @@ // Sync is on by default (relay.coinos.io) but can be turned off or pointed at // other relays in Settings; the preference lives in localStorage. -import * as nip06 from 'nostr-tools/nip06'; +import { HDKey } from '@scure/bip32'; +import { seedSync } from './seed.js'; import * as nip44 from 'nostr-tools/nip44'; import * as nip04 from 'nostr-tools/nip04'; import { getPublicKey, finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure'; @@ -186,7 +187,12 @@ pool.ensureRelay = async (url, params) => { relay._coinosRL = true; const notice = relay.onnotice; relay.onnotice = (msg) => { - if (RATE_LIMITED.test(String(msg))) markRelayRateLimited(relay.url, String(msg)); + // Primal's "too many concurrent REQs" is advice, not a refusal: every + // REQ past its twenty is answered all the same (measured: 60 at once, + // 60 EOSEs, 12 notices). Cooling it off for 30 s at every boot — when + // the relay lists, profiles and the feed all ask at once — only lost + // us its answers. A CLOSED with that reason still counts (below). + if (RATE_LIMITED.test(String(msg)) && !/concurrent/i.test(String(msg))) markRelayRateLimited(relay.url, String(msg)); try { notice?.call(relay, msg); } catch {} }; const subscribe = relay.subscribe.bind(relay); @@ -284,6 +290,54 @@ export function resetRelay(url) { try { r.close(); } catch {} pool.relays.delete(n); } +// Back from a freeze (a phone's backgrounded tab: timers stopped, the +// network's idea of every socket long gone), the pool's sockets still read +// OPEN, and anything sent on them vanishes. Left to itself the library +// notices one in up to 29 s (its ping) + 20 s (the ping's timeout), then +// waits 10 s more before redialing — a minute of a feed that won't catch up +// ("no new notes since 11:35?", Adam, 2026-11-01). So on resume every open +// socket is asked one tiny REQ; one that answers within the deadline is +// fine, one that doesn't is closed and redialed now, its subscriptions +// refired by the library with `since` set to the last event each saw. A +// relay already waiting out a reconnect backoff is dialed now too. +const WAKE_PING_MS = 2500; +const DUMMY_ID = 'a'.repeat(64); +function pingRelay(r, ms) { + return new Promise((resolve) => { + let done = false; + const fin = (ok) => { if (done) return; done = true; clearTimeout(t); resolve(ok); }; + const t = setTimeout(() => fin(false), ms); + try { + const sub = r.subscribe([{ ids: [DUMMY_ID], limit: 0 }], { + label: '<wake-ping>', + oneose: () => { fin(true); try { sub.close(); } catch {} }, + onclose: () => fin(true), // a relay refusing the REQ is a relay that answers + eoseTimeout: ms + 1000, // ours fires first: the library's would count as an EOSE + }); + } catch { fin(false); } + }); +} +function redialRelay(r, why) { + console.warn('nostr: ' + r.url + ' ' + why + ' — redialing'); + // the dead socket's close would reach the library long after (a closing + // handshake nobody answers), and undo the new connection: detached first + const ws = r.ws; + try { if (ws) { ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null; ws.close(); } } catch {} + try { r.handleHardClose(why); } catch {} // the library's own path: subscriptions kept, a reconnect queued + if (r.reconnectTimeoutHandle) { clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined; } + r.connect().catch(() => {}); // now, not after the backoff +} +export async function wakeRelays(ms = WAKE_PING_MS) { + const checks = []; + for (const r of pool.relays.values()) { + if (r.connected && r.ws && r.ws.readyState === 1) checks.push(pingRelay(r, ms).then((ok) => { if (!ok) redialRelay(r, 'silent after resume'); })); + else if (!r.connectionPromise && r.reconnectTimeoutHandle) { + clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined; + r.connect().catch(() => {}); + } + } + await Promise.all(checks); +} // the relays a subscription on this list actually dials (sick ones skipped) export const liveRelayList = (list) => liveRelays(list); // ...and the ones a one-shot read goes to (rate-limited ones skipped too) @@ -354,12 +408,77 @@ async function authRelay(relay, onauth) { } return relay.auth(onauth); } +// ---- one-shot reads: per relay, closed on its own EOSE, a few at a time -- +// The pool's own querySync keeps every relay's REQ open until the SLOWEST +// relay has answered or the wait runs out: asked beside a relay still +// dialing, our own relay — done in fifty milliseconds — carried each REQ +// for five seconds, and a feed's opening asks stacked up on it by the +// dozen. Each relay's REQ now closes on that relay's EOSE. And no relay +// carries more than REQ_SLOTS of them at once: the rest wait their turn, +// which is a beat on a relay that answers and no longer than the wait on +// one that doesn't. (A Following feed opened from the laptop sent some 560 +// REQs in twenty seconds across 57 relays, over a hundred each to damus +// and primal; damus answered the home IP "banned: too many rate-limit +// violations" on 2026-11-02, and primal notices past twenty at once.) +const REQ_SLOTS = 10; +const slots = new Map(); // normalized url -> { n, q: [resolve] } +function takeSlot(url) { + const k = relayKey(url); + let s = slots.get(k); + if (!s) slots.set(k, (s = { n: 0, q: [] })); + if (s.n < REQ_SLOTS) { s.n++; return Promise.resolve(false); } + const at = Date.now(); + return new Promise((resolve) => s.q.push(() => resolve(Date.now() - at))); // how long it waited +} +function freeSlot(url) { + const s = slots.get(relayKey(url)); + if (!s) return; + const next = s.q.shift(); + if (next) next(); // the slot passes straight to the next in line + else s.n--; +} +export const reqSlotsOf = (url) => { const s = slots.get(relayKey(url)); return s ? { busy: s.n, waiting: s.q.length } : { busy: 0, waiting: 0 }; }; +// One REQ carrying `filters` on one relay. Settles at the relay's EOSE, its +// CLOSED, a failed dial, or maxWait — whichever is first — and sends CLOSE. +function askRelay(url, filters, maxWait, onEvent) { + return new Promise((resolve) => { + let finished = false, sub = null, timer = 0; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + if (sub) { try { sub.close('done'); } catch {} } + freeSlot(url); + resolve(); + }; + takeSlot(url).then((waited) => { + // a turn that came only after the ask's own wait had run out: the + // caller has moved on, and a storm of late REQs helps nobody + if (waited > maxWait) { finish(); return; } + timer = setTimeout(finish, maxWait + 1000); + try { + sub = pool.subscribeMap(filters.map((filter) => ({ url, filter })), { maxWait, onevent: onEvent, oneose: finish, onclose: finish }); + if (finished) { try { sub.close('done'); } catch {} } + } catch { finish(); } + }); + }); +} +// All relays asked at once; an event is counted once however many hold it. +async function askRelays(relays, filters, maxWait, onEvent) { + const seen = new Set(); + await Promise.all(relays.map((url) => askRelay(url, filters, maxWait, (ev) => { + if (!ev || seen.has(ev.id)) return; + seen.add(ev.id); + onEvent(ev); + }))); +} export async function queryOn(relays, filter, maxWait = 1500) { probeRelays(relays); try { const live = readRelays(relays); if (!live.length) return []; - const evs = await pool.querySync(live, filter, { maxWait }); + const evs = []; + await askRelays(live, [filter], maxWait, (ev) => evs.push(ev)); const oks = await verifyEventsAsync(evs); return evs.filter((_, i) => oks[i]); } catch { return []; } @@ -464,17 +583,8 @@ export async function queryManyOn(relays, filters, maxWait = 1500) { try { const live = readRelays(relays); if (!live.length || !filters.length) return []; - const evs = await new Promise((resolve) => { - const got = []; - let sub = null, done = false; - const finish = () => { if (!done) { done = true; resolve(got); } }; - sub = pool.subscribeMap(live.flatMap((url) => filters.map((filter) => ({ url, filter }))), { - maxWait, - onevent: (e) => got.push(e), - oneose: () => { try { sub?.close('closed automatically on eose'); } catch {} finish(); }, - onclose: finish, - }); - }); + const evs = []; + await askRelays(live, filters, maxWait, (ev) => evs.push(ev)); const oks = await verifyEventsAsync(evs); return evs.filter((_, i) => oks[i]); } catch { return []; } @@ -487,14 +597,8 @@ export async function queryStreamOn(relays, filter, onEvent, maxWait = 1500) { try { const live = readRelays(relays); if (!live.length) return; - await new Promise((resolve) => { - pool.subscribeManyEose(live, filter, { - maxWait, - onevent(event) { - pending.push(verifyOneAsync(event).then((ok) => ok && onEvent(event)).catch(() => {})); - }, - onclose: resolve, - }); + await askRelays(live, [filter], maxWait, (event) => { + pending.push(verifyOneAsync(event).then((ok) => ok && onEvent(event)).catch(() => {})); }); } catch {} finally { await Promise.all(pending); } } @@ -547,13 +651,21 @@ export { getPublicKey, finalizeEvent, generateSecretKey }; // The nostr identity a seed carries (NostrSync#load's key, without the // wallet) — so the account switcher can put a face on every account on the -// device, not just the one that's open. Memoized: nip06 stretches the seed. +// device, not just the one that's open. Memoized: the seed is stretched. +// NIP-06: m/44'/1237'/<account>'/0/0 of the BIP-39 seed — what nostr-tools' +// privateKeyFromSeedWords derives, from the seed the wallet already stretched +// (seed.js) instead of stretching the words a second time. +export function nostrKeyFromSeed(mnemonic, passphrase = '', accountIndex = 0) { + const sk = HDKey.fromMasterSeed(seedSync(mnemonic, passphrase || '')).derive(`m/44'/1237'/${accountIndex || 0}'/0/0`).privateKey; + if (!sk) throw new Error('could not derive private key'); + return sk; +} const _seedPks = new Map(); export function seedPubkey(mnemonic, passphrase = '', accountIndex = 0) { const k = `${mnemonic}\n${passphrase}\n${accountIndex}`; if (_seedPks.has(k)) return _seedPks.get(k); let pk = null; - try { pk = getPublicKey(nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined, accountIndex || 0)); } catch {} + try { pk = getPublicKey(nostrKeyFromSeed(mnemonic, passphrase, accountIndex)); } catch {} _seedPks.set(k, pk); return pk; } @@ -604,7 +716,7 @@ export const PROFILE_RELAYS = HOME_RELAYS; // Fetch a recipient's profile (name + picture) for a pubkey, newest across relays. export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { let events; - try { events = await pool.querySync(relays, { kinds: [0], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return null; } + try { events = await queryOn(relays, { kinds: [0], authors: [pubkeyHex] }, 5000); } catch { return null; } const best = events.sort((a, b) => b.created_at - a.created_at)[0]; if (!best) return null; try { @@ -626,7 +738,7 @@ export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { // 10050), else their NIP-65 read relays (kind 10002), else [] (caller falls back). export async function fetchInboxRelays(pubkeyHex, relays = PROFILE_RELAYS) { let events; - try { events = await pool.querySync(relays, { kinds: [10002, 10050], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return []; } + try { events = await queryOn(relays, { kinds: [10002, 10050], authors: [pubkeyHex] }, 5000); } catch { return []; } const newest = (kind) => events.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0]; const dm = newest(10050); if (dm) { const r = dm.tags.filter((t) => t[0] === 'relay' && t[1]).map((t) => t[1]); if (r.length) return r; } @@ -736,8 +848,8 @@ export class NostrSync { load(mnemonic, passphrase = '', accountIndex = 0) { // a derived sibling wallet (same seed, higher BIP84 account) gets its own - // nostr identity and sync slots — nip06 shifts by account index - this.sk = nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined, accountIndex || 0); + // nostr identity and sync slots — NIP-06 shifts by account index + this.sk = nostrKeyFromSeed(mnemonic, passphrase, accountIndex); this.pk = getPublicKey(this.sk); this.ck = nip44.getConversationKey(this.sk, this.pk); } diff --git a/src/notify-text.js b/src/notify-text.js new file mode 100644 index 0000000..a88ab14 --- /dev/null +++ b/src/notify-text.js @@ -0,0 +1,79 @@ +// The words of every notification the worker shows while the app is closed. +// +// Chrome on Android (and, quietly, on desktop) scores each web notification's +// title and body with an on-device "unwanted notification" model before it +// shows it, and above 90 of 100 the notification is replaced by a "Possible +// spam" warning — hidden until the user taps "Show notification", and nine +// such in a week revoke the site's permission outright on a phone that +// doesn't visit much (Chromium kAutoRevokeSuspiciousNotification). Our old +// wording scored 92–99 across the board: "Payment received", "Incoming +// payment", "Payment request", "alice is calling", "New message", "alice +// sent you a message" — the exact shape of the scam notifications the model +// was trained (on synthetic data) to catch. What it lets through is less a +// rule than a measurement; the scores here come from Chrome 156's model via +// tools/notif-spam-score.js, which re-scores SAMPLES in a throwaway Chrome. +// Measured 2026-11-03: "sats" reads as money without reading as a payment +// scam (9–14), "payment" does not (95–97); a person's NAME in the body of a +// message or reaction line pushes it over (80–99), "New chat" / "<name>" +// does not (8–32); "reply" in any title is 96–99, "Someone replied" / +// "Open coinos to see it." is 13; a reply's TEXT is uncontrollable (an +// "@name" or a link in it scores 97+), so none rides along; "Calling" / +// "<name>" is 11–29 where every "<name> is calling" shape is 80–99. +// Re-run the scorer before changing any of these. + +export const OPEN = 'Open coinos to see it.'; + +function sats(n) { + const v = Number(n); + return v > 0 ? `+${v.toLocaleString()} sats. ${OPEN}` : OPEN; +} + +// A user-facing notification by reason. `d` is the push payload (amountSat, +// emoji) plus what the device adds (a sender's name). +export function notifyText(reason, d = {}) { + switch (reason) { + case 'payment': return ['Sats received', sats(d.amountSat)]; + case 'zap': return ['Zap received', sats(d.amountSat)]; + case 'dm': return ['New chat', d.name || 'Someone wrote to you']; + case 'chat': return ['New chat activity', 'There are new messages in your communities.']; + case 'mention': return [d.reply ? 'Someone replied' : 'You were mentioned', OPEN]; + case 'reaction': { + // a unicode emoji in the title is fine (17); a :shortcode: is not (85) + const e = d.emoji && !/^:[\w-]+:$/.test(d.emoji) ? ' ' + d.emoji : ''; + return ['New reaction' + e, `to your post. ${OPEN}`]; + } + case 'call': return ['Calling', d.name || 'Someone']; + // the wallet was asked for an invoice while closed (someone paying its name) + case 'incoming': return ['Someone is sending sats', 'Open coinos to receive them.']; + // a connected app asked the closed wallet to pay + case 'spend': return ['Spend request', 'A connected app asked to spend sats. Open coinos to approve.']; + default: return notifyText('chat'); + } +} + +// What tools/notif-spam-score.js scores: one line per shape a user can see. +export const SAMPLES = [ + notifyText('payment', { amountSat: 1000 }), + notifyText('payment', { amountSat: 21 }), + notifyText('payment'), + notifyText('zap', { amountSat: 1000 }), + notifyText('zap'), + notifyText('dm', { name: 'alice' }), + notifyText('dm', { name: 'Bitcoin Bob' }), + notifyText('dm', { name: 'Jarryd' }), + notifyText('dm'), + notifyText('chat'), + notifyText('mention'), + notifyText('mention', { reply: true }), + notifyText('reaction', { emoji: '❤️' }), + notifyText('reaction', { emoji: '🤙' }), + notifyText('reaction', { emoji: ':pepe:' }), + notifyText('call', { name: 'alice' }), + notifyText('call', { name: 'Bitcoin Bob' }), + notifyText('call', { name: 'Alice Caller' }), + notifyText('call'), + notifyText('incoming'), + notifyText('spend'), + ['Payment sent', '-1,000 sats via Damus'], + ['Spending balance', 'Receive or spend sats before 11/20/2026 to keep it alive.'], +]; diff --git a/src/nwc-bg.js b/src/nwc-bg.js index 3349126..9e7a303 100644 --- a/src/nwc-bg.js +++ b/src/nwc-bg.js @@ -57,6 +57,62 @@ export async function saveBg(walletKey, rec) { export async function clearBg(walletKey) { return tx('readwrite', (s) => { s.delete(walletKey); }); } + +// The worker's diary: what it did while the app was closed — each push, the +// steps of a payment, every notification shown or refused. A phone stops a +// worker without a trace, and the diary's last line says where it got to. +// Settings → Notifications → Copy hands it to whoever is looking into it. +// Appended line by line (a worker that dies mid-push keeps what it wrote), +// one writer at a time, a few hundred lines kept. +const LOG_KEY = '__log'; +const LOG_MAX = 400; +let logChain = Promise.resolve(); +export function appendBgLog(line) { + logChain = logChain.then(() => tx('readwrite', async (s) => { + const cur = (await get(s, LOG_KEY)) || []; + cur.push(line); + s.put(cur.length > LOG_MAX ? cur.slice(-LOG_MAX) : cur, LOG_KEY); + })).catch(() => {}); + return logChain; +} +export async function readBgLog() { + try { return (await tx('readonly', (s) => get(s, LOG_KEY))) || []; } catch { return []; } +} +export async function clearBgLog() { + return tx('readwrite', (s) => { s.delete(LOG_KEY); }); +} +// A lease on the worker's wallet work, kept in this store. Two pushes seconds +// apart ran side by side on iOS, each in its own worker (two "worker started" +// lines), and nothing kept them apart: both finished one send, and the +// second, over a stale copy, marked a paid send 'revoking'. The store is +// the one thing both see, and a readwrite transaction is atomic across them. +// A lease outlives a stopped worker only for its ttl. +export async function takeLease(name, { ttl = 12_000, wait = 5_000 } = {}) { + const key = '__lease:' + name; + const me = Math.random().toString(36).slice(2); + const start = Date.now(); + for (;;) { + let got = false; + try { + got = await tx('readwrite', async (s) => { + const cur = await get(s, key); + if (cur && cur.until > Date.now() && cur.owner !== me) return false; + s.put({ owner: me, until: Date.now() + ttl }, key); + return true; + }); + } catch { got = true; } // no store, no lease: run + if (got) break; + if (Date.now() - start > wait) break; // a holder that overstays: go anyway + await new Promise((r) => setTimeout(r, 250)); + } + const waited = Date.now() - start; + const release = () => tx('readwrite', async (s) => { + const cur = await get(s, key); + if (cur && cur.owner === me) s.delete(key); + }).catch(() => {}); + return { waited, release }; +} + // The service worker doesn't know wallet cache keys — it finds the right // record by the connection's service pubkey across all of them. export async function allBgs() { @@ -67,7 +123,8 @@ export async function allBgs() { cur.onsuccess = () => { const c = cur.result; if (!c) return res(out); - out.push({ walletKey: c.key, rec: c.value }); + // the worker's diary shares the store under a reserved key + if (!(typeof c.key === 'string' && c.key.startsWith('__'))) out.push({ walletKey: c.key, rec: c.value }); c.continue(); }; cur.onerror = () => rej(cur.error); @@ -76,8 +133,15 @@ export async function allBgs() { } // How many upcoming change/refund indices travel with the record: the worker -// cannot derive keys, so its allocations must land inside this window. -export const BG_KEY_LOOKAHEAD = 25; +// cannot derive keys, so its allocations must land inside this window. Each +// Lightning send burns a change and an HTLC index, so 25 keys lasted PullCap +// about 19 hourly payments (2026-10-26, "key window exhausted" after a day +// with the app closed). 250 keys are 16 KB in the record and cover weeks of +// hourly payments, or years of monthly ones; the app tops the window up +// every time it writes the mirror. +export const BG_KEY_LOOKAHEAD = 250; +// ...and the receive preimages a closed wallet can mint invoices with +export const BG_RECV_LOOKAHEAD = 60; // { ark } endpoints, the manager state to mirror, and a keyFor(chain, index) // derive function (the caller owns the account node — it never enters this @@ -96,7 +160,7 @@ export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdra idx.add(0); // the receive-address key: mailbox/refund defaults land on it for (const i of idx) keys[String(i)] = keyFor(3, i); const keys5 = {}; - for (let i = 0; i < 15; i++) { + for (let i = 0; i < BG_RECV_LOOKAHEAD; i++) { const n = (mgrState.nextLnRecvIndex || 0) + i; keys5[String(n)] = keyFor(5, n); } @@ -119,7 +183,7 @@ export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdra // worker counts what the app spent; a revoked one rides along flagged, to // be refused rather than ignored. connections: (connections || []).map((c) => ({ - id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk, + id: c.id, name: c.name || '', servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk, maxSat: c.maxSat, dailySat: c.dailySat, period: c.period || 'day', spentDate: c.spentDate || null, spentToday: c.spentToday || 0, revoked: !!c.revoked, diff --git a/src/nwc-lock.js b/src/nwc-lock.js index c4fec08..bd8586a 100644 --- a/src/nwc-lock.js +++ b/src/nwc-lock.js @@ -7,6 +7,24 @@ // the sats had gone. Web Locks are shared by every same-origin context, so // the first to claim a request's lock answers it and the rest stay quiet. // Where locks are unavailable (tests, ancient engines) the caller just runs. +// The worker's wallet work, one push at a time. Every push handler builds +// its own manager over the one shared mirror, and two of them driving the +// same send at once would sign it twice (a client's retry lands seconds +// after its lookup). A waiting push keeps its waitUntil and reads the mirror +// fresh once it gets the lock. +// The Web Lock alone didn't hold on iOS (two workers at once, neither +// waited), so the lease in the worker's store (nwc-bg.js) is taken inside it. +export async function withWorkLock(fn, { lease = null } = {}) { + const locks = typeof navigator !== 'undefined' && navigator.locks; + const run = async () => { + if (!lease) return fn(0); + const l = await lease(); + try { return await fn(l.waited); } finally { await l.release(); } + }; + if (!locks) return run(); + return locks.request('nwc-bg-work', run); +} + export async function withRequestLock(id, fn, held = () => undefined) { const locks = typeof navigator !== 'undefined' && navigator.locks; if (!locks || !id) return fn(); diff --git a/src/nwc-respond.js b/src/nwc-respond.js index 10692c9..311b928 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -14,6 +14,7 @@ // to a notification that opens the wallet. import { hex } from '@scure/base'; +import { notifyText } from './notify-text.js'; import * as nip04 from 'nostr-tools/nip04'; import * as nip44 from 'nostr-tools/nip44'; import { finalizeEvent } from 'nostr-tools/pure'; @@ -24,6 +25,14 @@ import { budgetRenewsAt, renewalPeriod, spentInWindow } from './nwc-budget.js'; import { renewPolicy } from './ark/manager.js'; const MAX_AGE_SEC = 120; +// A request the worker can't act on (keys or coins short in the mirror, an +// old mirror) wakes the user — and, unless someone answers first, tells the +// other app so (PullCap read the silence as "no answer" and suspended the +// subscription; with an error it can tell the payer their wallet is waiting +// for them). The notifier holds the reply this long before sending it. +const DECLINE_AFTER_MS = 20_000; +const FAIL_AFTER_MS = 5_000; // the notifier's shortest hold +const WAIT_MSG = 'Coinos is waiting for its owner to open the app and approve. Look this request up again later.'; const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin const nowSec = () => Math.floor(Date.now() / 1000); const errRes = (t, code, message) => ({ result_type: t, error: { code, message } }); @@ -61,16 +70,97 @@ export function shimAccount(rec) { return { deriveChild: (chain) => ({ deriveChild: (i) => ({ privateKey: byChain(chain, i) }) }) }; } -export async function bgManager(rec, walletKey, saveFn) { +export async function bgManager(rec, walletKey, saveFn, log = null) { + // the diary follows each send's steps through the mirror's saves: a worker + // the phone stops mid-payment leaves its last step on record + const steps = new Map(((rec.mgr && rec.mgr.actions) || []).filter((a) => a.type === 'ln-pay').map((a) => [a.id, a.step])); const storage = { load: () => rec.mgr, - save: (s) => { rec.mgr = s; saveFn(walletKey, rec).catch(() => {}); }, + save: (s) => { + rec.mgr = s; + if (log) { + for (const a of s.actions || []) { + if (a.type !== 'ln-pay' || steps.get(a.id) === a.step) continue; + steps.set(a.id, a.step); + log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} → ${a.step}${a.error ? ': ' + a.error : ''}`); + } + } + // the worker's hand on the mirror, for an app alive behind it: it + // folds this state in before it next writes its own over it + rec.workerAt = Date.now(); + Promise.resolve(saveFn(walletKey, rec)).catch(() => {}); + }, }; - return new ArkManager({ + const m = new ArkManager({ account: shimAccount(rec), storage, arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network, honorsExpired: !!rec.ark.honorsExpired, - }).init(); + lnOldestFirst: () => !!rec.custody && rec.custody !== 'custodial', + }); + // the chain tip a payment needs, asked for while the manager starts + try { m.warmTip().catch(() => {}); } catch { /* asked again when needed */ } + return m.init(); +} + +// ---- sends in flight across wakes ------------------------------------------ +// The phone stops a worker without warning: between reserving a coin and the +// ASP's cosign, or between the ASP paying and our reply. The action stays in +// the mirror at its last step, its coin reserved, and only the app used to +// finish it. These let the worker finish it on a later wake instead. +const pendingLnPays = (rec) => ((rec.mgr && rec.mgr.actions) || []) + .filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step)); +// which connection a send belongs to, kept beside the mirror: the action is +// the manager's and knows nothing of NWC +function notePending(rec, hash, conn) { + rec.pendingPays = (rec.pendingPays || []).filter((p) => p.hash !== hash).slice(-50); + rec.pendingPays.push({ hash, connId: conn.id, ts: Date.now() }); +} +function forgetPending(rec, hash) { + if (rec.pendingPays) rec.pendingPays = rec.pendingPays.filter((p) => p.hash !== hash); +} +function connOf(rec, a) { + const p = (rec.pendingPays || []).find((x) => x.hash === a.paymentHash); + return (p && (rec.connections || []).find((c) => c.id === p.connId)) || null; +} +// A settled send goes on the worker's spend log once; the app absorbs it +// into budgets and history on next open. +function recordBgSpend(rec, a, conn, fallback = {}) { + const hash = a.paymentHash || fallback.paymentHash; + forgetPending(rec, hash); + rec.spends = rec.spends || []; + if (hash && rec.spends.some((s) => s.paymentHash === hash)) return; + rec.spends.push({ + connId: conn ? conn.id : null, amountSat: a.amountSat || fallback.amountSat, feeSat: a.feeSat || 0, + paymentHash: hash, preimage: a.preimage, invoice: a.invoice || fallback.invoice, ts: Date.now(), + }); + if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200); +} +// Money left while the app was closed — that deserves a heads-up, unless +// the user turned "payments sent" notifications off in Settings. +function sentNotice(rec, a, conn, fallbackSat = 0) { + if (rec.prefs && rec.prefs.paySent === false) return true; + const paidSat = a.amountSat || fallbackSat; + const feeNote = a.feeSat ? ` (+${a.feeSat} fee)` : ''; + return { + notify: { + title: 'Payment sent', tag: 'nwc-sent', + body: `-${paidSat.toLocaleString()} sats${feeNote} via ${(conn && conn.name) || 'a connected app'}`, + }, + }; +} +// Drive a Lightning send on until it settles, fails, or the budget is gone. +// Every step is held to the deadline: a call to the ASP that hangs must not +// carry the worker past its budget (iOS stops it, mid-line, with no trace). +async function finishLnPay(mgr, id, deadline) { + const by = (p) => Promise.race([p, new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]); + let a = await by(mgr.awaitLnPay(id, Math.max(500, deadline - Date.now())).catch(() => null)); + a = mgr.lnAction(id); + while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline - 1500) { + await new Promise((r) => setTimeout(r, 1000)); + await by(mgr.driveLn(id).catch(() => {})); + a = mgr.lnAction(id); + } + return a; } // A CLINK offer request (kind 21001): mint an invoice against the mirrored @@ -115,7 +205,8 @@ async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log await saveFn(walletKey, rec); await publish({ bolt11: a.invoice }); log(`minted ${sat} sat offer invoice while closed`); - return { notify: { title: 'Incoming payment', body: 'Someone is paying you. Open Coinos to receive it.' } }; + const [title, body] = notifyText('incoming'); + return { notify: { title, body } }; } catch (e) { log('offer mint failed: ' + e.message); await publish({ error: 'Temporary Failure', code: 2 }).catch(() => {}); @@ -128,11 +219,14 @@ async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log // left to another device); false means "wake the user instead". export async function respondFromBg(data, { notifier, fetchFn = fetch, recordsFn = allBgs, saveFn = saveBg, log = () => {}, - errGraceMs = 3000, + errGraceMs = 3000, deadline: until = null, } = {}) { const ev = data && data.event; if (!ev || !ev.id || !ev.pubkey) return false; - if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) return false; + // Older than the request window (a push that sat at APNs while the phone + // was out of reach): the other app has moved on — nothing to answer, and + // no "Payment request" to show for it. The wake still tends what's in flight. + if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) { log(`request ${String(ev.id).slice(0, 8)} is ${nowSec() - ev.created_at} s old — ignored`); return true; } const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1]; if (!target) return false; @@ -176,6 +270,23 @@ export async function respondFromBg(data, { try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; } const method = req?.method; const params = req?.params || {}; + // Wake the user, out loud: the wait reply is left with the notifier, which + // sends it only if nobody (a sibling device, the woken user) answers in + // time — so it can never land on top of a real payment. + const declineLater = async (why) => { + log(why + ' — waking the user; the app is told to wait'); + try { + const evt = await buildReply(conn, ev, scheme, errRes(method || 'error', 'OTHER', WAIT_MSG)); + await fetchFn(`${notifier}/decline`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event: evt, afterMs: DECLINE_AFTER_MS }), + }); + } catch (e) { log('could not leave the wait reply: ' + (e && e.message)); } + return false; + }; + // the push's budget (on iOS about ten seconds from the push, not thirty) + const deadline = until || Date.now() + PAY_WAIT_MS; + let lookupNotice = null; // Revoked, lately: refuse out loud — a silent worker looks like a phone // that is off, which is the one thing the other app cannot tell apart. if (conn.revoked) { @@ -195,6 +306,8 @@ export async function respondFromBg(data, { // ...and hands each pay request to the first device that claims it, so // two awake devices don't each lock an HTLC for one zap. Unreachable → // proceed: a lone worker must not go silent over a notifier hiccup. + // (the same call says when a reply already exists: one round trip where + // "answered?" and "mine?" used to be two, one after the other) const claim = async () => { try { const r = await fetchFn(`${notifier}/claim`, { @@ -202,9 +315,25 @@ export async function respondFromBg(data, { body: JSON.stringify({ event: ev.id, by: `sw-${walletKey}`.slice(0, 64) }), }); if (!r.ok) return true; - return (await r.json()).ok !== false; + const j = await r.json(); + if (j.answered) return 'answered'; + return j.ok !== false; } catch { return true; } }; + // a claim this worker can't make good on (the wallet's server unreachable) + // is handed back, so the app the user opens isn't told to stand down + const unclaim = () => { + try { + Promise.resolve(fetchFn(`${notifier}/claim`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event: ev.id, by: `sw-${walletKey}`.slice(0, 64), release: true }), + })).catch(() => {}); + } catch { /* it lapses by itself */ } + }; + const lostClaim = (c) => { + log(c === 'answered' ? 'already answered elsewhere' : 'another device claimed this request — standing down'); + return true; + }; if (method === 'get_info') { await publish({ @@ -224,9 +353,9 @@ export async function respondFromBg(data, { if (method === 'make_invoice') { const sat = Math.floor((params.amount || 0) / 1000); if (!sat) { await publish(errRes('make_invoice', 'OTHER', 'amount required (msat)')); return true; } - if (!rec.keys5 || !rec.key4 || !rec.mgr) return false; // old mirror: wake the user - if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return false; // preimage window exhausted - if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return false; // claim key window exhausted + if (!rec.keys5 || !rec.key4 || !rec.mgr) return declineLater('old mirror, no receive keys'); + if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return declineLater('preimage window exhausted'); + if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return declineLater('key window exhausted'); let a; try { const mgr = await bgManager(rec, walletKey, saveFn); @@ -252,23 +381,47 @@ export async function respondFromBg(data, { await publish(errRes('lookup_invoice', 'OTHER', 'payment_hash or invoice required')); return true; } - if (!rec.mgr) return false; const q = { paymentHash: params.payment_hash, invoice: params.invoice }; - // read straight off the mirror; the ASP is only needed to claim - const view = Object.assign(Object.create(ArkManager.prototype), { state: rec.mgr, account: shimAccount(rec) }); - let tx = view.lnLookup(q); - const a = tx && tx.state === 'pending' && tx.type === 'incoming' - && (rec.mgr.actions || []).find((x) => x.type === 'ln-recv' && x.paymentHash === tx.payment_hash); + // read straight off the mirror; the ASP is only needed to claim. Every + // copy of the connection is asked (the freshest may be another network's + // mirror: PullCap's lookups of a send in flight came back "not found"). + let tx = null; + for (const m of matches) { + if (!m.rec.mgr) continue; + const view = Object.assign(Object.create(ArkManager.prototype), { state: m.rec.mgr, account: shimAccount(m.rec) }); + tx = view.lnLookup(q); + if (tx) { ({ walletKey, rec, conn } = m); break; } + } + if (!tx && !rec.mgr) return declineLater('old mirror, no state to look up'); + // a payment of ours still on its way (incoming: claim it; outgoing: a + // send an earlier wake began — drive it on, so the lookup itself can + // answer 'settled' with the preimage instead of a second pay request) + const a = tx && tx.state === 'pending' + && (rec.mgr.actions || []).find((x) => x.type === (tx.type === 'incoming' ? 'ln-recv' : 'ln-pay') && x.paymentHash === tx.payment_hash + && !['done', 'failed'].includes(x.step)); if (a) { try { - const mgr = await bgManager(rec, walletKey, saveFn); - await Promise.race([mgr.driveLn(a.id).catch((e) => log('claim: ' + e.message)), new Promise((r) => setTimeout(r, PAY_WAIT_MS))]); + const mgr = await bgManager(rec, walletKey, saveFn, log); + if (a.type === 'ln-pay') { + const r = await finishLnPay(mgr, a.id, Math.min(deadline, Date.now() + 6000)); + if (r && r.step === 'done') { + const c = connOf(rec, r) || conn; + recordBgSpend(rec, r, c); + log(`finished a ${r.amountSat} sat send from an earlier wake`); + const n = sentNotice(rec, r, c); + if (n !== true) lookupNotice = n.notify; + } else if (r && r.step === 'failed') forgetPending(rec, a.paymentHash); + await saveFn(walletKey, rec); + } else { + await Promise.race([mgr.driveLn(a.id).catch((e) => log('claim: ' + e.message)), new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]); + } tx = mgr.lnLookup(q); - if (tx.state === 'settled') log(`received ${tx.amount / 1000} sat while closed`); + if (tx.state === 'settled' && tx.type === 'incoming') log(`received ${tx.amount / 1000} sat while closed`); } catch (e) { log('ark init failed: ' + e.message); } } await publish(tx ? { result_type: 'lookup_invoice', result: tx } : errRes('lookup_invoice', 'NOT_FOUND', 'invoice not found')); - return true; + log(`lookup: ${tx ? tx.type + ' ' + tx.state : 'not found'}`); + return lookupNotice ? { notify: lookupNotice } : true; } if (method === 'get_balance') { await publish({ result_type: 'get_balance', result: { balance: spendableSat(rec) * 1000 } }); @@ -304,12 +457,21 @@ export async function respondFromBg(data, { } // ---- pay_invoice ------------------------------------------------------ - if (await answered()) { log('already answered elsewhere'); return true; } - + // What the mirror alone can decide comes first, with no network at all; + // then the claim (which also says if someone already answered) goes out + // beside the manager's start. Each of these used to wait for the one + // before it: a zap answered from a closed phone spent about six seconds + // getting to the payment (398ja, 2026-11-10: "a lot more slower"). + // A refusal never goes out over another device's answer. + const refuse = async (code, message) => { + if (await answered()) { log('already answered elsewhere'); return true; } + await publish(errRes('pay_invoice', code, message)); + return true; + }; const invoice = params.invoice; const dec = invoice && maybeBolt11(invoice); - if (!dec) { await publish(errRes('pay_invoice', 'OTHER', 'not a bolt11 invoice')); return true; } - if (!dec.amountSat) { await publish(errRes('pay_invoice', 'OTHER', 'zero-amount invoices are not supported')); return true; } + if (!dec) return refuse('OTHER', 'not a bolt11 invoice'); + if (!dec.amountSat) return refuse('OTHER', 'zero-amount invoices are not supported'); // The bolt11 names its network; only a record on that network can pay it. // A mismatched copy fails in milliseconds with a nonsense error that beats // a sibling device's real payment to the client — so stand down instead, @@ -322,54 +484,135 @@ export async function respondFromBg(data, { return answered(); } ({ walletKey, rec, conn } = fit); - if (dec.amountSat > conn.maxSat) { - await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the ${conn.maxSat} sat per-payment limit`)); - return true; - } + if (dec.amountSat > conn.maxSat) return refuse('QUOTA_EXCEEDED', `over the ${conn.maxSat} sat per-payment limit`); // What the app spent this window rides on the mirrored connection; the // worker's own spends the app has not yet folded in are counted from its log. const { left } = spentInWindow(conn, rec.spends || []); - if (dec.amountSat > left) { - await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining ${renewalPeriod(conn.period)} budget (${left} sat)`)); - return true; - } - if (spendableSat(rec) < dec.amountSat) return false; // stale/empty mirror: wake the user - - // The ASP's own Lightning send, over the mirrored state. Change and HTLC - // outputs allocate from nextKeyIndex, which must stay inside the key - // window the app pre-derived; if it ran out, wake the user instead. - if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) { log('key window exhausted'); return false; } - let mgr; - try { - mgr = await bgManager(rec, walletKey, saveFn); - } catch (e) { log('ark init failed: ' + e.message); return false; } - - // A local failure carries no urgency — wait out the grace period and - // re-check before publishing, so a sibling device's slower SUCCESS reply - // wins the client's eyes instead of being shouted over. + if (dec.amountSat > left) return refuse('QUOTA_EXCEEDED', `over the remaining ${renewalPeriod(conn.period)} budget (${left} sat)`); + // A send this worker began on an earlier wake and never finished (PullCap's + // night of 2026-10-23: three hours of fifteen). Its coin sat reserved in + // the mirror, so a fresh attempt at the SAME invoice was refused as + // "already paid or is being paid" — which the client read as paid, hours + // before the money moved — and one at any OTHER invoice found no spendable + // coin and woke the user. Finish what was begun first: the same invoice + // gets that payment's outcome, another invoice gets the coin back. + const stuck = pendingLnPays(rec); + const same = stuck.find((a) => a.paymentHash === dec.paymentHash) + || (rec.mgr.actions || []).find((a) => a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step === 'done'); + let mgr = null; + let mgrP = null; + const ready = async () => mgr || (mgr = await (mgrP || (mgrP = bgManager(rec, walletKey, saveFn, log)))); + // the manager's start (the ASP's info, the chain tip) runs while the + // notifier is asked whether anyone else answered + ready().catch(() => {}); + // A local failure carries no urgency — a sibling device's slower SUCCESS + // reply must win the client's eyes instead of being shouted over. The + // error is left with the notifier, which sends it only if nobody answers + // in FAIL_AFTER_MS: waiting here instead ran past iOS's ten seconds after + // a failed send, and the other app heard nothing at all (PullCap, + // 2026-10-29). Waiting here is the fallback when the notifier won't take it. const failQuietly = async (message) => { + const code = /insufficient/i.test(message || '') ? 'INSUFFICIENT_BALANCE' : 'PAYMENT_FAILED'; + try { + const evt = await buildReply(conn, ev, scheme, errRes('pay_invoice', code, message)); + const r = await fetchFn(`${notifier}/decline`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event: evt, afterMs: FAIL_AFTER_MS }), + }); + if (r.ok) { log(`payment failed (${message}) — ${code} goes out unless someone answers first`); return true; } + } catch {} await new Promise((r) => setTimeout(r, errGraceMs)); if (!(await answered())) { - await publish(errRes('pay_invoice', 'INTERNAL', message)).catch(() => {}); + await publish(errRes('pay_invoice', code, message)).catch(() => {}); } return true; }; - if (!(await claim())) { log('another device claimed this request — standing down'); return true; } - const deadline = Date.now() + PAY_WAIT_MS; + const settled = async (a, c, { record = true } = {}) => { + if (record) recordBgSpend(rec, a, c, { paymentHash: dec.paymentHash, amountSat: dec.amountSat, invoice }); + // the mirror and the reply at once: every second counts on iOS, and the + // "Payment sent" line comes after both + await Promise.all([ + saveFn(walletKey, rec), + publish({ result_type: 'pay_invoice', result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 } }).then(() => log('reply sent')), + ]); + return sentNotice(rec, a, c, dec.amountSat); + }; + if (same) { + const c = await claim(); + if (c !== true) return lostClaim(c); + log(`a send for this invoice is already ${same.step} — finishing it`); + const wasDone = same.step === 'done'; + let a = same; + if (!wasDone) { + try { await ready(); } catch (e) { log('ark init failed: ' + e.message); return false; } + a = await finishLnPay(mgr, same.id, deadline); + } + // settled earlier (by the app, perhaps): its preimage again, nothing new + // on the spend log + if (a && a.step === 'done') return settled(a, conn, { record: !wasDone }); + if (a && a.step === 'failed') { forgetPending(rec, dec.paymentHash); return failQuietly(a.error || 'payment failed'); } + log('that send is still in flight — waking the user to finish it'); + return false; + } + const notices = []; + // Only when they hold the coins this payment needs: otherwise the answer + // comes first and the wake's tending finishes them after. + if (stuck.length && spendableSat(rec) < dec.amountSat) { + if (await answered()) { log('already answered elsewhere'); return true; } + try { + await ready(); + for (const a of stuck) { + if (Date.now() > deadline - 4000) break; + const r = await finishLnPay(mgr, a.id, Math.min(deadline - 3000, Date.now() + 5000)); + if (!r || r.step !== 'done') { + log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} from an earlier wake is still ${r ? r.step : 'gone'}`); + if (r && r.step === 'failed') forgetPending(rec, a.paymentHash); + continue; + } + const c = connOf(rec, r); + recordBgSpend(rec, r, c); + log(`finished a ${r.amountSat} sat send from an earlier wake`); + const n = sentNotice(rec, r, c); + if (n !== true) notices.push(n.notify); + } + await saveFn(walletKey, rec); + } catch (e) { log('ark init failed: ' + e.message); } + } + const withNotices = (out) => (notices.length && out ? { ...(out === true ? {} : out), notices } : out); + + const spendable = spendableSat(rec); + if (spendable < dec.amountSat) return declineLater(`${spendable} sat spendable in the mirror, ${dec.amountSat} asked`); + + // The ASP's own Lightning send, over the mirrored state. Change and HTLC + // outputs allocate from nextKeyIndex, which must stay inside the key + // window the app pre-derived; if it ran out, wake the user instead. + if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return declineLater('key window exhausted'); + // the claim and the manager's start (begun above), side by side + const claimP = claim(); + try { await ready(); } catch (e) { + const c = await claimP; + if (c !== true) return lostClaim(c); + unclaim(); + return declineLater('ark init failed: ' + e.message); + } + const mine = await claimP; + if (mine !== true) return lostClaim(mine); + // whose send this is, for the wake that finishes it should this one not + notePending(rec, dec.paymentHash, conn); + // (not waited on: the manager's own save of the new send, a moment on, + // writes this same record) + Promise.resolve(saveFn(walletKey, rec)).catch(() => {}); + log(`paying ${dec.amountSat} sat`); let a = null; for (let attempt = 0; ; attempt++) { let id; try { id = await mgr.payLnInvoice(invoice); } catch (e) { + forgetPending(rec, dec.paymentHash); return failQuietly(e.message); } - a = await mgr.awaitLnPay(id, Math.max(1000, deadline - Date.now())).catch(() => mgr.lnAction(id)); - while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 1500)); - await mgr.driveLn(id).catch(() => {}); - a = mgr.lnAction(id); - } + a = await finishLnPay(mgr, id, deadline); // A ghost coin (spent elsewhere, this mirror never learned) dies on the // launchpad in milliseconds. Reconcile against the server — flipping the // ghost — and retry once from live coins; the budget has barely started. @@ -384,6 +627,7 @@ export async function respondFromBg(data, { // Another device of this wallet started this same payment first. Its // outcome is ours: wait on it, and only speak if the payer stays quiet. log('a sibling device is paying this invoice — waiting on its outcome'); + forgetPending(rec, dec.paymentHash); const out = await mgr.lnOutcome(dec.paymentHash, Math.max(3000, deadline - Date.now())); if (out.status === 'failed') return failQuietly(a.error); if (out.status !== 'success') { log('sibling payment still pending — leaving it to them'); return true; } @@ -394,38 +638,50 @@ export async function respondFromBg(data, { return true; } if (!a || a.step === 'failed') { + forgetPending(rec, dec.paymentHash); return failQuietly((a && a.error) || 'payment failed'); } if (a.step !== 'done') { - // still in flight as our budget runs out: the app resumes it from the - // mirrored state on next open — wake the user rather than guess. + // still in flight as our budget runs out: the next wake (or the app, on + // open) finishes it — wake the user rather than guess. log('payment still pending at SW deadline'); return false; } - - rec.spends = rec.spends || []; - rec.spends.push({ - connId: conn.id, amountSat: a.amountSat || dec.amountSat, feeSat: a.feeSat || 0, - paymentHash: dec.paymentHash, preimage: a.preimage, invoice, ts: Date.now(), - }); - if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200); - await saveFn(walletKey, rec); - await publish({ - result_type: 'pay_invoice', - result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 }, - }); log(`paid ${a.amountSat || dec.amountSat} sat via the ASP from the background mirror`); - // Money left while the app was closed — that deserves a heads-up, unless - // the user turned "payments sent" notifications off in Settings. - if (rec.prefs && rec.prefs.paySent === false) return true; - const paidSat = a.amountSat || dec.amountSat; - const feeNote = a.feeSat ? ` (+${a.feeSat} fee)` : ''; - return { - notify: { - title: 'Payment sent', tag: 'nwc-sent', - body: `-${paidSat.toLocaleString()} sats${feeNote} via ${conn.name || 'a connected app'}`, - }, - }; + return withNotices(await settled(a, conn)); +} + +// Sends begun on an earlier wake and never finished (see pay_invoice): any +// wake drives each on, within its budget, and hands back the "Payment sent" +// lines for the ones that settle. Without this a stuck send waited for the +// app to open, its coin reserved all the while. +export async function tendLnPays({ recordsFn = allBgs, saveFn = saveBg, log = () => {}, deadline = Date.now() + 8000 } = {}) { + const notices = []; + for (const { walletKey, rec } of await recordsFn()) { + if (!rec || rec.v < 3 || !rec.mgr || !rec.ark) continue; + const stuck = pendingLnPays(rec); + if (!stuck.length || Date.now() > deadline - 3000) continue; + try { + const mgr = await Promise.race([bgManager(rec, walletKey, saveFn, log), + new Promise((_, rej) => setTimeout(() => rej(new Error('out of time')), Math.max(0, deadline - Date.now())))]); + for (const a of stuck) { + if (Date.now() > deadline - 3000) break; + const r = await finishLnPay(mgr, a.id, deadline); + if (!r || r.step !== 'done') { + log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} from an earlier wake is still ${r ? r.step : 'gone'}`); + if (r && r.step === 'failed') forgetPending(rec, a.paymentHash); + continue; + } + const c = connOf(rec, r); + recordBgSpend(rec, r, c); + log(`finished a ${r.amountSat} sat send from an earlier wake`); + const n = sentNotice(rec, r, c); + if (n !== true) notices.push(n.notify); + } + await saveFn(walletKey, rec); + } catch (e) { log('earlier sends: ' + e.message); } + } + return notices; } // Renewals while the app is closed. The app registers a standing order with diff --git a/src/public-chat.js b/src/public-chat.js index 9cce6f5..dd54342 100644 --- a/src/public-chat.js +++ b/src/public-chat.js @@ -213,7 +213,9 @@ export function mountPublicChat() { const hasAccount = (() => { try { - return !!(sessionStorage.getItem('btc-wallet-accounts') || (JSON.parse(localStorage.getItem('btc-wallet-watch') || '[]')).length); + // a session in this tab, a saved wallet (a tab opened from a link has no session yet), or a watch-only one + return !!(sessionStorage.getItem('btc-wallet-accounts') || localStorage.getItem('btc-wallet-vault') + || (JSON.parse(localStorage.getItem('btc-wallet-watch') || '[]')).length); } catch { return false; } })(); @@ -306,8 +308,9 @@ export function mountPublicChat() { h('div', { class: 'row between', style: 'align-items:center;gap:10px' }, h('div', { class: 'col', style: 'gap:2px;min-width:0' }, h('div', { class: 'chat-title' }, t('publicChatTitle', { name: COMMUNITY.name })), - h('div', { class: 'small muted' }, t('publicChatDesc'))), - h('button', { class: 'btn-primary btn-sm', style: 'flex-shrink:0', onClick: () => { location.href = '/'; } }, + h('div', { class: 'small muted' }, t(hasAccount ? 'publicChatDescMember' : 'publicChatDesc'))), + // with an account on the device the button opens the room itself (/?chat) + h('button', { class: 'btn-primary btn-sm', style: 'flex-shrink:0', onClick: () => { location.href = hasAccount ? '/?chat' : '/'; } }, hasAccount ? t('publicChatOpenApp') : t('publicChatSignIn'))), tabs), log)); diff --git a/src/pull-refresh.js b/src/pull-refresh.js new file mode 100644 index 0000000..e05fa9a --- /dev/null +++ b/src/pull-refresh.js @@ -0,0 +1,97 @@ +// Pull down at the top of the page to reload it — where the browser doesn't. +// +// Chrome's own pull-to-refresh is off whenever the page runs full screen, +// which the Android app (a TWA in immersive mode) always is, and a WebView +// (the Beta and F-Droid builds) and an installed PWA have none at all: "pull +// down to refresh isn't working for me in the apk" (Adam, 2026-11-06). In +// those, this one takes over, and the browser's is switched off so the two +// never both fire; a plain browser tab keeps the browser's. +// +// A pull counts only when the page is at its top and the finger isn't in +// something scrolled down (a chat, a picker) or something over the page (a +// dialog, the media viewer): those keep their own drags. It's vertical only — +// sideways swipes belong to the tab navigation. + +const PULL = 80; // px of (damped) pull that reloads on release +const MAX = 120; + +export function appLike() { + try { + if (typeof window !== 'undefined' && window.CoinosHost) return true; + return matchMedia('(display-mode: standalone)').matches || matchMedia('(display-mode: fullscreen)').matches; + } catch { return false; } +} + +// something under the finger that is scrolled away from its top, or a layer +// over the page, owns this drag +function blocked(el) { + for (let n = el; n && n !== document.body && n !== document.documentElement; n = n.parentElement) { + if (n.scrollTop > 0) return true; + if (n.matches && n.matches('.lightbox, .confirm-pop-backdrop, .msg-sheet, [role=dialog], dialog, input, textarea, select, [contenteditable=true]')) return true; + } + return false; +} + +export function initPullRefresh({ reload = () => location.reload() } = {}) { + if (typeof document === 'undefined' || !appLike()) return false; + document.documentElement.style.overscrollBehaviorY = 'contain'; // the browser's off: this one instead + + const ind = document.createElement('div'); + ind.className = 'ptr'; + ind.setAttribute('aria-hidden', 'true'); + ind.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>'; + document.body.appendChild(ind); + + let y0 = null, x0 = 0, dy = 0, active = false, done = false; + const show = (d) => { + const p = Math.min(1, d / PULL); + ind.style.transform = `translate(-50%, ${Math.min(d, MAX) - 48}px) rotate(${p * 270}deg)`; + ind.style.opacity = String(Math.min(1, p * 1.2)); + ind.classList.toggle('ready', d >= PULL); + }; + const reset = () => { + ind.classList.remove('ready'); + ind.style.transition = 'transform .2s ease, opacity .2s ease'; + ind.style.transform = 'translate(-50%, -48px)'; + ind.style.opacity = '0'; + setTimeout(() => { ind.style.transition = ''; }, 220); + }; + + addEventListener('touchstart', (e) => { + if (done || e.touches.length !== 1) { y0 = null; return; } + const top = (document.scrollingElement || document.documentElement).scrollTop <= 0; + if (!top || blocked(e.target)) { y0 = null; return; } + y0 = e.touches[0].clientY; x0 = e.touches[0].clientX; dy = 0; active = false; + }, { passive: true }); + + addEventListener('touchmove', (e) => { + if (y0 == null || done) return; + const t = e.touches[0]; + const ry = t.clientY - y0, rx = t.clientX - x0; + if (!active) { + // decide once: a clear downward pull, not a sideways swipe or a scroll up + if (Math.abs(ry) < 8 && Math.abs(rx) < 8) return; + if (ry <= 0 || Math.abs(rx) > ry) { y0 = null; return; } + active = true; + } + dy = ry * 0.5; // damped, like the browser's + show(dy); + }, { passive: true }); + + const end = () => { + if (y0 == null) return; + y0 = null; + if (active && dy >= PULL) { + done = true; + ind.classList.add('spin'); + ind.style.transform = 'translate(-50%, 24px)'; + reload(); + return; + } + if (active) reset(); + active = false; + }; + addEventListener('touchend', end, { passive: true }); + addEventListener('touchcancel', () => { if (y0 != null) { y0 = null; if (active) reset(); active = false; } }, { passive: true }); + return true; +} diff --git a/src/seed.js b/src/seed.js new file mode 100644 index 0000000..0592035 --- /dev/null +++ b/src/seed.js @@ -0,0 +1,53 @@ +// The BIP-39 seed of a recovery phrase, stretched once per phrase. +// +// Stretching is PBKDF2-HMAC-SHA512 over 2048 rounds. Done in JavaScript +// (@scure/bip39, nostr-tools' nip06) it cost every launch twice over — the +// wallet's account key and the nostr identity each stretched the same words — +// and was the largest single piece of opening a wallet on a phone. The browser +// has the same function built in (WebCrypto), many times faster but +// asynchronous: warmSeed() runs it before a wallet opens, and the synchronous +// readers (seedSync) find the result waiting. A reader that comes first +// still gets the right seed, the slow way, and leaves it for the rest. +import { mnemonicToSeedSync } from '@scure/bip39'; + +const seeds = new Map(); // `${phrase}\n${passphrase}` → Uint8Array(64) +const MAX = 8; // a few accounts on one device; oldest out first +const key = (mnemonic, passphrase) => `${mnemonic}\n${passphrase || ''}`; +function keep(k, seed) { + seeds.delete(k); + seeds.set(k, seed); + while (seeds.size > MAX) seeds.delete(seeds.keys().next().value); +} + +export function seedSync(mnemonic, passphrase = '') { + const k = key(mnemonic, passphrase); + const hit = seeds.get(k); + if (hit) return hit; + const seed = mnemonicToSeedSync(mnemonic, passphrase || ''); + keep(k, seed); + return seed; +} + +// BIP-39 exactly: password = NFKD(phrase), salt = NFKD("mnemonic" + passphrase). +// Anything that goes wrong leaves the cache empty; seedSync does it the slow way. +export async function warmSeed(mnemonic, passphrase = '') { + if (!mnemonic) return; + const k = key(mnemonic, passphrase); + if (seeds.has(k)) return; + // what @scure/bip39 accepts; anything else is left for it to refuse + if (![12, 15, 18, 21, 24].includes(mnemonic.normalize('NFKD').split(' ').length)) return; + try { + const subtle = globalThis.crypto && globalThis.crypto.subtle; + if (!subtle) return; + const enc = new TextEncoder(); + const pw = await subtle.importKey('raw', enc.encode(mnemonic.normalize('NFKD')), 'PBKDF2', false, ['deriveBits']); + const bits = await subtle.deriveBits( + { name: 'PBKDF2', hash: 'SHA-512', salt: enc.encode(('mnemonic' + (passphrase || '')).normalize('NFKD')), iterations: 2048 }, + pw, 512, + ); + if (!seeds.has(k)) keep(k, new Uint8Array(bits)); + } catch {} +} + +// A locked or removed wallet's seed doesn't stay behind in memory. +export function forgetSeeds() { seeds.clear(); } diff --git a/src/style.css b/src/style.css index 384b6d6..803979f 100644 --- a/src/style.css +++ b/src/style.css @@ -251,7 +251,7 @@ button:disabled { opacity: .5; cursor: not-allowed; } /* ---- inputs ---- */ label.field { display: block; } .field .lab { font-size: 13px; font-weight: 600; color: var(--muted); margin-bottom: 6px; display: block; } -input[type=text], input[type=search], input[type=password], input[type=number], textarea, select { +input[type=text], input[type=search], input[type=password], input[type=number], input[type=datetime-local], textarea, select { width: 100%; font: inherit; color: var(--ink); background: var(--paper); border: 1px solid var(--line-strong); border-radius: 11px; padding: 11px 13px; } @@ -344,6 +344,10 @@ textarea.send-dest::placeholder { font-family: var(--sans); } eight-letter word ("resemble") pushed the third column out past the card's padding. The cells are a touch tighter so every BIP-39 word (eight letters at most) still fits whole in a third of a narrow card. */ +/* The one-word check after the phrase: four of its words, tap the right one. */ +.quiz-opts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.quiz-opt { font-family: var(--mono); font-size: 16px; padding: 14px 8px; } +.quiz-opt.wrong { border-color: var(--red); color: var(--red); background: var(--red-soft); } .words { display: grid; grid-auto-flow: row; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } .words .w { display: flex; align-items: baseline; gap: 5px; background: var(--surface2); min-width: 0; @@ -359,6 +363,10 @@ textarea.send-dest::placeholder { font-family: var(--sans); } /* ---- lists (tx / utxo) ---- */ .list { display: flex; flex-direction: column; } .item { display: flex; align-items: center; gap: 12px; padding: 13px 2px; border-top: 1px solid var(--line); } +/* the middle column may shrink: a long name chip beside the label then + ellipsizes on one line instead of breaking mid-name and pushing the + amount about */ +.item > .grow { min-width: 0; } .item:first-child { border-top: 0; } .ico { width: 34px; height: 34px; border-radius: 9px; flex: none; display: grid; place-items: center; font-size: 16px; } .ico.in { background: var(--green-soft); color: var(--green); } @@ -366,7 +374,7 @@ textarea.send-dest::placeholder { font-family: var(--sans); } .amount-pos { color: var(--green); font-family: var(--mono); font-weight: 600; } .amount-neg { color: var(--red); font-family: var(--mono); font-weight: 600; } .amount { font-family: var(--mono); font-weight: 600; } -.tag { font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 6px; } +.tag { font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 6px; white-space: nowrap; flex: none; } .tag.pending { background: var(--accent-soft); color: var(--accent-ink); } .tag.conf { background: var(--green-soft); color: var(--green); } @@ -571,6 +579,9 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } border: none; background: none; padding: 11px 8px; font-weight: 550; border-radius: 10px; } .msg-sheet-item:hover { background: var(--surface2); } +.msg-sheet-item.danger, .msg-sheet-item.danger .msg-sheet-ico { color: var(--red); } +.msg-sheet-item.danger.armed { background: color-mix(in srgb, var(--red) 12%, transparent); } +.chat-removed-head { padding: 10px 4px 2px; } .msg-sheet-ico { width: 20px; text-align: center; color: var(--muted); flex-shrink: 0; } /* the quoted message a reply carries, and the composer's replying-to bar */ .chat-quote { @@ -847,11 +858,29 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } } .lightbox-x:hover { background: rgba(255, 255, 255, .24); } @keyframes lightbox-in { from { opacity: 0; } to { opacity: 1; } } -.note-video { display: block; width: 100%; height: auto; max-height: 100vh; object-fit: contain; border-radius: 10px; margin-top: 6px; background: #000; } +.note-video { display: block; width: 100%; height: auto; max-height: var(--vid-cap); object-fit: contain; border-radius: 10px; margin-top: 6px; background: #000; } /* a video plays by itself, muted, while it is on screen; the corner button turns the sound on (see autoplay in messages.js) */ .note-video-box { position: relative; display: block; max-width: 100%; margin-top: 6px; } .note-video-box .note-video { margin-top: 0; } +/* the height a feed clip may take: the whole screen on a phone, three + quarters of it on a computer (a portrait clip on a 1920 screen ran off it) */ +:root { --vid-cap: 100vh; } +@media (min-width: 900px) { :root { --vid-cap: 75vh; } } +/* with a mouse: the big play/pause in the middle, while the pointer is over the clip */ +.vid-pp { + position: absolute; left: 50%; top: 50%; z-index: 2; width: 64px; height: 64px; margin: -32px 0 0 -32px; padding: 0; + border: 0; border-radius: 50%; background: rgba(18, 18, 18, .6); color: #fff; cursor: pointer; + display: none; align-items: center; justify-content: center; +} +.vid-pp:hover { background: rgba(18, 18, 18, .85); } +.vid-pp-i { width: 0; height: 0; margin-left: 5px; border-style: solid; border-width: 13px 0 13px 22px; border-color: transparent transparent transparent #fff; } +.vid-pp.playing .vid-pp-i { width: 20px; height: 24px; margin: 0; border-width: 0 7px; border-color: #fff; } +.has-mouse .note-video-box:hover .vid-pp, .vid-pp:focus-visible { display: flex; } +/* the moments bar is the clip's only seek bar: the middle button is its play + control on a touch screen too, up whenever the clip is paused */ +.note-video-box.own-bar .vid-pp:not(.playing) { display: flex; } +.note-video-box:has(.vid-heavy) .vid-pp { display: none; } .vid-sound { position: absolute; top: 8px; left: 8px; z-index: 2; margin: 0; padding: 4px 10px; border: 0; border-radius: 999px; background: rgba(18, 18, 18, .7); color: #fff; font-size: 13px; line-height: 1.3; @@ -1013,17 +1042,11 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .feed-compose-icon { display: flex; flex-shrink: 0; } .feed-compose-icon .compose-pen { stroke: #14935a; } .feed-compose-icon .compose-pen-fill { fill: #14935a; opacity: .18; } -.feed-page .feed-new-pill { - bottom: calc(env(safe-area-inset-bottom, 0px) + 82px); -} -/* a phone is too narrow for it beside the Post button: it rides above */ -@media (max-width: 480px) { - .feed-page .feed-new-pill { bottom: calc(env(safe-area-inset-bottom, 0px) + 142px); } -} .feed-new-pill { - /* Rides at the bottom of the window, where a thumb already is; the tap - takes the reader up to the new posts. */ - position: fixed; bottom: calc(env(safe-area-inset-bottom, 0px) + 18px); + /* Rides at the bottom of the window, where a thumb already is, above the + nav; the tap takes the reader up to the new posts. (Painted beside the + page, not inside it: see page-floaters in app.js.) */ + position: fixed; bottom: calc(env(safe-area-inset-bottom, 0px) + 82px); left: 50%; transform: translateX(-50%); z-index: 30; width: auto; padding: 12px 24px; border-radius: 999px; background: var(--fill); color: var(--fill-fg); border: none; @@ -1032,6 +1055,10 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } box-shadow: 0 3px 12px rgba(0, 0, 0, .28); animation: pill-in .18s ease-out; } +/* a phone is too narrow for it beside the Post button: it rides above */ +@media (max-width: 480px) { + .feed-new-pill { bottom: calc(env(safe-area-inset-bottom, 0px) + 142px); } +} /* the generic button hover (a light surface) and press (a 1px drop, which REPLACED the centring transform and threw the pill half its width to the right, out from under the pointer, so the tap never landed) stay off it */ @@ -1097,6 +1124,9 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } /* over a playing clip: a tap takes it full screen; YouTube's control bar along the bottom is left uncovered, and full screen it all is */ .yt-shield { position: absolute; left: 0; right: 0; top: 0; bottom: 52px; z-index: 1; cursor: pointer; } +/* a mouse reaches the player's own buttons (pause in the middle, full screen + in the corner): while one is over the box its clicks go to the player */ +.yt-embed.by-mouse .yt-shield { display: none; } .yt-embed:fullscreen { border-radius: 0; aspect-ratio: auto; width: 100%; height: 100%; } .yt-embed:fullscreen .yt-shield { display: none; } .yt-embed:fullscreen iframe { object-fit: contain; } @@ -1197,7 +1227,8 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .profile-banner + .row .profile-avatar { border: 3px solid var(--paper); box-sizing: content-box; } -.zap-chip { display: inline-flex; gap: 5px; align-items: center; cursor: pointer; background: var(--surface2); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px 1px 2px; } +.zap-chip { display: inline-flex; gap: 5px; align-items: center; cursor: pointer; background: var(--surface2); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px 1px 2px; min-width: 0; max-width: 100%; } +.zap-chip > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .chat-avatar.mini { width: 18px; height: 18px; font-size: 8px; flex-shrink: 0; } /* A feed row is the one place a face is the point, not a label on a line — Amethyst gives it 55dp; at our 30px it read as a bullet beside the post. @@ -1238,6 +1269,8 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .chat-avatar.header-ava { width: 30px; height: 30px; font-size: 11px; } .composer-options { margin-inline-start: auto; flex-shrink: 0; } .compose-preview-toggle[aria-pressed="true"] { background: var(--accent-soft); color: var(--ink); } +.post-actions button[aria-pressed="true"]:not(.compose-preview-toggle) { background: var(--accent-soft); color: var(--ink); } +.sched-pick input { min-width: 0; } /* the post composer: Preview and Cancel stand as tall as Post and the attach square beside them, in the same type */ .post-actions .composer-options > button { padding: 11px 16px; font-size: inherit; border-radius: 11px; min-height: 44px; } @@ -1331,7 +1364,7 @@ button, .btn { transition: filter .12s, background .15s, border-color .15s, box- .chat-avatar.clickable:hover { transform: scale(1.07); } .header-avatar .chat-avatar { transition: transform .15s ease; } .header-avatar:hover .chat-avatar { transform: scale(1.08); } -input[type=text], input[type=search], input[type=password], input[type=number], textarea, select { transition: border-color .15s, box-shadow .15s; } +input[type=text], input[type=search], input[type=password], input[type=number], input[type=datetime-local], textarea, select { transition: border-color .15s, box-shadow .15s; } .settings-nav::after { transition: transform .18s ease, color .18s; } .settings-nav:hover::after { transform: translateX(3px); color: var(--ink); } @@ -1821,6 +1854,7 @@ body::after { color: #fff; font-size: 14px; text-align: center; text-shadow: 0 1px 3px rgba(0, 0, 0, .6); } .vid-nopic a { color: #fff; text-decoration: underline; pointer-events: auto; } +.note-video-box.gone .note-video { max-height: 160px !important; aspect-ratio: 16 / 9; } .media-tile-cost { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, .8); font-size: 12px; font-weight: 600; background: #16181d; @@ -1868,7 +1902,7 @@ body::after { min-height: 48px; padding: 8px 14px; font-size: 16px; } .feed-post-fab { right: calc(50% - var(--col-edge) + 16px); bottom: 24px; } - .feed-new-pill, .feed-page .feed-new-pill { left: calc(50% + var(--col-mid)); bottom: 24px; } + .feed-new-pill { left: calc(50% + var(--col-mid)); bottom: 24px; } .toast { left: calc(50% + var(--col-mid)); } } @media (min-width: 1220px) { @@ -1923,6 +1957,8 @@ body::after { /* a reply shown with the post it answers above it (Conversations, a profile's Replies) */ .note-context { margin-bottom: 6px; } .note-context-label { margin-bottom: 2px; } +.note-scope .note-context-label { display: inline-block; text-decoration: none; } +.note-scope .note-context-label:hover { text-decoration: underline; } .note-context .quote-card { margin-top: 0; } .note-context .quote-card .note-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; overflow: hidden; } /* attach + GIF inside the composer's field, bottom right; the field keeps @@ -1995,7 +2031,8 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut /* A post's audio/video timeline: the waveform (audio), and what was said, reacted or zapped at a moment, sitting over the bar at that moment. */ -.media-timeline { padding: 24px 0 8px; white-space: normal; color: var(--text); } +.media-timeline { padding: 8px 0; white-space: normal; color: var(--text); } +.note-video-box .media-timeline:not(.has-marks) { display: none; } .media-time { font-variant-numeric: tabular-nums; } .media-seek { position: relative; height: 42px; --played: 0%; } .media-wave { position: absolute; inset: 3px 0; display: flex; align-items: center; gap: 2px; @@ -2006,12 +2043,20 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut .has-wave .media-wave::after { content: ''; position: absolute; inset: 0 0 0 var(--played); background: var(--bg, #fff); opacity: .55; pointer-events: none; } .media-seek input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: .01; cursor: pointer; } .media-seek:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } -.media-marks { position: absolute; left: 0; right: 0; top: -22px; height: 22px; pointer-events: none; } +/* the marks hang just under the line, at their moment (a plain bar: inside + its touch strip); under a waveform, below it */ +.media-marks { position: absolute; left: 0; right: 0; top: 24px; height: 18px; pointer-events: none; } +.has-wave .media-marks { top: 100%; height: 22px; } +.media-seek.has-wave:has(.media-mark) { margin-bottom: 22px; } +.media-mark { bottom: auto; top: 0; } .media-mark, .mp-mark { position: absolute; bottom: 0; transform: translateX(-50%); padding: 0 2px; margin: 0; border: 0; background: none; font-size: 16px; line-height: 1; cursor: pointer; pointer-events: auto; } .media-mark .cemoji, .mp-mark .cemoji { width: 20px; height: 20px; vertical-align: bottom; } +.media-mark-face { display: block; width: 20px !important; height: 20px !important; min-width: 0; font-size: 8px; border-radius: 50%; } +.mp-mark .media-mark-face { width: 26px !important; height: 26px !important; box-shadow: 0 0 0 1.5px #fff; } +.mp-pop .media-mark-face { width: 36px !important; height: 36px !important; box-shadow: 0 0 0 2px #fff; } /* the pager's: above its seek bar, there when the bar is */ .mp-marks { position: absolute; left: 0; right: 0; bottom: 10px; height: 26px; pointer-events: none; } .mp-mark { font-size: 20px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); } @@ -2033,8 +2078,33 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut } @media (prefers-reduced-motion: reduce) { .mp-pop { animation: none; opacity: 0; } } +/* a timed comment's time: a chip that plays the clip from there */ +.media-time-link { display: inline-flex; align-items: center; margin-right: 6px; padding: 1px 8px; border-radius: 999px; + background: var(--accent-soft); color: var(--accent); font-size: .85em; font-weight: 650; font-variant-numeric: tabular-nums; + text-decoration: none; vertical-align: 1px; white-space: nowrap; } +.media-time-link:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); } .media-moment-detail { overflow-wrap: anywhere; max-height: 72px; overflow-y: auto; margin-top: 5px; } .media-moment-detail:empty { display: none; } .live-watching.has-who { cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; } .live-who { display: flex; flex-wrap: wrap; gap: 6px; } .live-who-p { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px 3px 3px; border-radius: 999px; background: var(--bg2, rgba(127,127,127,.12)); border: 0; color: inherit; cursor: pointer; } + +/* a gift's note: the sender's words above the amount, and the field for them */ +.gift-note { + margin: 0; padding: 8px 12px; max-width: 100%; + border-left: 3px solid var(--accent); border-radius: 4px; + background: color-mix(in srgb, var(--accent) 8%, transparent); + font-style: italic; text-align: left; white-space: pre-wrap; overflow-wrap: anywhere; +} +.gift-note-input { width: 100%; min-height: 0; font-family: var(--sans); } + +/* pull to refresh, where the browser has none (src/pull-refresh.js) */ +.ptr { + position: fixed; top: env(safe-area-inset-top, 0px); left: 50%; z-index: 2147483000; + width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; + background: var(--paper); color: var(--muted); box-shadow: 0 2px 10px rgba(0, 0, 0, .18); + transform: translate(-50%, -48px); opacity: 0; pointer-events: none; +} +.ptr.ready { color: var(--ink); } +.ptr.spin svg { animation: ptr-spin .8s linear infinite; } +@keyframes ptr-spin { to { transform: rotate(360deg); } } diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 211c4f9..b0ef49f 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -4,12 +4,19 @@ // nwc-respond.js), and only falls back to the wake-the-user notification // when it can't. -import { respondFromBg, tendRenewals } from './nwc-respond.js'; -import { withRequestLock } from './nwc-lock.js'; +import { respondFromBg, tendRenewals, tendLnPays } from './nwc-respond.js'; +import { withRequestLock, withWorkLock } from './nwc-lock.js'; +import { appendBgLog, takeLease } from './nwc-bg.js'; import { bgAutoWithdraw } from './bg-autowithdraw.js'; import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js'; +import { notifyText } from './notify-text.js'; const NOTIFIER = 'https://nwcpush.coinos.io'; +// The worker's diary (nwc-bg.js): every line also lands in IndexedDB as it is +// written, so a worker the phone stops mid-push leaves its last step behind. +const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19); +const diary = (m) => { console.log('[sw-nwc]', m); appendBgLog(`${stamp()} ${m}`); }; +diary(`worker started${typeof navigator !== 'undefined' && navigator.locks ? '' : ' (no Web Locks)'}`); const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app the tap opens // where a call's offer wrap is fetched from: the notifier, or (the app served // on this machine, as the tests do) this origin @@ -52,11 +59,12 @@ self.addEventListener('push', (e) => { // (PullCap's night of 2026-10-22: paid four times, no "Payment sent" seen.) // Try the full options; refused, try again with the plainest ones. const show = async (title, opts) => { - try { await self.registration.showNotification(title, opts); showed = true; return; } - catch (e) { console.warn('[sw] notification refused:', e && e.message); } - try { await self.registration.showNotification(title, { body: opts && opts.body, tag: opts && opts.tag, data: opts && opts.data }); showed = true; } - catch (e) { console.warn('[sw] plain notification refused too:', e && e.message); } + try { await self.registration.showNotification(title, opts); showed = true; diary(`notification shown: ${title}`); return; } + catch (e) { diary(`notification refused (${title}): ${e && e.message}`); } + try { await self.registration.showNotification(title, { body: opts && opts.body, tag: opts && opts.tag, data: opts && opts.data }); showed = true; diary(`plain notification shown: ${title}`); } + catch (e) { diary(`plain notification refused too (${title}): ${e && e.message}`); } }; + diary(`push ${data.type || '?'}${data.reason ? '/' + data.reason : ''}${data.event && data.event.id ? ' ' + data.event.id.slice(0, 8) : ''}`); // the stand-in, as soon as a path knows it has nothing to say (a long tail // of work after it mustn't keep the push waiting for its notification) const quiet = async () => { @@ -101,8 +109,11 @@ self.addEventListener('push', (e) => { await cache.put('/__pending-call', new Response(JSON.stringify({ offer: ring.offer, at: Date.now() }), { headers: { 'content-type': 'application/json' } })); } catch (_) {} for (const c of clients) { try { c.postMessage({ type: 'call-offer', offer: ring.offer }); } catch (_) {} } - await show((ring.name || 'Someone') + ' is calling', { - body: ring.type === 'video' ? 'Video call. Tap to answer.' : 'Voice call. Tap to answer.', + // the words are measured against Chrome's spam model (notify-text.js): + // "<name> is calling" is hidden behind a warning, "Calling" / "<name>" shows + const [callTitle, callBody] = notifyText('call', { name: ring.name }); + await show(callTitle, { + body: callBody, icon: 'icon-192.png', badge: 'badge-96.png', tag: 'call', renotify: true, requireInteraction: true, vibrate: [400, 200, 400, 200, 400], data: { url: './?open=call', view: 'call' }, @@ -134,24 +145,15 @@ self.addEventListener('push', (e) => { if (verdict && verdict.name) dmName = verdict.name; } catch (err) { console.warn('[sw-dm] could not classify:', err && err.message); } } - // A reaction names who, when this device knows them, and is dropped - // when they are muted here — the notifier can't know either. - let who = null; + // A reaction from someone muted on this device is dropped — the + // notifier can't know. (It no longer names who: a name in the line + // is what tips Chrome's spam model over, see notify-text.js.) if (data.reason === 'reaction' && data.from) { const boxes = await allInboxes().catch(() => []); if (boxes.some((b) => (b.muted || []).includes(data.from))) return; - who = boxes.map((b) => b.names && b.names[data.from]).find(Boolean) || null; } const emoji = !data.emoji || data.emoji === '+' ? '\u2764\ufe0f' : /^:[\w-]+:$/.test(data.emoji) ? data.emoji : data.emoji.slice(0, 8); - const T = { - payment: ['Payment received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats. Open coinos to see it.` : 'Open coinos to see it.'], - dm: dmName ? [dmName, 'sent you a message.'] : ['New message', 'You have a new private message.'], - chat: ['New chat activity', 'There are new messages in your communities.'], - mention: [data.reply ? 'New reply to your post' : 'You were mentioned', data.text || 'Open coinos to see it.'], - zap: ['Zap received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats. Open coinos to see it.` : 'Someone zapped you. Open coinos to see it.'], - reaction: [who ? `${who} reacted ${emoji}` : `New reaction ${emoji}`, 'to your post. Open coinos to see it.'], - }; - const [title, body] = T[data.reason] || T.chat; + const [title, body] = notifyText(data.reason, { amountSat: data.amountSat, name: dmName, reply: data.reply, emoji }); // a reply lands you in the Notifications list, where it is const target = data.reason === 'mention' || data.reason === 'reaction' ? { url: './?open=notifs', view: 'notifs' } : { url: './' }; await show(title, { @@ -172,62 +174,101 @@ self.addEventListener('push', (e) => { // four payments, no line for the fourth, then no more wakes). On an // Apple endpoint the stand-in goes up FIRST; the outcome replaces it. const apple = await applePush(); + // What a push may take. iOS stops the worker about ten seconds in (PullCap's + // diary of 2026-10-24: every wake's last line 8–12 s after its push, in the + // middle of a payment), whatever the waitUntil — so there everything is + // planned inside nine; elsewhere the usual thirty. + const budgetEnd = startedAt + (apple ? 9_000 : 27_000); const dropStandIn = async () => { const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []); for (const n of open) n.close(); }; - if (apple && await standIn(false).catch(() => false)) showed = true; + if (apple && await standIn(false).catch((e) => { diary('stand-in refused: ' + (e && e.message)); return false; })) { showed = true; diary('stand-in shown'); } // An open window handles requests itself with full wallet state — nudge // it and stay out of the way. On iOS only one on screen counts: a // backgrounded app is suspended, so a nudge to it went unanswered. + // Android does the same to an app behind another one, minutes in: the + // page is frozen, its relay sockets dead, and the nudge sat unread until + // the app was opened — the zap spun for as long (wake to payment: 15 s + // at the median on Android phones against 6 for a closed app, 2026-11-10). + // So on any phone only a window on screen is handed the request; one + // behind another app is nudged too, in case it is still awake, and the + // request's lock decides between it and this worker. + const phone = apple || /Mobi|Android/i.test((self.navigator && self.navigator.userAgent) || ''); const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - const clients = all.filter((c) => !apple || c.visibilityState === 'visible'); + const clients = all.filter((c) => !phone || c.visibilityState === 'visible'); if (clients.length) { for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); if (apple) await dropStandIn(); // the window on screen shows it live + diary('window on screen — handed over'); return; } + if (!apple) for (const c of all) { try { c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); } catch (_) {} } + const notice = (n) => show(n.title, { + body: n.body, icon: 'icon-192.png', badge: 'badge-96.png', + tag: n.tag || 'nwc-incoming', renotify: !apple, data: { url: './' }, + }); // A wake is the only moment a closed wallet gets to look after its coins: - // claim renewals whose round has run, cover coins without one. After the - // answer, inside what is left of the push budget. - const tend = () => tendRenewals({ deadline: startedAt + 27_000, log: (m) => console.log('[sw-renew]', m) }) - .catch((err) => console.warn('[sw-renew] failed:', err && err.message)); + // finish sends an earlier wake left in flight, claim renewals whose round + // has run, cover coins without one. After the answer, inside what is + // left of the push budget. + const tend = async () => { + const late = await tendLnPays({ deadline: budgetEnd, log: diary }).catch((err) => { diary('earlier sends failed: ' + (err && err.message)); return []; }); + for (const n of late) await notice(n); + if (late.length && apple) await dropStandIn(); + await tendRenewals({ deadline: budgetEnd, log: (m) => diary('renew: ' + m) }) + .catch((err) => diary('renewals failed: ' + (err && err.message))); + }; // an NWC wake-up may also have moved money in; check the rule after setTimeout(() => { bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }).catch(() => {}); }, 0); - let handled = false; - try { - handled = await withRequestLock(data.event && data.event.id, - () => respondFromBg(data, { notifier: NOTIFIER, log: (m) => console.log('[sw-nwc]', m) }), - () => { console.log('[sw-nwc] another context holds this request'); return true; }); - } catch (err) { - console.warn('[sw-nwc] auto-answer failed:', err && err.message); - } - // handled-with-a-heads-up: e.g. an invoice was minted while closed and - // the user should open coinos to complete the receive - if (handled && handled.notify) { - await show(handled.notify.title, { - body: handled.notify.body, + // one push at a time over the shared mirror (a client's retry lands + // seconds after its lookup, and each push builds its own manager) + await withWorkLock(async () => { + const waited = Date.now() - startedAt; + if (waited > 500) diary(`waited ${waited} ms for an earlier push to finish`); + if (Date.now() > budgetEnd - 2_000) { diary('no time left after waiting — leaving it to the next try'); return; } + let handled = false; + try { + handled = await withRequestLock(data.event && data.event.id, + () => respondFromBg(data, { notifier: NOTIFIER, log: diary, deadline: budgetEnd }), + () => { diary('another context holds this request'); return true; }); + } catch (err) { + diary('auto-answer failed: ' + (err && err.message)); + } + // sends from an earlier wake that settled on the way to this answer + const late = (handled && handled.notices) || []; + for (const n of late) await notice(n); + // handled-with-a-heads-up: e.g. an invoice was minted while closed and + // the user should open coinos to complete the receive + if (handled && handled.notify) { + await notice(handled.notify); + if (apple) await dropStandIn(); + await tend(); + return; + } + if (handled) { + if (late.length && apple) await dropStandIn(); + diary('answered quietly'); + await quiet(); + await tend(); + return; + } + if (Date.now() > budgetEnd) { diary('out of time — leaving it to the next try'); return; } + // A kind-21001 offer request is money coming IN (someone asking us to + // mint an invoice) — "an app wants to pay" would read as the opposite. + const incoming = data.event && data.event.kind === 21001; + diary(incoming ? 'waking the user: incoming payment' : 'waking the user: payment request'); + const [reqTitle, reqBody] = notifyText(incoming ? 'incoming' : 'spend'); + await show(reqTitle, { + body: reqBody, icon: 'icon-192.png', badge: 'badge-96.png', - tag: handled.notify.tag || 'nwc-incoming', renotify: !apple, + tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, data: { url: './' }, }); if (apple) await dropStandIn(); await tend(); - return; - } - if (handled) { await quiet(); await tend(); return; } - // A kind-21001 offer request is money coming IN (someone asking us to - // mint an invoice) — "an app wants to pay" would read as the opposite. - const incoming = data.event && data.event.kind === 21001; - await show(incoming ? 'Incoming payment' : 'Payment request', { - body: incoming - ? 'Someone is trying to send you money. Open coinos so your wallet can receive it.' - : 'An app is asking your wallet to pay. Open Coinos to approve.', - icon: 'icon-192.png', badge: 'badge-96.png', - tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, - data: { url: './' }, - }); - if (apple) await dropStandIn(); + }, { lease: () => takeLease('work', { wait: Math.max(0, budgetEnd - Date.now() - 3_000) }) }); + diary(`push done in ${Date.now() - startedAt} ms`); }); }); diff --git a/src/wallet.js b/src/wallet.js index aca11e1..72e0c33 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -9,7 +9,8 @@ // transactions, only the (txid, vout, value, address) of each UTXO. import { HDKey } from '@scure/bip32'; -import { generateMnemonic, mnemonicToSeedSync, validateMnemonic, entropyToMnemonic } from '@scure/bip39'; +import { generateMnemonic, validateMnemonic, entropyToMnemonic } from '@scure/bip39'; +import { seedSync } from './seed.js'; import { wordlist } from '@scure/bip39/wordlists/english'; import { hex, base64urlnopad, base32nopad, base58check } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; @@ -87,7 +88,7 @@ export function accountXpubFor({ mnemonic, passphrase = '', xprv } = {}, netName const node = HDKey.fromExtendedKey(xprv); acct = node.depth === 0 ? node.derive(`m/84'/${coin}'/0'`) : node; } else { - acct = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase)).derive(`m/84'/${coin}'/0'`); + acct = HDKey.fromMasterSeed(seedSync(mnemonic, passphrase)).derive(`m/84'/${coin}'/0'`); } return acct.publicExtendedKey; } @@ -278,7 +279,7 @@ export class Wallet { const node = HDKey.fromExtendedKey(this.xprv); acct = node.depth === 0 ? node.derive(`m/84'/${coin}'/${this.accountIndex || 0}'`) : node; } else { - acct = HDKey.fromMasterSeed(mnemonicToSeedSync(this.mnemonic, this.passphrase)).derive(`m/84'/${coin}'/${this.accountIndex || 0}'`); + acct = HDKey.fromMasterSeed(seedSync(this.mnemonic, this.passphrase)).derive(`m/84'/${coin}'/${this.accountIndex || 0}'`); } this._account = acct; this._accountKey = key; @@ -2013,11 +2014,38 @@ export function parseExtendedKey(s) { // wrong password fails to decrypt rather than returning garbage). All fields // are hex so the blob is JSON-serializable for localStorage. const _SCRYPT = { N: 2 ** 15, r: 8, p: 1, dkLen: 32 }; +// A vault under the BLANK password (the default: wallets save themselves to +// the device unless a password is set) gets nothing from scrypt — anyone with +// the blob can compute scrypt('') — yet paid for it on every cold launch: +// ~130 ms on a Pixel 9, half a second on a slower phone, before anything +// opened. Its key is kept beside the blob instead, and new seals reuse that +// salt so the kept key stays good. A real password is stretched every time, +// as before, and never kept. +const BLANK_KEY = 'btc-wallet-vault-k0'; +let _blank = null; // { salt: hex, key: Uint8Array } +function blankKey() { + if (_blank) return _blank; + try { + const j = JSON.parse(globalThis.localStorage?.getItem(BLANK_KEY) || 'null'); + if (j && typeof j.salt === 'string' && typeof j.key === 'string') _blank = { salt: j.salt, key: hex.decode(j.key) }; + } catch {} + return _blank; +} function _vaultKey(password, salt) { - return scrypt(utf8ToBytes(password), salt, _SCRYPT); + if (password !== '') return scrypt(utf8ToBytes(password), salt, _SCRYPT); + const k = blankKey(); + const sh = hex.encode(salt); + if (k && k.salt === sh) return k.key; + const key = scrypt(utf8ToBytes(''), salt, _SCRYPT); + _blank = { salt: sh, key }; + try { globalThis.localStorage?.setItem(BLANK_KEY, JSON.stringify({ salt: sh, key: hex.encode(key) })); } catch {} + return key; } export function encryptVault(obj, password) { - const salt = randomBytes(16); + // sealed under a real password now: the blank one's key has nothing to open + if (password !== '') { _blank = null; try { globalThis.localStorage?.removeItem(BLANK_KEY); } catch {} } + const kept = password === '' ? blankKey() : null; + const salt = kept ? hex.decode(kept.salt) : randomBytes(16); const nonce = randomBytes(24); const ct = xchacha20poly1305(_vaultKey(password, salt), nonce).encrypt(utf8ToBytes(JSON.stringify(obj))); return { v: 1, salt: hex.encode(salt), nonce: hex.encode(nonce), ct: hex.encode(ct) }; diff --git a/static/.well-known/assetlinks.json b/static/.well-known/assetlinks.json index 9d84b58..545363d 100644 --- a/static/.well-known/assetlinks.json +++ b/static/.well-known/assetlinks.json @@ -1,8 +1,54 @@ -[{ - "relation": ["delegate_permission/common.handle_all_urls"], - "target": { - "namespace": "android_app", - "package_name": "io.coinos.app", - "sha256_cert_fingerprints": ["76:64:83:AB:83:72:C0:2F:84:06:D2:95:95:E7:38:9A:28:69:19:0B:42:9E:0F:FD:09:2E:BB:0B:FA:2B:C2:F4"] +[ + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "io.coinos.app", + "sha256_cert_fingerprints": [ + "76:64:83:AB:83:72:C0:2F:84:06:D2:95:95:E7:38:9A:28:69:19:0B:42:9E:0F:FD:09:2E:BB:0B:FA:2B:C2:F4" + ] + } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "io.coinos.beta", + "sha256_cert_fingerprints": [ + "76:64:83:AB:83:72:C0:2F:84:06:D2:95:95:E7:38:9A:28:69:19:0B:42:9E:0F:FD:09:2E:BB:0B:FA:2B:C2:F4" + ] + } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "io.coinos.webview", + "sha256_cert_fingerprints": [ + "76:64:83:AB:83:72:C0:2F:84:06:D2:95:95:E7:38:9A:28:69:19:0B:42:9E:0F:FD:09:2E:BB:0B:FA:2B:C2:F4" + ] + } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "io.coinos.fdroid", + "sha256_cert_fingerprints": [ + "76:64:83:AB:83:72:C0:2F:84:06:D2:95:95:E7:38:9A:28:69:19:0B:42:9E:0F:FD:09:2E:BB:0B:FA:2B:C2:F4" + ] + } } -}] +] diff --git a/static/coinos-beta.apk b/static/coinos-beta.apk Binary files differnew file mode 100644 index 0000000..4054941 --- /dev/null +++ b/static/coinos-beta.apk diff --git a/static/docs/index.html b/static/docs/index.html index 631faf9..eec46e8 100644 --- a/static/docs/index.html +++ b/static/docs/index.html @@ -62,6 +62,7 @@ footer { margin-top: 64px; color: var(--faint); font-size: 14px; } <li><a href="#rest">The coinos REST API (hosted accounts)</a></li> <li><a href="#migration">Moving a username to v3</a></li> <li><a href="#testing">Testing without real money</a></li> + <li><a href="#android">Android app</a></li> <li><a href="#source">Source code</a></li> </ol></nav> @@ -158,6 +159,15 @@ curl "$BASE/payments" -H "$AUTH" # history</code></pre> </table></div> <p>Switch your base URL to <code>https://staging.coinos.io/api</code> and register a fresh account there. Mutinynet addresses start with <code>tb1</code>, and its invoices with <code>lntbs</code>. Liquid is switched off on staging. The test server is reset now and then, so don't keep anything there you'd miss.</p> +<h2 id="android">Android app</h2> +<p>v3 is a web app and runs in any browser; the Android app wraps it with the system share sheet, NFC and <code>lightning:</code>/<code>bitcoin:</code>/<code>nostr:</code> links. Same code, same keys on your device.</p> +<ul> + <li>Direct download: <a href="https://v3.coinos.io/coinos.apk">v3.coinos.io/coinos.apk</a> (the current build, signed by coinos)</li> + <li>The same file from the source mirror: <a href="https://git.coinos.io/coinosv3.git/plain/static/coinos.apk">git.coinos.io/coinosv3.git/plain/static/coinos.apk</a> (1.14, SHA-256 <code>58a5c478…fdbfc1</code>; the full hash is in the commit that added it)</li> + <li><a href="https://zapstore.dev">Zapstore</a>: search for coinos; updates arrive through the store</li> + <li>F-Droid: a reproducible build is in review at <a href="https://gitlab.com/fdroid/fdroiddata/-/merge_requests/49241">fdroiddata</a></li> +</ul> + <h2 id="source">Source code</h2> <ul> <li>coinos v3 (this app): <a href="https://github.com/coinos/coinosv3">github.com/coinos/coinosv3</a></li> diff --git a/tools/account-access-test.js b/tools/account-access-test.js index c9af32e..91fbe05 100644 --- a/tools/account-access-test.js +++ b/tools/account-access-test.js @@ -36,19 +36,24 @@ try { check('avatar opens the wallet settings with sign-in and logout', await page.evaluate(() => ['Security', 'Sign into another account', 'Log out'].every((s) => document.body.innerText.includes(s)))); const saved = await page.evaluate(() => localStorage.getItem('btc-wallet-watch')); await click('Sign into another account'); - check('sign-in opens on Create new, not the seed import box', await page.evaluate(() => - document.querySelector('.tabs .active')?.textContent.includes('Create new') && !document.querySelector('.card textarea'))); + check('sign-in opens on the front door\'s two choices', await page.evaluate(() => + !!document.getElementById('add-create') && !!document.getElementById('add-signin') && !document.querySelector('.tabs') && !document.querySelector('.card textarea'))); + await click('Sign in to an existing account'); + check('...then the sign-in list, seed phrase first', await page.evaluate(() => { + const b = [...document.querySelectorAll('.onb button')].map((x) => x.textContent.trim()); + return b[0] === 'Seed phrase' && b.includes('Nostr'); + })); // Expand the Nostr login card without supplying a private key. - await click('Sign in with Nostr'); + await click('Nostr'); await page.waitForSelector('input[placeholder="nsec, private key, or bunker://…"]'); check('Nostr key sign-in is reachable without deleting the old wallet', await page.evaluate((saved) => localStorage.getItem('btc-wallet-watch') === saved, saved)); - await click('← Back'); + for (let i = 0; i < 3; i++) { await click('← Back'); await new Promise((r) => setTimeout(r, 150)); } await page.waitForSelector('.header-avatar'); await page.click('.header-avatar'); await click('Log out'); check('logout asks before ending the session', await page.evaluate(() => document.body.innerText.includes('Your accounts stay saved on this device.'))); await click('Log out'); - check('logout reaches the sign-in screen and retains the saved wallet', await page.evaluate((saved) => !document.querySelector('.header-avatar') && document.body.innerText.includes('Sign in with Nostr') && localStorage.getItem('btc-wallet-watch') === saved, saved)); + check('logout reaches the sign-in screen and retains the saved wallet', await page.evaluate((saved) => !document.querySelector('.header-avatar') && document.body.innerText.includes('Sign in to an existing account') && localStorage.getItem('btc-wallet-watch') === saved, saved)); // Remember a public login identity on the watch-only account so every // header destination is available, without supplying a private key. await page.evaluate(({ key }) => { diff --git a/tools/account-delete-test.js b/tools/account-delete-test.js new file mode 100644 index 0000000..d06bcb8 --- /dev/null +++ b/tools/account-delete-test.js @@ -0,0 +1,115 @@ +// Settings → Wallet → Delete account: asks the registrar what stands in the +// way, says what goes and what stays (and how many sats are still here), +// and on a ticked box + tap has the registrar forget the account (a NIP-98 +// request signed by the wallet's key) and removes the wallet from the device. +// The registrar here is a stand-in inside the test; relays are the stand-ins. +// +// Run: bun tools/account-delete-test.js +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { verifyEvent } from 'nostr-tools/pure'; +import { createHash } from 'node:crypto'; +import { buildHtml } from '../build.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5271, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 420, height: 900 }); +page.on('pageerror', (e) => { console.log(' page error:', e.message); ok = false; }); + +// the registrar: records what it's asked, answers as the real one would +const calls = []; +let owed = { pendingSat: 0, withdrawSat: 0 }; +const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type,authorization', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS' }; +await page.setRequestInterception(true); +page.on('request', (req) => { + const u = new URL(req.url()); + if (u.hostname !== 'names.coinos.io') return req.continue(); + if (req.method() === 'OPTIONS') return req.respond({ status: 200, headers: cors, body: '{}' }); + const reply = (status, j) => req.respond({ status, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(j) }); + if (u.pathname !== '/account/delete') return reply(404, { error: 'not found' }); + const body = req.postData() || ''; + let ev = null; + try { ev = JSON.parse(Buffer.from((req.headers().authorization || '').replace(/^Nostr /, ''), 'base64').toString()); } catch {} + const tag = (k) => ev?.tags.find((t) => t[0] === k)?.[1]; + const signed = !!ev && ev.kind === 27235 && verifyEvent(ev) && tag('method') === 'POST' + && new URL(tag('u')).pathname === '/account/delete' && tag('payload') === createHash('sha256').update(body).digest('hex'); + const j = JSON.parse(body || '{}'); + calls.push({ check: !!j.check, signed, pubkey: ev?.pubkey }); + const blocked = owed.pendingSat + owed.withdrawSat > 0; + if (j.check) return reply(200, { names: ['[email protected]'], ...owed, paying: 0, blocked }); + if (blocked) return reply(409, { error: 'funds', ...owed, paying: 0 }); + return reply(200, { ok: true, names: ['[email protected]'] }); +}); + +const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); +const until = async (fn, ms = 15000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(250); } }; +const text = () => page.evaluate(() => document.body.innerText); +const goBtn = () => page.evaluate(() => { const b = [...document.querySelectorAll('button.btn-danger')].find((x) => x.textContent.trim() === 'Delete account' && x.closest('.card')?.querySelector('input[type=checkbox]')); return b ? { disabled: b.disabled } : null; }); +const openCard = async () => { + await page.evaluate(() => document.querySelector('[data-key="delete-account"] button')?.click()); + await until(async () => (await text()).includes('[email protected] is released')); +}; + +try { + await page.goto('http://localhost:5271/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('button', 'Create a new wallet'); + await sleep(300); + await click('button', 'Have an existing seed'); + await sleep(300); + await page.waitForSelector('textarea'); + await page.type('textarea', generateMnemonic(wordlist)); + await click('button', 'Open wallet'); + await until(async () => /receive/i.test(await text())); + await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings')?.click()); + await until(async () => (await text()).includes('Advanced')); + await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Wallet'))?.click()); + await sleep(500); + if (process.env.DEBUG) console.log((await text()).slice(0, 600)); + check('Settings → Wallet has a Delete account card', !!(await until(() => page.evaluate(() => !!document.querySelector('[data-key="delete-account"]'))))); + + console.log('\n[sats still owed]'); + owed = { pendingSat: 21, withdrawSat: 500 }; + await openCard(); + let tx = await text(); + check('says the name is released, records deleted, and what stays', /is released/.test(tx) && /Our servers delete/.test(tx) && /Not deleted: your coins/.test(tx)); + check('names the sats still owed and won\'t go on', /521 sats are still on their way/.test(tx) && (await goBtn())?.disabled === true, tx.match(/⚠[^\n]*/)?.[0]?.slice(0, 80)); + check('the question was signed by the wallet\'s key', calls.length === 1 && calls[0].check && calls[0].signed); + await click('button', 'Back'); + await sleep(300); + check('Back returns to the settings page, account intact', !!(await page.evaluate(() => !!document.querySelector('[data-key="delete-account"]'))) && calls.every((c) => c.check)); + + console.log('\n[nothing in the way]'); + owed = { pendingSat: 0, withdrawSat: 0 }; + await openCard(); + check('the button waits for the box to be ticked', (await goBtn())?.disabled === true); + await page.evaluate(() => document.querySelector('.card input[type=checkbox]')?.click()); + await sleep(300); + check('ticked: the button is live', (await goBtn())?.disabled === false); + const before = calls.length; + await page.evaluate(() => [...document.querySelectorAll('button.btn-danger')].find((x) => x.textContent.trim() === 'Delete account' && !x.disabled)?.click()); + const gone = await until(async () => /create a new wallet/i.test(await text()), 20000); + if (process.env.DEBUG) console.log((await text()).slice(0, 900)); + check('the registrar was told to delete, signed', calls.slice(before).some((c) => !c.check && c.signed), JSON.stringify(calls.slice(before).map((c) => ({ check: c.check, signed: c.signed })))); + check('the wallet is gone from the device: back at the front door', !!gone); + const left = await page.evaluate(() => ({ session: sessionStorage.getItem('btc-wallet-accounts'), vault: localStorage.getItem('btc-wallet-vault') })); + check('no account left in the session', !left.session || !JSON.parse(left.session).accounts?.length, String(left.session).slice(0, 60)); + await page.reload({ waitUntil: 'domcontentloaded' }); + check('and a reload doesn\'t bring it back', !!(await until(async () => /create a new wallet/i.test(await text()), 10000))); +} catch (e) { + console.log(' error:', e.message); ok = false; +} finally { + await browser.close(); + server.stop(true); +} +console.log(ok ? '\nPASS' : '\nFAIL'); +process.exit(ok ? 0 : 1); diff --git a/tools/account-switch-leak-test.js b/tools/account-switch-leak-test.js index ea3b4ee..aa06579 100644 --- a/tools/account-switch-leak-test.js +++ b/tools/account-switch-leak-test.js @@ -3,7 +3,7 @@ // under its key, so a throwaway account showed adam's zaps and reactions for // good. Also: a DM cache already polluted that way is swept on load. // Run: bun tools/account-switch-leak-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -61,9 +61,9 @@ const nav = (label) => page.evaluate((l) => document.querySelector(`.app-nav-but async function newWallet() { await click('create a new wallet'); await sleep(600); if (!(await page.$('.words .w .t'))) { await click('generate'); await sleep(600); } - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); return mn; } diff --git a/tools/amber-dm-upgrade-test.js b/tools/amber-dm-upgrade-test.js index 9a5070a..3a1917a 100644 --- a/tools/amber-dm-upgrade-test.js +++ b/tools/amber-dm-upgrade-test.js @@ -7,7 +7,7 @@ // relays hold is opened again — including ones the old code wrote off. // The remote signer is played by this script over a shimmed relay. // Run: bun tools/amber-dm-upgrade-test.js (and ALL_LOGGED=1 bun tools/amber-dm-upgrade-test.js) -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import * as nip44 from 'nostr-tools/nip44'; @@ -102,9 +102,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); // an Amber login, and a wrap log in which the old code had already @@ -120,14 +120,14 @@ try { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Messages"]')?.click()); console.log('\n[signed in with Amber by intent]'); - check('Messages says messages sent to you can\'t be opened, and offers Connect Amber', await waitText('can’t be opened here', 10000) && await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Connect Amber'))); + check('Messages says messages sent to you can\'t be opened, and offers Connect Amber', await waitText('can’t be opened here', 10000) && await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Connect signer'))); await sleep(4000); check('no decrypt intent went to Amber for an arriving wrap', !(await page.evaluate(() => window.__amberUrls.some((u) => /nip44_decrypt/.test(u))))); check('neither message shows', !(await text()).includes('a message from before the upgrade') && !(await text()).includes('wrote off')); if (!ALL) check('the arriving wrap is not logged as opened', await page.evaluate(([k, id]) => !(JSON.parse(localStorage.getItem(k + ':messages') || '{}').wraps || []).includes(id.slice(0, 16)), [base, NEW.id])); console.log('\n[Connect Amber: the same account over NIP-46]'); - await click('connect amber'); await sleep(300); + await click('connect signer'); await sleep(300); check('a screen explains it and offers the signer-app link', await waitText('read your messages', 5000)); let uri = null; for (let i = 0; i < 40 && !uri; i++) { uri = await page.evaluate(() => document.querySelector('a[href^="nostrconnect://"]')?.getAttribute('href')); if (!uri) await sleep(250); } diff --git a/tools/amber-login-test.js b/tools/amber-login-test.js index 9da49a8..46a16f6 100644 --- a/tools/amber-login-test.js +++ b/tools/amber-login-test.js @@ -39,10 +39,11 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(500); console.log('\n[the Amber door]'); + await click('sign in to an existing account'); await sleep(300); check('the front door offers Nostr sign-in', await click('sign in with nostr') || await click('nostr')); await sleep(500); - check('on Android, the login offers Amber', await waitText('sign in with amber', 5000)); - await click('sign in with amber'); await sleep(400); + check('on Android, the login offers Amber', await waitText('sign in with a signer app', 5000)); + await click('sign in with a signer app'); await sleep(400); check('tapping it opens a get_public_key intent', (await lastType()) === 'get_public_key', JSON.stringify(await urls())); check('...and shows the waiting card with a paste box', await waitText('waiting for your signer', 2000) && !!(await page.$('.amber-paste'))); await deliver(npubEncode(PK)); diff --git a/tools/ark-ln-savings-small-test.js b/tools/ark-ln-savings-small-test.js new file mode 100644 index 0000000..a674b42 --- /dev/null +++ b/tools/ark-ln-savings-small-test.js @@ -0,0 +1,112 @@ +// A Lightning invoice opened while Savings is selected, with Spending empty +// and Savings under the server's board minimum (546 sats against coinos's +// 1,000: "can you help me", 2026-11-03), used to offer "Move Savings into +// Spending first" — a button whose board panel then said "Needs at least +// 1,000 sats in Savings". The ask now says what Savings holds and what a move +// needs, and offers the door that works: a Lightning receive into Spending. +// With enough in Savings the move is offered as before, straight to the +// board panel; the zap card asks the same question through a hook. +// Run: bun tools/ark-ln-savings-small-test.js +import { buildFeatures } from '../src/features/index.js'; +import { getArkConfig } from '../src/api.js'; + +let fails = 0; +const check = (name, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`); + if (!ok) fails++; +}; + +globalThis.fetch = async () => { throw new Error('offline (test)'); }; +globalThis.window = undefined; +globalThis.localStorage = { + _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, + removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; }, + get length() { return Object.keys(this._d).length; }, +}; +// the server's info as the app remembers it between sessions +const cfg = getArkConfig(); +localStorage.setItem('btc-wallet-arkinfo:' + cfg.ark, JSON.stringify({ info: { minBoardAmountSat: 1000, boardFees: {} }, tip: 1, at: Date.now() })); + +const CHAIN_FEE = 150; +const store = {}; +const wallet = { + watchOnly: false, offline: false, spendable: 546, feeRates: { halfHourFee: 1 }, + utxos: [], txs: [], receive: [], change: [], + account: () => null, + loadFeatureState: (n, fb) => JSON.parse(store[n] || 'null') || fb, + saveFeatureState: (n, st) => { store[n] = JSON.stringify(st); }, + _cacheKey: () => 'test', saveCache: () => {}, + nostrPubkey: () => 'ab'.repeat(32), + registerCacheExtension: () => {}, registerRealtimeHook: () => {}, registerLoadHook: () => {}, + registerScanHook: () => {}, registerCacheSavedHook: () => {}, registerCoinLock: () => {}, + loadArkState: () => null, saveArkState: () => {}, _arkNs: () => 'ns', _arkKey: () => 'k', + _arkHoldKey: () => 'h', _arkVtxoOwner: () => null, adoptArkState: () => null, + freshReceive: () => ({ address: 'bc1qtest' }), freshChange: () => ({ address: 'bc1qchange' }), + balance: () => ({ spendableSat: 0 }), emit: () => {}, on: () => {}, + buildTx({ recipients }) { + const amount = recipients[0].amount; + if (amount + CHAIN_FEE > wallet.spendable) throw new Error('Insufficient funds for amount + fee.'); + return { outputs: [{ amount }], fee: CHAIN_FEE }; + }, +}; +const calls = []; +const ui = { account: 'savings' }; +let features = []; +const ctx = { + h: (tag, attrs, ...kids) => ({ tag, attrs: attrs || {}, kids: kids.flat().filter(Boolean) }), + ui, render: () => {}, wallet, + hook: (name, ...args) => { for (const f of features) if (f[name]) { const v = f[name](...args); if (v) return v; } return null; }, + getAccount: () => ui.account, setAccount: (a, dir) => { calls.push(['setAccount', a]); ui.account = a; }, + goHome: () => calls.push(['goHome']), + openMnemonic: async () => {}, + fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => 'sats', getUnit: () => 'sats', + parseAmount: (v) => parseInt(v, 10) || 0, + copyBtn: () => null, pasteBtn: () => null, toast: () => {}, blankSend: () => ({ recipients: [] }), + openExternal: () => {}, +}; +features = buildFeatures(ctx); +const ark = features.find((f) => f.id === 'ark'); + +const text = (n) => typeof n === 'string' ? n : n && n.kids ? n.kids.map(text).join(' ') : ''; +const buttons = (n, out = []) => { if (n && n.tag === 'button') out.push(n); if (n && n.kids) n.kids.forEach((k) => buttons(k, out)); return out; }; +const button = (tree, label) => buttons(tree).find((b) => text(b) === label); + +console.log('[546 sats in Savings, Spending empty, 175-sat invoice]'); +{ + ui.arkLnFromSavings = { invoice: 'lnbc1750n1test', meta: null, amountSat: 175 }; + const tree = ark.sendView(); + const body = text(tree); + check('says what Savings holds and what a move needs', /Savings holds 546 sats, and a move into Spending needs at least 1,000 sats/.test(body), body); + check('no move button', !button(tree, 'Move Savings into Spending first'), buttons(tree).map(text).join(' | ')); + check('hook answers: Savings cannot board', !ark.arkSavingsCanBoard()); + const recv = button(tree, 'Receive over Lightning instead'); + check('offers a Lightning receive', !!recv); + recv.attrs.onClick(); + check('the receive door opens Spending (its Receive page) and closes the ask', + calls.some((c) => c[0] === 'setAccount' && c[1] === 'spending') && !ui.arkLnFromSavings && ui.account === 'spending', JSON.stringify(calls)); +} + +console.log('[1,000 sats in Savings: the minimum, but not the fee on top]'); +{ + calls.length = 0; ui.account = 'savings'; wallet.spendable = 1000; + ui.arkLnFromSavings = { invoice: 'lnbc1750n1test', meta: null, amountSat: 175 }; + const tree = ark.sendView(); + check('still no move offered', !button(tree, 'Move Savings into Spending first') && !!button(tree, 'Receive over Lightning instead'), text(tree)); +} + +console.log('[5,000 sats in Savings]'); +{ + calls.length = 0; ui.account = 'savings'; wallet.spendable = 5000; + ui.arkLnFromSavings = { invoice: 'lnbc1750n1test', meta: null, amountSat: 175 }; + const tree = ark.sendView(); + const body = text(tree); + check('the move is offered, with the confirmation note', /Move funds over from Savings first/.test(body) && !!button(tree, 'Move Savings into Spending first'), body); + check('no receive door', !button(tree, 'Receive over Lightning instead')); + check('hook answers: Savings can board', ark.arkSavingsCanBoard() === true); + button(tree, 'Move Savings into Spending first').attrs.onClick(); + check('the move button opens the board panel toward Spending, prefilled at the minimum or more', + !ui.arkLnFromSavings && ui.arkMoveOpen && ui.arkMoveDir === 'toSpending' && parseInt(ui.arkBoardAmt, 10) >= 1000, `amt=${ui.arkBoardAmt} ${JSON.stringify(calls)}`); +} + +console.log(fails ? `\n${fails} FAILED` : '\nall passed'); +process.exit(fails ? 1 : 0); diff --git a/tools/ark-store-test.js b/tools/ark-store-test.js index 4e02fa0..a72ad81 100644 --- a/tools/ark-store-test.js +++ b/tools/ark-store-test.js @@ -87,4 +87,25 @@ assert.equal(broken.lsMode, true, 'a refusing IndexedDB drops to localStorage'); assert.equal(broken.get(K)._rev, 1); console.log('✓ localStorage fallback'); +// ---- peek: the stored object, no copy; a write is seen at once ---- +const ls4 = new Storage(); +ls4.setItem(K, JSON.stringify({ _rev: 3, vtxos: [] })); +const pk = new ArkStore({ backend: memoryBackend(), ls: () => ls4 }); +const before = pk.peek(K); +assert.equal(before._rev, 3, 'before opening, peek reads localStorage'); +assert.equal(pk.peek(K), before, 'and keeps the parse'); +await pk.open(); +assert.equal(pk.peek(K), pk.peek(K), 'opened: the same object every time'); +assert.notEqual(pk.get(K), pk.peek(K), 'get still copies'); +pk.set(K, { _rev: 4, vtxos: [] }); +assert.equal(pk.peek(K)._rev, 4, 'a write shows in the next peek'); +pk.remove(K); +assert.equal(pk.peek(K), null); +const pl = new ArkStore({ backend: null, ls: () => ls4 }); +pl.set(K, { _rev: 5, vtxos: [] }); +assert.equal(pl.peek(K)._rev, 5); +pl.set(K, { _rev: 6, vtxos: [] }); +assert.equal(pl.peek(K)._rev, 6, 'localStorage mode: a write drops the kept parse'); +console.log('✓ peek reads without copying'); + console.log('\n✅ ark store behaves'); diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js index 214ab45..68e7191 100644 --- a/tools/ark/custody-test.js +++ b/tools/ark/custody-test.js @@ -127,4 +127,25 @@ const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expi console.log('✓ deep coins: no base fee for a refresh of deep coins alone, a capped coin is never spent, the worker orders them flattened'); } +// Lightning sends: a wallet that renews its own coins spends the nearest +// expiry first; the custodial default keeps the smallest coin that covers it +{ + const m = mk(true); + m.info.lnSendFees = { baseFeeSat: 0, minFeeSat: 0, ppmExpiryTable: [] }; + m.state.vtxos = [coin('young', 3000, 2000), coin('old', 9000, 1100), coin('older', 2000, 1050), coin('past', 8000, 900)]; + const ids = (amt) => m._selectLnInputs(m.state.vtxos, 1000, amt, 0).inputs.map((v) => v.id); + assert.deepEqual(ids(2500), ['young'], 'custodial: the smallest coin that covers it'); + assert.deepEqual(ids(12000), ['old', 'young'], 'custodial: largest first'); + m.lnOldestFirst = () => true; + assert.deepEqual(ids(1500), ['older'], 'self: the nearest expiry that covers it'); + assert.deepEqual(ids(2500), ['old'], '…skipping one too small'); + assert.deepEqual(ids(10000), ['older', 'old'], 'self: gathers nearest expiry first'); + assert.deepEqual(ids(13000), ['older', 'old', 'young'], 'coins inside their timelock carry it alone when they can'); + // a pile of old crumbs that would breach the input cap: largest-first instead + m.state.vtxos = [...Array.from({ length: MAX_SEND_INPUTS + 2 }, (_, i) => coin('crumb' + i, 100, 1010)), coin('big', 6000, 3000)]; + assert.deepEqual(ids(6050).slice(0, 1), ['big'], 'too many old crumbs for one payment: largest first'); + assert.equal(ids(6050).length, 2); + console.log('✓ Lightning sends: nearest expiry first when renewing your own coins, smallest-that-covers otherwise'); +} + console.log('\n✅ custody tiers behave'); diff --git a/tools/ark/refresh-test.js b/tools/ark/refresh-test.js index 0557659..6938daf 100644 --- a/tools/ark/refresh-test.js +++ b/tools/ark/refresh-test.js @@ -29,6 +29,7 @@ import { import { ALICE, aliceReady } from './alice.js'; const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535'; +const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002'; const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`; const FUND_SAT = 12345; const SPEND_SAT = 3000; @@ -86,7 +87,13 @@ if (!status?.fundingTx) { console.error('round never executed'); process.exit(1) const fundingTx = parseTx(status.fundingTx); console.log(`round executed: funding tx ${fundingTx.txid}, ${status.outputVtxos.length} output vtxo(s)`); execSync(`${BCLI} generatetoaddress 2 $(${BCLI} getnewaddress) >/dev/null`, { shell: '/bin/bash' }); -await sleep(2000); +// alice's bark checks the round anchor against esplora, which indexes a block +// ~5 s after it's mined; hand her the coin before then and she drops it +for (let i = 0; i < 30; i++) { + const st = await fetch(`${ESPLORA}/tx/${fundingTx.txid}/status`).then((r) => r.ok && r.json()).catch(() => null); + if (st?.confirmed) break; + await sleep(1000); +} const newVtxo = decodeVtxo(status.outputVtxos[0]); console.log(`new vtxo: ${newVtxo.id} (${newVtxo.amountSat} sat, expiry ${newVtxo.expiryHeight}, transitions: ${newVtxo.genesis.map((g) => g.transition.type).join(' -> ')})`); diff --git a/tools/avatar-profile-test.js b/tools/avatar-profile-test.js index 5c5293d..6d6c944 100644 --- a/tools/avatar-profile-test.js +++ b/tools/avatar-profile-test.js @@ -3,7 +3,7 @@ // wherever the reader was in the feed. Back returns to the same place. // // Run: bun tools/avatar-profile-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { generateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { cacheKeyFor } from '../src/wallet.js'; @@ -25,9 +25,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const A = 'a'.repeat(63) + '9', B = 'b'.repeat(63) + '7'; diff --git a/tools/balance-settings-row-test.js b/tools/balance-settings-row-test.js new file mode 100644 index 0000000..89e68d9 --- /dev/null +++ b/tools/balance-settings-row-test.js @@ -0,0 +1,65 @@ +// Settings → Wallet lists Spending and Savings; each row opens the page the +// balance card's gear opens. "I simply cannot find this option" (Nils, +// 2026-11-12) — the Spending gear was the only way in. +// Run: bun tools/balance-settings-row-test.js (NO_BUILD=1 to reuse dist/) +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); +const server = Bun.serve({ port: 0, fetch: async (req) => { + const path = new URL(req.url).pathname; + const f = Bun.file('dist' + path); + return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +// mainnet so Spending turns on (the Ark server's info is read); every other +// outside request is refused, so nothing is claimed or published +await page.setRequestInterception(true); +page.on('request', (r) => { const u = new URL(r.url()); (u.origin === server.url.origin || u.hostname === 'ark.coinos.io') ? r.continue() : r.abort(); }); +page.on('pageerror', (e) => errs.push(String(e))); +const click = (x) => page.evaluate((s) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(s)); if (e) e.click(); return !!e; }, x); +const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const openWalletPage = async () => { + await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500); + await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^Wallet/.test(e.textContent.trim())).click()); await sleep(500); +}; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'mainnet')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await seedWords(page); + await skipSeedCheck(page); + if (await waitText('claim a username', 8000)) { await click('not now'); await sleep(800); } + await waitText('receive'); + await sleep(1500); + check('savings-only wallet lists Savings alone', await (async () => { await openWalletPage(); const v = await page.$$eval('.balance-settings-row', (l) => l.map((e) => e.dataset.view)); await page.goBack(); await page.goBack(); await sleep(500); return v.join() === 'savings'; })()); + await click('set up a spending account'); await sleep(800); + if (await waitText('claim a username', 5000)) { await click('not now'); await sleep(800); } + await waitText('receive'); await sleep(800); + if (process.env.DEBUG) console.log((await page.evaluate(() => document.body.innerText)).slice(0, 600)); + const gears = await page.$$eval('.balance-gear', (l) => l.length); + await openWalletPage(); + if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.balance-settings-row').closest('.card').scrollIntoView()); await page.screenshot({ path: process.env.SHOT }); } + const views = await page.$$eval('.balance-settings-row', (l) => l.map((e) => e.dataset.view)); + check('Settings → Wallet lists the balances', views.length >= 1, views.join(',')); + check('Spending is listed when the card shows it', views.includes('spending'), `gears on the wallet: ${gears}`); + await page.evaluate(() => document.querySelector('.balance-settings-row[data-view="spending"]')?.click()); await sleep(600); + const st = await page.evaluate(() => ({ screen: history.state.nav.screen, view: history.state.nav.settingsView, h3: [...document.querySelectorAll('h3')].map((e) => e.textContent) })); + check('the Spending row opens the Spending settings', st.screen === 'accountSettings' && st.view === 'spending' && st.h3.includes('Spending'), JSON.stringify(st.h3.slice(0, 4))); + await page.goBack(); await sleep(600); + check('Back returns to Settings → Wallet', !!(await page.$('.balance-settings-row'))); + await page.evaluate(() => document.querySelector('.balance-settings-row[data-view="savings"]')?.click()); await sleep(600); + const sv = await page.evaluate(() => ({ screen: history.state.nav.screen, view: history.state.nav.settingsView })); + check('the Savings row opens the Savings settings', sv.screen === 'accountSettings' && sv.view === 'savings'); + check('no page errors', errs.length === 0, errs.join(' | ')); +} finally { + await browser.close(); server.stop(true); +} +console.log(ok ? 'PASS' : 'FAIL'); +process.exit(ok ? 0 : 1); diff --git a/tools/blob-alt-test.js b/tools/blob-alt-test.js new file mode 100644 index 0000000..4d958f2 --- /dev/null +++ b/tools/blob-alt-test.js @@ -0,0 +1,92 @@ +// A face or banner whose host has lost the file is found on another server +// that holds the same Blossom blob (same sha256 name): the one the person +// publishes in their kind 10063 first. The small avatar's thumbnail is made +// from the copy, the profile page's big face and banner paint from it, and +// the find is remembered on the device. +// Run: bun tools/blob-alt-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { nip19 } from 'nostr-tools'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sk = generateSecretKey(), PK = getPublicKey(sk), now = Math.floor(Date.now() / 1000); +const H1 = 'c11a29785c1af623a45ebfdb23860170a9d0c0ead31c1bcb209085cd662f7a17'; +const H2 = '479711427c577f040a5bf8cb05c47f75abfef37580893dd63b66f777c34dc327'; +const png = (color) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', `color=c=${color}:size=320x320`, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const FACE = png('orange'), COVER = png('teal'); +const html = await buildHtml({ minify: true, pwa: false }); +const hits = { dead: 0, alt: 0 }; +let origin = ''; +const server = Bun.serve({ port: 0, fetch: (req) => { + const u = new URL(req.url); + if (u.pathname.startsWith('/dead/')) { hits.dead++; return new Response('{"error":"The requested blob was not found."}', { status: 404, headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' } }); } + if (u.pathname === `/alt/${H1}.png`) { hits.alt++; return new Response(FACE, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); } + if (u.pathname === `/alt/${H2}.png`) { hits.alt++; return new Response(COVER, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); } + if (u.pathname === '/api/feed') return new Response(snapshot, { headers: { 'content-type': 'application/json' } }); + return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }); +} }); +origin = server.url.origin; +const notes = Array.from({ length: 12 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 60 - i * 60, tags: [], content: `Post ${i}: the harbour at dusk, boats coming in.` }, sk)); +const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Karnage', picture: `${origin}/dead/${H1}.png`, banner: `${origin}/dead/${H2}.png`, about: 'Saying the quiet part out loud.' }) }, sk); +const servers = finalizeEvent({ kind: 10063, created_at: now, tags: [['server', `${origin}/alt/`]], content: '' }, sk); +const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes, profiles: [profile], popUntil: now - 3600 }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(origin) ? r.continue() : r.abort()); // the public hosts: unreachable here + await page.evaluateOnNewDocument(([notes, profile, servers]) => { + class FakeSocket extends EventTarget { + constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set(); + setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); } + emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); } + message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); } + send(raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; } + if (m[0] !== 'REQ') return; + const [, id, f] = m; this.subs.add(id); + let evs = []; + if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until) + && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))).slice(0, f.limit || 100); + if (f.kinds?.includes(0) && f.authors?.includes(profile.pubkey)) evs = [profile]; + if (f.kinds?.includes(10063) && f.authors?.includes(servers.pubkey)) evs = [servers]; + if (f.ids) evs = notes.filter((e) => f.ids.includes(e.id)); + setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30); + } + close() { this.readyState = 3; this.emit('close', new Event('close')); } + } + Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); + window.WebSocket = FakeSocket; + }, [notes, profile, servers]); + const bg = (sel) => page.evaluate((s) => document.querySelector(s)?.style.backgroundImage || '', sel); + // the feed: the small face's thumbnail is made from the copy + await page.goto(origin + '/feed', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 5, { timeout: 20000 }); + // the copy is found and the thumbnail made from it (the probe and the + // thumbnail's own fetch: two hits); the row keeps the look it was admitted + // with, the next paint wears the face + for (let i = 0; i < 80 && hits.alt < 2; i++) await sleep(250); + assert.ok(hits.dead >= 1 && hits.alt >= 2, `the dead host was asked (${hits.dead}) and the copy fetched for the thumbnail (${hits.alt})`); + console.log('✓ the thumbnail is made from the copy on the published server'); + const remembered = await page.evaluate(() => localStorage.getItem('btc-wallet-blob-alts') || ''); + assert.ok(remembered.includes(`/alt/${H1}.png`), 'the find is remembered on the device'); + // the profile page (the screen that showed an empty circle and no cover): + // big face and banner from the copies, the post rows' small faces from + // the thumbnail + await page.goto(origin + '/' + nip19.npubEncode(PK), { waitUntil: 'domcontentloaded' }); + await page.waitForFunction((h) => (document.querySelector('.profile-banner')?.style.backgroundImage || '').includes('/alt/' + h), { timeout: 20000 }, H2) + .catch(async () => { throw new Error('banner not from the copy: ' + await bg('.profile-banner')); }); + const face = await bg('.profile-avatar'); + assert.ok(face.includes(`/alt/${H1}.png`), 'the big face paints from the copy: ' + face); + console.log('✓ the profile page paints the big face and the banner from the copies'); + const rowSel = `.note-post[data-zap-post="${notes[0].id}"] .ava-img`; + await page.waitForFunction((s, h) => /^url\("data:/.test(document.querySelector(s)?.style.backgroundImage || '') || (document.querySelector(s)?.style.backgroundImage || '').includes('/alt/' + h), { timeout: 20000 }, rowSel, H1) + .catch(async () => { throw new Error('post row face: ' + await page.evaluate((id) => [...document.querySelector('.note-post[data-zap-post="' + id + '"]')?.querySelectorAll('[class*=avatar], .ava-img') || []].map((e) => e.outerHTML.slice(0, 220)).join(' || '), notes[0].id)); }); + assert.deepEqual(errors, []); + console.log('✓ the post rows wear the face, and no page errors'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/call-test.js b/tools/call-test.js index d5c3909..44f9b46 100644 --- a/tools/call-test.js +++ b/tools/call-test.js @@ -5,7 +5,7 @@ // both. A call can be declined, and a stranger's (neither followed nor // written to) doesn't ring, but is in the conversation as a missed call. Everything on the wire is an ephemeral gift wrap. // Run: bun tools/call-test.js (NO_BUILD=1 to reuse dist/) -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; import { getPublicKey } from 'nostr-tools/pure'; @@ -74,9 +74,9 @@ async function wallet(name) { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive'); return { page, mn, pk: getPublicKey(privateKeyFromSeedWords(mn)), base: cacheKeyFor(mn + '\n'), click, waitText, name }; } diff --git a/tools/call-wake-test.js b/tools/call-wake-test.js index 54c42ee..3f81db1 100644 --- a/tools/call-wake-test.js +++ b/tools/call-wake-test.js @@ -1,12 +1,12 @@ // A call wakes a closed app: with Bob's coinos closed, Alice calls; the push // the notifier would send (delivered here to Bob's service worker through // Chrome's DevTools protocol) makes the worker fetch the offer's wrap, open -// it with Bob's key and ring — "Alice Caller is calling" — and the tap that +// it with Bob's key and ring — "Calling | Alice Caller" — and the tap that // opens the app finds the offer kept for it: it rings there, Bob answers, // they talk. A push for a stranger's call, or for something that isn't an // offer, rings nothing. Plus the worker's check on its own (classifyCall). // Run: bun tools/call-wake-test.js (NO_BUILD=1 to reuse dist/) -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; @@ -91,9 +91,9 @@ async function wallet(name) { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive'); const sk = privateKeyFromSeedWords(mn); return { context, page, mn, sk, pk: getPublicKey(sk), base: cacheKeyFor(mn + '\n'), click, waitText, name }; @@ -152,7 +152,7 @@ try { // the tap: the worker opens the app at ?open=call await B.page.goto(`http://localhost:${PORT}/?open=call`, { waitUntil: 'domcontentloaded' }); const ns = await notifications(B.page); - check('the worker rang: "Alice Caller is calling"', ns.some((n) => /^Alice Caller is calling \| Voice call/.test(n) && / \| call$/.test(n)), JSON.stringify(ns)); + check('the worker rang: "Calling | Alice Caller"', ns.some((n) => /^Calling \| Alice Caller/.test(n) && / \| call$/.test(n)), JSON.stringify(ns)); check('the app the tap opened rings with the offer the worker kept', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText), 25000)); // Alice's candidates to Bob (his own 'answered here' copy, ~770 chars, isn't one) const toBob = () => [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length > 1000 && w.content.length < 1500).length; diff --git a/tools/chat-remove-member-test.js b/tools/chat-remove-member-test.js new file mode 100644 index 0000000..1007973 --- /dev/null +++ b/tools/chat-remove-member-test.js @@ -0,0 +1,224 @@ +// A community's admin taps a message and removes its author, with everything +// they said: one owner-signed ban-list edition (CORD-04 vsk 4) that every +// client folds. "can i have some control as a chat group admin to click on a +// message and see an option to remove the user and all their messages from +// the group?" (Adam, 2026-11-10). +// +// Everything runs against a relay inside this process: the page's sockets all +// go there, so nothing reaches a live room. +// +// Run: bun tools/chat-remove-member-test.js +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { + parseInviteLink, openInviteBundle, channelStream, channelEpoch, controlKey, wrapRumor, openWrap, msTags, + foldControl, banlistHead, makeBanlist, +} from '../src/concord.js'; +import { hexToBytes } from '@noble/hashes/utils'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +// ---- a relay in a few lines: stores what it's given, serves it by filter ---- +const events = new Map(); +const socks = new Map(); // ws -> Map(subId -> filters) +const matches = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) + && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until) + && Object.entries(f).every(([k, v]) => k[0] !== '#' || e.tags.some((t) => t[0] === k.slice(1) && v.includes(t[1]))); +const store = (e) => { + if (events.has(e.id)) return; + events.set(e.id, e); + for (const [ws, subs] of socks) for (const [id, fs] of subs) if (fs.some((f) => matches(f, e))) ws.send(JSON.stringify(['EVENT', id, e])); +}; +const RELAY_PORT = 27811, RELAY = `ws://127.0.0.1:${RELAY_PORT}`; +const relay = Bun.serve({ + port: RELAY_PORT, hostname: '127.0.0.1', + fetch(req, srv) { return srv.upgrade(req) ? undefined : new Response('relay'); }, + websocket: { + open(ws) { socks.set(ws, new Map()); }, + close(ws) { socks.delete(ws); }, + message(ws, raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'EVENT') { store(m[1]); ws.send(JSON.stringify(['OK', m[1].id, true, ''])); } + else if (m[0] === 'REQ') { + const fs = m.slice(2); + socks.get(ws).set(m[1], fs); + for (const f of fs) { + const hit = [...events.values()].filter((e) => matches(f, e)).sort((a, b) => b.created_at - a.created_at); + for (const e of f.limit ? hit.slice(0, f.limit) : hit) ws.send(JSON.stringify(['EVENT', m[1], e])); + } + ws.send(JSON.stringify(['EOSE', m[1]])); + } else if (m[0] === 'CLOSE') socks.get(ws)?.delete(m[1]); + else if (m[0] === 'COUNT') ws.send(JSON.stringify(['COUNT', m[1], { count: 0 }])); + else if (/^NEG-/.test(m[0])) ws.send(JSON.stringify(['NEG-ERR', m[1], 'blocked: unsupported'])); + }, + }, +}); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5263, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 1100, height: 850 }); +page.on('pageerror', (e) => { console.log(' page error:', e.message); ok = false; }); +await page.evaluateOnNewDocument((relayUrl) => { + const Real = window.WebSocket; + window.WebSocket = function (url, protocols) { return new Real(/^wss?:/.test(url) ? relayUrl : url, protocols); }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); + localStorage.setItem('btc-wallet-network', 'regtest'); + // the invite link is "copied": keep it where the test can read it + try { Object.defineProperty(navigator, 'clipboard', { value: { writeText: async (v) => { window.__clip = v; }, readText: async () => window.__clip || '' }, configurable: true }); } catch {} +}, RELAY); + +const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); +const setInput = (sel, v) => page.evaluate((s, val) => { const e = document.querySelector(s); if (!e) return false; e.value = val; e.dispatchEvent(new Event('input', { bubbles: true })); return true; }, sel, v); +const until = async (fn, ms = 20000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(300); } }; +const bodyText = () => page.evaluate(() => document.body.innerText); +const waitText = (x, ms = 20000) => until(async () => (await bodyText()).toLowerCase().includes(x.toLowerCase()), ms); +const logText = () => page.evaluate(() => document.querySelector('.chat-log')?.innerText || ''); +const say = async (text) => { + await page.waitForSelector('#msg-draft', { timeout: 15000 }); + await page.evaluate((v) => { + const e = document.querySelector('#msg-draft'); + const ta = e.shadowRoot ? e.shadowRoot.querySelector('textarea') : (e.querySelector && e.querySelector('textarea')) || e; + ta.focus(); ta.value = v; + ta.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + }, text); + await sleep(150); + await page.keyboard.press('Enter'); +}; +const tapBubble = (text) => page.evaluate((x) => { + const b = [...document.querySelectorAll('.chat-log .chat-bubble')].find((n) => n.innerText.includes(x)); + if (b) b.click(); + return !!b; +}, text); +const sheet = () => page.evaluate(() => [...document.querySelectorAll('.msg-sheet .msg-sheet-item')].map((b) => b.innerText.trim())); + +try { + await page.goto('http://localhost:5263/', { waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('button', 'Create a new wallet'); + await sleep(300); + await click('button', 'Have an existing seed'); + await sleep(300); + await page.waitForSelector('textarea'); + await page.type('textarea', generateMnemonic(wordlist)); + await click('button', 'Open wallet'); + await waitText('receive', 15000); + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click()); + await sleep(800); + await click('button', 'Create'); + await sleep(300); + await setInput('input[placeholder*="name" i]', 'removal test'); + await sleep(200); + await page.evaluate(() => { const i = document.querySelector('input[placeholder*="name" i]'); const b = i && i.parentElement.querySelector('button'); if (b) b.click(); }); + await waitText('removal test', 25000); + await click('.item', 'removal test'); + await page.waitForSelector('#msg-draft', { timeout: 15000 }); + await say('the admin says hello'); + check('the admin posts in their community', await until(async () => (await logText()).includes('the admin says hello'))); + + // the join material, the way anyone gets it: the invite link + await page.evaluate(() => document.querySelector('[aria-label="Add person"]')?.click()); + await sleep(300); + await click('button', 'Copy invite link'); + const link = await until(() => page.evaluate(() => window.__clip || null), 10000); + const parsed = parseInviteLink(link || ''); + const bundleEv = parsed && [...events.values()].find((e) => e.kind === 33301 && e.pubkey === parsed.signerPk); + const jm = bundleEv && openInviteBundle(bundleEv, parsed.token); + check('invite link opens the community', !!(jm && jm.community_root), link ? link.slice(0, 50) + '…' : 'no link'); + if (!jm) throw new Error('no join material'); + await page.evaluate(() => document.querySelector('[aria-label="Add person"]')?.click()); + const ch = jm.channels[0]; + const stream = channelStream(jm, ch); + const post = async (sk, text) => { + const { created_at, ms } = msTags(Date.now()); + const rumor = { kind: 9, pubkey: getPublicKey(sk), content: text, tags: [['channel', ch.id], ['epoch', String(channelEpoch(jm, ch))], ms], created_at }; + store(await wrapRumor(rumor, sk, stream)); + }; + const spammer = generateSecretKey(), friend = generateSecretKey(); + await post(spammer, 'Can I have some free sats'); + await sleep(30); + await post(friend, 'good evening all'); + await sleep(30); + await post(spammer, 'Any donation can help'); + check('members\' messages arrive', await until(async () => { const x = await logText(); return x.includes('free sats') && x.includes('good evening') && x.includes('donation'); })); + + console.log('\n[the sheet]'); + await tapBubble('the admin says hello'); + await sleep(300); + let items = await sheet(); + check('own message: no remove option', items.length > 0 && !items.some((x) => /remove/i.test(x)), items.join(' / ')); + await page.keyboard.press('Escape'); + await page.evaluate(() => document.querySelector('.confirm-pop-backdrop')?.click()); + await sleep(300); + await tapBubble('free sats'); + await sleep(300); + items = await sheet(); + check('someone else\'s message: "Remove … and their messages"', items.some((x) => /Remove .+ and their messages$/.test(x)), items.join(' / ')); + await click('.msg-sheet-item', 'and their messages'); + await sleep(300); + items = await sheet(); + check('first tap only asks', items.some((x) => /tap again/i.test(x)) && (await logText()).includes('free sats'), items.join(' / ')); + await click('.msg-sheet-item', 'tap again'); + const gone = await until(async () => { const x = await logText(); return !x.includes('free sats') && !x.includes('donation'); }, 15000); + check('second tap: every message of theirs is gone', !!gone); + const after = await logText(); + check('everyone else\'s stay', after.includes('good evening') && after.includes('the admin says hello')); + + console.log('\n[what other clients see]'); + const control = controlKey(hexToBytes(jm.community_root), jm.community_id, jm.root_epoch || 0); + const entries = () => [...events.values()].filter((e) => e.kind === 1059 && e.pubkey === control.pk).map((w) => openWrap(w, control)).filter(Boolean); + let folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); + check('the relay holds an owner-signed ban list naming them', folded.banned.has(getPublicKey(spammer)) && folded.banned.size === 1); + // a member (not the owner) signing a higher version can't undo it + const head = banlistHead(entries(), { ownerHex: jm.owner, cid: jm.community_id }); + const forged = { ...makeBanlist({ ...head, version: head.version + 5 }, [], Date.now()), pubkey: getPublicKey(spammer) }; + store(await wrapRumor(forged, spammer, control, { plaintext: true })); + folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); + check('a member\'s own "empty list" edition changes nothing', folded.banned.has(getPublicKey(spammer))); + await post(spammer, 'me again, free sats?'); + await post(friend, 'still here'); + await until(async () => (await logText()).includes('still here')); + check('their new messages never show', !(await logText()).includes('me again')); + + console.log('\n[after a reload, and a second removal]'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await until(async () => (await logText()).includes('good evening'), 25000); + await sleep(1500); + check('still gone after a reload', !/free sats|donation|me again/.test(await logText()) && (await logText()).includes('good evening')); + const second = generateSecretKey(); + await post(second, 'buy my course'); + await until(async () => (await logText()).includes('buy my course')); + await tapBubble('buy my course'); + await sleep(300); + await click('.msg-sheet-item', 'and their messages'); + await sleep(300); + await click('.msg-sheet-item', 'tap again'); + await until(async () => !(await logText()).includes('buy my course'), 15000); + folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); + check('the second removal keeps the first', folded.banned.has(getPublicKey(spammer)) && folded.banned.has(getPublicKey(second)) && folded.banned.size === 2, `${folded.banned.size} on the list`); + + console.log('\n[putting someone back]'); + await page.evaluate(() => document.querySelector('.chat-head .col.clickable')?.click()); + await sleep(500); + const hasList = await until(async () => (await bodyText()).includes('Removed from this community'), 5000); + check('the member list shows who was removed (to the admin)', !!hasList); + await page.evaluate(() => { const rows = [...document.querySelectorAll('.chat-members .item')].filter((r) => [...r.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Restore')); rows.at(-1)?.querySelector('button')?.click(); }); + const back = await until(async () => (await logText()).includes('buy my course'), 15000); + check('Restore brings their messages back', !!back); + folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); + check('and the list keeps the other', folded.banned.has(getPublicKey(spammer)) && folded.banned.size === 1); +} catch (e) { + console.log(' error:', e.message); ok = false; +} finally { + await browser.close(); + server.stop(true); relay.stop(true); +} +console.log(ok ? '\nPASS' : '\nFAIL'); +process.exit(ok ? 0 : 1); diff --git a/tools/comments-test.js b/tools/comments-test.js index 5a73e6f..21d028d 100644 --- a/tools/comments-test.js +++ b/tools/comments-test.js @@ -5,7 +5,7 @@ // are caught at the socket; nothing is published. // // Run: bun tools/comments-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { SimplePool } from 'nostr-tools/pool'; import { buildHtml } from '../build.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -86,8 +86,8 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); - await click('skip verification'); + await seedWords(page); + await skipSeedCheck(page); await waitText('receive', 20000); await page.goto('http://localhost:5283/' + NEVENT, { waitUntil: 'domcontentloaded' }); const first = comments.sort((a, b) => a.created_at - b.created_at)[0]; diff --git a/tools/composer-media-test.js b/tools/composer-media-test.js index 94b343a..db1648a 100644 --- a/tools/composer-media-test.js +++ b/tools/composer-media-test.js @@ -5,7 +5,7 @@ // paperclip, its URL landing in the draft. // // Run: bun tools/composer-media-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { buildHtml } from '../build.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -42,9 +42,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); await page.evaluate(() => document.querySelector('button[aria-label="Settings"]').click()); await sleep(300); diff --git a/tools/dm-nip42-test.js b/tools/dm-nip42-test.js index 51fa3a9..debe910 100644 --- a/tools/dm-nip42-test.js +++ b/tools/dm-nip42-test.js @@ -6,7 +6,7 @@ // copy of what we send in our own inbox so other clients see it too. // Both inboxes are played by this script's WebSocket shim. // Run: bun tools/dm-nip42-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -87,9 +87,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const ME_SK = privateKeyFromSeedWords(mn); const ME = getPublicKey(ME_SK); const base = cacheKeyFor(mn + '\n'); diff --git a/tools/dm-row-resort-test.js b/tools/dm-row-resort-test.js index 9e400bc..0ecdd44 100644 --- a/tools/dm-row-resort-test.js +++ b/tools/dm-row-resort-test.js @@ -3,7 +3,7 @@ // it was first drawn for — a tap on Bill's face opened someone else's // profile. After the re-sort, each row's avatar and row open their own. // Run: bun tools/dm-row-resort-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -67,9 +67,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('Receive', 20000); const ME = getPublicKey(privateKeyFromSeedWords(mn)); const base = cacheKeyFor(mn + '\n'); diff --git a/tools/feed-appetite-test.js b/tools/feed-appetite-test.js index b79a2a9..9f469cb 100644 --- a/tools/feed-appetite-test.js +++ b/tools/feed-appetite-test.js @@ -6,7 +6,7 @@ // still a while brings the ordinary page back. A page held still under an // insert above is not travel. // Run: bun tools/feed-appetite-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -79,9 +79,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/feed-boot-chrome-test.js b/tools/feed-boot-chrome-test.js new file mode 100644 index 0000000..91e5188 --- /dev/null +++ b/tools/feed-boot-chrome-test.js @@ -0,0 +1,113 @@ +// The feed opened by URL (/feed) paints as the restoring wallet's from its +// first frame. It painted as a visitor's — no post button, chips or nav — +// until the restore landed a beat later (the vault's scrypt, the wallet's +// state, every feature's init: ~0.5 s on a laptop, more on a phone), and +// the button "took a while to appear" (Adam, 2026-10-31). Boot now tells +// a wallet is about to open (a session list in the tab, a vault the last +// unlock opened without a password, watch-only accounts) before the work, +// and the page paints for it. A visitor with no wallet still gets none. +// Run: bun tools/feed-boot-chrome-test.js +import puppeteer from './puppeteer.js'; +import { buildHtml } from '../build.js'; +import { encryptVault } from '../src/wallet.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +let fails = 0; +const check = (name, ok, detail = '') => { console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`); if (!ok) fails++; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5331, fetch: (req) => { + const p = new URL(req.url).pathname; + if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const mnemonic = generateMnemonic(wordlist); +const account = { id: 'boot-test', label: 'Boot', type: 'full', mnemonic, passphrase: '', network: 'regtest', persisted: true }; +const vault = encryptVault([{ label: account.label, mnemonic, passphrase: '', network: 'regtest' }], ''); + +// Every mutation batch gets a number; the first batch each piece of the page +// showed up in says whether it came with the page or after it. +const watch = () => { + window.__seen = {}; window.__batch = 0; + const SEL = { page: '.feed-page', fab: '.feed-post-fab', chips: '.feed-chips', nav: '.app-bottom-nav', side: '.wallet-side' }; + new MutationObserver(() => { + window.__batch++; + for (const [k, s] of Object.entries(SEL)) if (!(k in window.__seen) && document.querySelector(s)) { window.__seen[k] = { batch: window.__batch, at: Math.round(performance.now()) }; if (k === 'fab') window.__fab0 = document.querySelector(s); } + }).observe(document, { childList: true, subtree: true }); +}; +async function load(name, seed) { + const ctx = await browser.createBrowserContext(); // its own storage: each scenario starts clean + const page = await ctx.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 390, height: 844 }); + await page.evaluateOnNewDocument(watch); + await page.evaluateOnNewDocument(seed, { account, vault }); + await page.goto('http://localhost:5331/feed', { waitUntil: 'domcontentloaded' }); + await sleep(3500); + const r = await page.evaluate(() => ({ + seen: window.__seen, errors: [], + fabSame: !!window.__fab0 && document.querySelector('.feed-post-fab') === window.__fab0, + chips: [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent.trim() + (c.classList.contains('on') ? '*' : '')), + fab: !!document.querySelector('.feed-post-fab'), nav: !!document.querySelector('.app-bottom-nav'), + walletOpen: !!document.querySelector('.app-nav-profile, .chat-avatar'), + blankNote: localStorage.getItem('btc-wallet-vault-blank'), + text: document.body.innerText.replace(/\s+/g, ' ').slice(0, 120), + })); + r.errors = errors; + console.log(`[${name}] ${JSON.stringify(r.seen)} chips=${r.chips.join('|')}`); + await ctx.close(); + return r; +} +// with the page, or (the nav rides the boot shell too) before it +const withPage = (r, k) => r.seen[k] && r.seen.page && r.seen[k].batch <= r.seen.page.batch; + +try { + // 1. A reload: the tab's session list says which wallet is coming. + let r = await load('session', ({ account }) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + sessionStorage.setItem('btc-wallet-accounts', JSON.stringify({ accounts: [account], activeId: account.id })); + }); + check('session: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen)); + check('session: the chips paint with the page', withPage(r, 'chips')); + check('session: the nav paints with the page', withPage(r, 'nav')); + check('session: the wallet then opens behind it', r.fab && r.nav && r.walletOpen, r.text); + check('session: the button is the same node through the restore (no blink)', r.fabSame); + check('session: Popular stood for the visitor copy, then the wallet\'s own chips', r.chips.length >= 2 && r.chips.filter((c) => /Popular/.test(c)).length === 1, r.chips.join('|')); + check('session: no page errors', r.errors.length === 0, r.errors.join(' | ')); + + // 2. A cold start: a vault the last unlock opened without a password. + r = await load('vault', ({ vault }) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-vault', JSON.stringify(vault)); + localStorage.setItem('btc-wallet-vault-blank', '1'); + }); + check('vault: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen)); + check('vault: the nav paints with the page', withPage(r, 'nav')); + check('vault: the wallet opens behind it', r.fab && r.walletOpen, r.text); + check('vault: the same node through the scrypt restore', r.fabSame); + check('vault: no page errors', r.errors.length === 0, r.errors.join(' | ')); + + // 3. The same vault before any unlock left its note: boot can't tell, so + // the page paints as a visitor's and grows the button when the wallet + // opens — and the unlock leaves the note for next time. + r = await load('vault, no note', ({ vault }) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-vault', JSON.stringify(vault)); + }); + check('unnoted vault: the button still comes, after the page', r.fab && r.seen.fab && r.seen.page && r.seen.fab.batch > r.seen.page.batch, JSON.stringify(r.seen)); + check('unnoted vault: the unlock leaves the note', r.blankNote === '1'); + + // 4. A visitor: no wallet on this device, nothing to paint for. + r = await load('visitor', () => { localStorage.setItem('btc-wallet-network', 'regtest'); }); + check('visitor: the feed paints, without a post button', r.seen.page && !r.fab && !r.seen.fab, JSON.stringify(r.seen)); + check('visitor: no page errors', r.errors.length === 0, r.errors.join(' | ')); +} finally { + await browser.close(); + server.stop(true); +} +console.log(fails ? `\n❌ ${fails} failing` : '\n✅ the feed paints for the wallet that is coming'); +process.exit(fails ? 1 : 0); diff --git a/tools/feed-bottom-nav-test.js b/tools/feed-bottom-nav-test.js index ea466fd..ccba776 100644 --- a/tools/feed-bottom-nav-test.js +++ b/tools/feed-bottom-nav-test.js @@ -13,7 +13,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d, saveFeatureState() {}, registerCacheExtension() {} }; let feature; -function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); } +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); } feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); window.test = { ui, wallet, render, get feature() { return feature; }, async feed() { ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null; diff --git a/tools/feed-conversations-test.js b/tools/feed-conversations-test.js index f16fc35..7394986 100644 --- a/tools/feed-conversations-test.js +++ b/tools/feed-conversations-test.js @@ -4,7 +4,7 @@ // profile's Replies tab shows the same context; the view persists across // feeds and reloads like the other views. // Run: bun tools/feed-conversations-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -48,9 +48,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, alice, bob, roots, replies]) => { diff --git a/tools/feed-custom-test.js b/tools/feed-custom-test.js index 122cb0a..c5ed41f 100644 --- a/tools/feed-custom-test.js +++ b/tools/feed-custom-test.js @@ -4,7 +4,7 @@ // feed, and deleting a feed. No relays are contacted for real — the // WebSocket shim answers the subscriptions. // Run: bun tools/feed-custom-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { generateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { cacheKeyFor } from '../src/wallet.js'; @@ -90,9 +90,9 @@ try { await sleep(400); // through the real first run: the wizard's own words, read off the screen await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); // the starter follows land once the current list has been asked for diff --git a/tools/feed-fab-anim-test.js b/tools/feed-fab-anim-test.js new file mode 100644 index 0000000..bd9379c --- /dev/null +++ b/tools/feed-fab-anim-test.js @@ -0,0 +1,58 @@ +// The post button is on screen the moment the feed is, while the page is +// still sliding in. It is position: fixed, and it used to be rendered inside +// the page — whose entrance animation puts a transform on it, which makes +// fixed descendants position within the page: the button rode at the foot +// of the feed card, below the screen, until the animation ended (measured +// on a phone: 4109 px down at 87 ms, in place at 562 ms). Now the shell +// paints it beside the page. +// Run: bun tools/feed-fab-anim-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const p = new URL(req.url).pathname; + if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const account = { id: 'fab-test', label: 'Fab', type: 'full', mnemonic: generateMnemonic(wordlist), passphrase: '', network: 'regtest', persisted: true }; +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.evaluateOnNewDocument((account) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + sessionStorage.setItem('btc-wallet-accounts', JSON.stringify({ accounts: [account], activeId: account.id })); + }, account); + await page.goto(server.url.origin + '/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.app-bottom-nav', { timeout: 20000 }); + await sleep(1500); + const tap = (which) => page.evaluate((which) => new Promise((resolve) => { + const b = [...document.querySelectorAll('.app-bottom-nav button')].find((x) => new RegExp(which, 'i').test(x.textContent)); + const t0 = performance.now(); const samples = []; + const tick = () => { + const f = document.querySelector('.feed-post-fab'); const ms = Math.round(performance.now() - t0); + if (f) { const r = f.getBoundingClientRect(); samples.push({ ms, top: Math.round(r.top), right: Math.round(innerWidth - r.right), anim: !!document.querySelector('.anim-page'), inPage: !!f.closest('.anim-page, .feed-page') }); } + if (ms < 320) requestAnimationFrame(tick); else resolve(samples); + }; + b.click(); requestAnimationFrame(tick); + }), which); + for (const round of [1, 2]) { + await tap('wallet'); await sleep(900); + const s = await tap('feed'); + assert.ok(s.length >= 5, 'the button was sampled while the page slid in: ' + s.length); + const first = s[0]; + assert.ok(first.anim, 'the page was still animating at the first sample (' + first.ms + ' ms)'); + assert.ok(!first.inPage, 'the button is painted beside the page, not inside it'); + const off = s.filter((x) => x.top > 844 - 40 || x.top < 0); + assert.equal(off.length, 0, `the button sat off screen in ${off.length} of ${s.length} frames: ` + JSON.stringify(s.slice(0, 4))); + console.log(`✓ round ${round}: the post button is on screen from the first frame (top ${first.top}px at ${first.ms} ms, ${s.length} frames sampled)`); + } + assert.deepEqual(errors, []); + console.log('✓ no page errors'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/feed-list-test.js b/tools/feed-list-test.js index 6d60240..00942b8 100644 --- a/tools/feed-list-test.js +++ b/tools/feed-list-test.js @@ -5,7 +5,7 @@ // unticking "Keep as a nostr list" sends the NIP-09 deletion. No relay is // written for real — the WebSocket shim swallows EVENT and answers OK. // Run: bun tools/feed-list-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -83,9 +83,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const ME_SK = privateKeyFromSeedWords(mn); diff --git a/tools/feed-media-cache-test.js b/tools/feed-media-cache-test.js index 5a26ca2..8ca8185 100644 --- a/tools/feed-media-cache-test.js +++ b/tools/feed-media-cache-test.js @@ -3,7 +3,7 @@ // slow picture), a reload paints the grid from that cache at once and // resumes the crawl where it stopped, and tile frames are kept as pictures. // Run: bun tools/feed-media-cache-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -115,9 +115,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const key = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk]) => { diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js index dac9804..74de24a 100644 --- a/tools/feed-media-views-test.js +++ b/tools/feed-media-views-test.js @@ -5,7 +5,7 @@ // until tapped: then the author, the words as a caption, likes and sats. // Back and Escape close it. // Run: bun tools/feed-media-views-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -112,9 +112,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const key = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/feed-mention-tap-test.js b/tools/feed-mention-tap-test.js index e4d3f7a..d381288 100644 --- a/tools/feed-mention-tap-test.js +++ b/tools/feed-mention-tap-test.js @@ -2,7 +2,7 @@ // thread on top of them (the mention's tap reached the row, which opened // the thread over the profile it had just shown). Links in a post likewise. // Run: bun tools/feed-mention-tap-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { npubEncode } from 'nostr-tools/nip19'; @@ -33,9 +33,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('Receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, a, f, ns]) => { diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js index 59d6f7c..d66ab59 100644 --- a/tools/feed-new-posts-test.js +++ b/tools/feed-new-posts-test.js @@ -14,7 +14,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d, saveFeatureState() {}, registerCacheExtension() {} }; let feature; -function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); } +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); } feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); window.test = { ui, wallet, render, get feature() { return feature; }, async feed() { ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null; diff --git a/tools/feed-pager-ahead-test.js b/tools/feed-pager-ahead-test.js index 057ac3e..0906034 100644 --- a/tools/feed-pager-ahead-test.js +++ b/tools/feed-pager-ahead-test.js @@ -3,7 +3,7 @@ // feed pages on, and the pager grows — past a run of posts with nothing to // show, too — so the reader never meets the end while there is more. // Run: bun tools/feed-pager-ahead-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -96,9 +96,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js index 4cbe68c..f74c981 100644 --- a/tools/feed-picture-pager-test.js +++ b/tools/feed-picture-pager-test.js @@ -4,7 +4,7 @@ // closes it, as before; once the reader has moved on, a tap shows the post's // details and the next tap (or Escape) closes the pager onto that post. // Run: bun tools/feed-picture-pager-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -67,9 +67,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/feed-reboost-test.js b/tools/feed-reboost-test.js index e22df91..f43e7b5 100644 --- a/tools/feed-reboost-test.js +++ b/tools/feed-reboost-test.js @@ -4,7 +4,7 @@ // reload — stays out. A repost of a post you haven't read still lands, and // the row you read keeps its place. // Run: bun tools/feed-reboost-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -95,9 +95,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pks]) => { diff --git a/tools/feed-repost-test.js b/tools/feed-repost-test.js index 4936dd6..1bf3d7a 100644 --- a/tools/feed-repost-test.js +++ b/tools/feed-repost-test.js @@ -4,7 +4,7 @@ // copy doesn't check out is left out, and a note already in the feed isn't // shown twice. // Run: bun tools/feed-repost-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -48,9 +48,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, z, l, o]) => { diff --git a/tools/feed-streaming-test.js b/tools/feed-streaming-test.js index c69b1eb..4fa00cf 100644 --- a/tools/feed-streaming-test.js +++ b/tools/feed-streaming-test.js @@ -26,7 +26,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => ['wss://k : k === 'feedNotes' ? seeded : k === 'profiles' ? { [author]: { name: 'Stream Author', t: Date.now() } } : d, saveFeatureState() {}, registerCacheExtension() {} }; let feature; -function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView(), feature.bottomNav()]); } +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView(), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); } feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: () => null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); window.test = { feature, render }; diff --git a/tools/feed-switch-seed-test.js b/tools/feed-switch-seed-test.js index bf2b830..6d9d4f0 100644 --- a/tools/feed-switch-seed-test.js +++ b/tools/feed-switch-seed-test.js @@ -3,7 +3,7 @@ // other open feeds already hold, and once the feed on screen settles the // other feeds' first posts are fetched ahead into the disk cache. // Run: bun tools/feed-switch-seed-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -70,9 +70,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, a, b, ns]) => { diff --git a/tools/feed-video-aspect-test.js b/tools/feed-video-aspect-test.js index 84c0568..4196fe1 100644 --- a/tools/feed-video-aspect-test.js +++ b/tools/feed-video-aspect-test.js @@ -4,7 +4,7 @@ // shape still gets the 16:9 box. // Photos too: a big one paints from a column-sized copy (see makeMediaThumb). // Run: bun tools/feed-video-aspect-test.js (CHROME=<path> to pick a browser) -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -96,9 +96,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const key = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/feed-video-pager-test.js b/tools/feed-video-pager-test.js index cccde41..5bba7d8 100644 --- a/tools/feed-video-pager-test.js +++ b/tools/feed-video-pager-test.js @@ -4,7 +4,7 @@ // Each clip has a seek bar to drag; a tap pauses (and shows the details), // another plays on. Leaving full screen leaves the pager. // Run: bun tools/feed-video-pager-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -67,6 +67,8 @@ const openClip = async (word) => { await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word); await sleep(400); const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); }; @@ -80,9 +82,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/feed-window-dom-test.js b/tools/feed-window-dom-test.js index c86e98e..1f6ff93 100644 --- a/tools/feed-window-dom-test.js +++ b/tools/feed-window-dom-test.js @@ -3,7 +3,7 @@ // page keeps its length, the row under your eyes never moves when the window // shifts, and scrolling back up brings the top posts back. // Run: bun tools/feed-window-dom-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -78,9 +78,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/fullscreen-notice-test.js b/tools/fullscreen-notice-test.js index cf15f8c..a7c041a 100644 --- a/tools/fullscreen-notice-test.js +++ b/tools/fullscreen-notice-test.js @@ -5,7 +5,7 @@ // (feed-video-pager-test). A feed clip's own bar has no full-screen button: // a tap on the clip is the way, into the pager. // Run: bun tools/fullscreen-notice-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -68,6 +68,8 @@ async function run(lockOk) { await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word); await sleep(400); const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); }; @@ -77,9 +79,9 @@ async function run(lockOk) { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/gif-picker-test.js b/tools/gif-picker-test.js index f72cde3..9a48de9 100644 --- a/tools/gif-picker-test.js +++ b/tools/gif-picker-test.js @@ -4,7 +4,7 @@ // In a DM the picked GIF is sent at once. Uses the live API — localhost is // admitted as nostr.build's shared development client. // Run: bun tools/gif-picker-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -35,9 +35,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk]) => { diff --git a/tools/gift-from-test.js b/tools/gift-from-test.js new file mode 100644 index 0000000..a0ffaf8 --- /dev/null +++ b/tools/gift-from-test.js @@ -0,0 +1,71 @@ +// A gift link can name its sender: #f=<base32 pubkey|created_at|sig>, a +// nostr signature over the gift's code. The claim screen shows who it's +// from (and that claiming follows them); a link whose signature doesn't +// match its code — passed on as someone else's gift — shows no sender. +// Run: bun run build && bun tools/gift-from-test.js +import puppeteer from './puppeteer.js'; +import { base32nopad } from '@scure/base'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const server = Bun.serve({ port: 0, async fetch(req) { + const p = new URL(req.url).pathname; + const f = Bun.file('dist' + (p === '/' ? '/index.html' : p)); + return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } }); +} }); +const giftCode = () => { + const b = new Uint8Array(42); + b[0] = 0x11; b[1] = 3; // ark gift, mutinynet + new DataView(b.buffer).setBigUint64(2, 1000n, true); + b.set(crypto.getRandomValues(new Uint8Array(32)), 10); + return base32nopad.encode(b); +}; +const sk = generateSecretKey(); +const fromFrag = (code) => { + const ev = finalizeEvent({ kind: 27121, created_at: Math.floor(Date.now() / 1000), content: '', + tags: [['x', bytesToHex(sha256(new TextEncoder().encode(code.toUpperCase())))]] }, sk); + const b = new Uint8Array(100); + b.set(hexToBytes(ev.pubkey), 0); + new DataView(b.buffer).setUint32(32, ev.created_at); + b.set(hexToBytes(ev.sig), 36); + return base32nopad.encode(b); +}; +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const text = (page) => page.evaluate(() => document.body.innerText); +const waitText = async (page, re, ms = 30000) => { + for (let t = 0; t < ms; t += 250) { if (re.test(await text(page))) return true; await sleep(250); } + return false; +}; +async function open(path) { + const page = await browser.newPage(); + await page.goto(server.url.href); + await page.evaluate(() => { localStorage.clear(); localStorage.setItem('btc-wallet-network', 'mutinynet'); }); + await page.goto(server.url.href + path); + await waitText(page, /aren.t visible/i); + await sleep(500); + return page; +} +try { + const code = giftCode(); + let page = await open(`ag/${code}#m=hi&f=${fromFrag(code)}`); + let tx = await text(page); + check('a signed link names its sender', /\bFrom\b/.test(tx) && /Claiming it follows them/.test(tx), tx.slice(0, 300).replace(/\n/g, ' | ')); + check('the note still shows', /\bhi\b/.test(tx)); + await page.close(); + // an upper-case key, as a scanner may hand it over + page = await open(`ag/${code}#F=${fromFrag(code)}`); + { const tx = await text(page); check("an upper-cased link still names its sender", /Claiming it follows them/.test(tx), tx.slice(0, 200).replace(/\n/g, " | ")); } + await page.close(); + // the same signature on a different gift: no sender + page = await open(`ag/${giftCode()}#f=${fromFrag(code)}`); + check('a signature over another code names nobody', !/Claiming it follows them/.test(await text(page))); + await page.close(); +} finally { + await browser.close(); + server.stop(); +} +console.log(ok ? 'PASS' : 'FAIL'); +process.exit(ok ? 0 : 1); diff --git a/tools/gift-profile-setup-test.js b/tools/gift-profile-setup-test.js new file mode 100644 index 0000000..04e58c5 --- /dev/null +++ b/tools/gift-profile-setup-test.js @@ -0,0 +1,95 @@ +// After a gift is opened into a fresh wallet and its seed written down, the +// first-run wizard asks for a username, then a picture and a cover — the gift's +// recipient should arrive as someone people can pay by name. "Not now" lands in +// the account the gift went to. +// Drives an ark gift on mutinynet whose bearer key holds nothing (a random +// secret: the staging ASP sees no coin, so the sweep finds it taken), then +// "Create wallet anyway" → the seed → on to the wizard. +// Run: bun run build && bun tools/gift-profile-setup-test.js [screenshot.png] +import puppeteer from './puppeteer.js'; +import { base32nopad } from '@scure/base'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const server = Bun.serve({ port: 0, async fetch(req) { + const p = new URL(req.url).pathname; + const f = Bun.file('dist' + (p === '/' ? '/index.html' : p)); + return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } }); +} }); +const giftCode = () => { + const b = new Uint8Array(42); + b[0] = 0x11; b[1] = 3; // ark gift, mutinynet + new DataView(b.buffer).setBigUint64(2, 1000n, true); + b.set(crypto.getRandomValues(new Uint8Array(32)), 10); + return base32nopad.encode(b); +}; +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const text = (page) => page.evaluate(() => document.body.innerText); +const click = (page, x) => page.evaluate((x) => { + const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); + e?.click(); return !!e; +}, x); +const waitText = async (page, re, ms = 30000) => { + for (let t = 0; t < ms; t += 250) { if (re.test(await text(page))) return true; await sleep(250); } + return false; +}; +async function toSeed(page, note) { + await page.goto(server.url.href); + await page.evaluate(() => { localStorage.clear(); localStorage.setItem('btc-wallet-network', 'mutinynet'); }); + await page.goto(server.url.href + 'ag/' + giftCode() + (note ? '#m=' + encodeURIComponent(note) : '')); + check('the gift opens: the server sees no coin behind it', await waitText(page, /aren.t visible/i)); + if (note) { + const q = await page.evaluate(() => document.querySelector('.gift-note')?.textContent || null); + check('the sender\'s note shows on the gift', q === note, String(q)); + check('...and leaves the address bar with the code', await page.evaluate(() => location.pathname === '/' && !location.hash)); + } + await click(page, 'claim'); + check('...so the claim finds it taken', await waitText(page, /create a wallet anyway/i)); + check('...create a wallet anyway shows the seed', await click(page, 'create a wallet anyway') && await waitText(page, /recovery phrase/i, 10000)); +} +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }); + await toSeed(page, 'Happy birthday! 🎂 <b>not bold</b>'); + check('a heads-up comes before the words', /no one is looking/i.test(await text(page)) && await page.evaluate(() => !document.querySelector('.words'))); + await click(page, 'show seed phrase'); + check('...then the words, with Next', await page.evaluate(() => document.querySelectorAll('.words .w').length === 12) && await click(page, 'next')); + check('...then one word to pick, or Skip', await waitText(page, /which one is word #5/i, 5000) && await page.evaluate(() => document.querySelectorAll('.quiz-opt').length === 4)); + await click(page, 'skip'); + check('the seed done: the wizard asks for a username', await waitText(page, /now pick a username/i, 10000), (await text(page)).slice(0, 200)); + check('...with the claim form (or its wait) and Not now', await page.evaluate(() => !!document.querySelector('.onb')) && /not now/i.test(await text(page))); + if (process.argv[2]) await page.screenshot({ path: process.argv[2] }); + // the look step follows the name, as in any first run + await page.evaluate(() => localStorage.setItem('btc-wallet-onb-step', 'avatar')); + await page.reload(); + check('the next step is the picture and cover', !!await page.waitForSelector('.onb-cover', { timeout: 15000 }).catch(() => null)); + // a second gift, verified this time, then Not now + const p2 = await browser.newPage(); + p2.on('pageerror', (e) => errors.push(String(e))); + await p2.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }); + await toSeed(p2); + await click(p2, 'show seed phrase'); + const fifth = await p2.evaluate(() => document.querySelectorAll('.words .w .t')[4]?.textContent.trim()); + check('Next opens the word check', await click(p2, 'next') && await waitText(p2, /which one is word #5/i, 5000)); + // a wrong pick says so and stays + const wrongOk = await p2.evaluate((right) => { + const b = [...document.querySelectorAll('.quiz-opt')].find((x) => x.textContent.trim() !== right); b?.click(); return !!b; + }, fifth); + await sleep(300); + check('...a wrong word is marked, and the check stays', wrongOk && await p2.evaluate(() => !!document.querySelector('.quiz-opt.wrong')) && /not that one/i.test(await text(p2))); + const filled = await p2.evaluate((right) => { + const b = [...document.querySelectorAll('.quiz-opt')].find((x) => x.textContent.trim() === right); b?.click(); return !!b; + }, fifth); + if (filled) { + check('a verified seed leads to the username step too', await waitText(p2, /now pick a username/i, 10000)); + await click(p2, 'not now'); + let gone = false; + for (let i = 0; i < 20 && !gone; i++) { await sleep(250); gone = !(await p2.evaluate(() => !!document.querySelector('.onb'))); } + check('Not now: into the wallet, the wizard gone', gone, (await text(p2)).slice(0, 200)); + } else check('the right word is among the choices', false); + check('no page errors', errors.length === 0, errors.join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a gift\'s new wallet is offered a name, a picture and a cover' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/go-live-test.js b/tools/go-live-test.js index c3fc8e3..3990fa6 100644 --- a/tools/go-live-test.js +++ b/tools/go-live-test.js @@ -6,7 +6,7 @@ // recording, and the recording is complete. // Run: bun tools/go-live-test.js (LIVE_SERVER=path to coinosv3-server/live/server.js; NO_BUILD=1 to reuse dist/; // LIVE_CONFIG=a json with {sfuAppId, sfuSecret} to watch over WebRTC — the real Cloudflare SFU) -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { naddrEncode } from 'nostr-tools/nip19'; import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; @@ -129,9 +129,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive'); const base = cacheKeyFor(mn + '\n'); await page.evaluate((k) => { diff --git a/tools/grid-early-player-test.js b/tools/grid-early-player-test.js index 0ab1c1e..04e44fb 100644 --- a/tools/grid-early-player-test.js +++ b/tools/grid-early-player-test.js @@ -14,7 +14,7 @@ // that); on a link slower than the clip, more — to the 4 MB cap. With Data // Saver on, the pager is careful again: such a clip waits for a tap and // nothing loads ahead. -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -100,9 +100,9 @@ async function run() { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/history-export-test.js b/tools/history-export-test.js index f7fff82..877b95e 100644 --- a/tools/history-export-test.js +++ b/tools/history-export-test.js @@ -2,7 +2,7 @@ // movements) as a line of a CSV file, oldest first, with what was paid, the // fee beside it and the change to the balance; a failed payment is left out. // Run: bun tools/history-export-test.js (NO_BUILD=1 to reuse dist/) -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; @@ -43,9 +43,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive'); // Spending's history, as the Ark manager stores it (moved into IndexedDB at the next boot) const key = cacheKeyFor(mn + '\n') + ':ark:' + ns('http://localhost:3535'); diff --git a/tools/image-tap-test.js b/tools/image-tap-test.js index d3bcb7e..ce40d53 100644 --- a/tools/image-tap-test.js +++ b/tools/image-tap-test.js @@ -3,7 +3,7 @@ // the picture opens the picture. // // Run: bun tools/image-tap-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { buildHtml } from '../build.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -27,9 +27,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const A = 'a'.repeat(63) + '9'; diff --git a/tools/kind0-overwrite-test.js b/tools/kind0-overwrite-test.js new file mode 100644 index 0000000..e791560 --- /dev/null +++ b/tools/kind0-overwrite-test.js @@ -0,0 +1,141 @@ +// A profile (kind 0) must never be written over one we failed to find. +// +// 2026-10-30: a long-time nostr user signed in to coinos with his key for +// the first time. His kind 0 lived on relays that keep history (ditto, +// snort, oxtr) and nowhere our quick lookup asked, so the payment-address +// publish merged over an empty base and went out as {lud16, nip05} alone: +// name, picture and banner gone from every relay. These scenarios run the +// messages feature against a fake relay network where only an archive holds +// the real profile, and record every event the wallet publishes. +// Run: bun tools/kind0-overwrite-test.js +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; + +let fails = 0; +const check = (name, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`); + if (!ok) fails++; +}; + +globalThis.localStorage = { + _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, + removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; }, + get length() { return Object.keys(this._d).length; }, +}; + +// per-relay stores: REQ answered from the socket's own relay, EVENT stored there +const relayStore = {}; +const published = []; // { url, ev } +const reqs = []; // { url, filter } +const norm = (u) => String(u).replace(/\/$/, ''); +class FakeSocket { + constructor(url) { + this.url = norm(url); this.readyState = 0; + setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 0); + } + reply(msg) { setTimeout(() => { if (this.readyState === 1 && this.onmessage) this.onmessage({ data: JSON.stringify(msg) }); }, 0); } + send(str) { + let m; try { m = JSON.parse(str); } catch { return; } + if (m[0] === 'EVENT') { (relayStore[this.url] ||= []).push(m[1]); published.push({ url: this.url, ev: m[1] }); this.reply(['OK', m[1].id, true, '']); } + if (m[0] === 'REQ') { + const [, sub, ...filters] = m; + for (const f of filters) reqs.push({ url: this.url, filter: f }); + if (filters.some((f) => f.cache)) { this.reply(['EOSE', sub]); return; } // Primal's cache knows nothing here + const hit = (relayStore[this.url] || []).filter((e) => filters.some((f) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)))); + hit.sort((a, b) => b.created_at - a.created_at); + for (const e of hit) this.reply(['EVENT', sub, e]); + this.reply(['EOSE', sub]); + } + } + close() { this.readyState = 3; this.onclose && this.onclose({}); } +} +globalThis.WebSocket = FakeSocket; +globalThis.Image = class { set src(v) { this._src = v; } }; +globalThis.fetch = async () => { throw new Error('offline'); }; + +const { messagesFeature } = await import('../src/features/messages.js'); + +const ARCHIVE = 'wss://relay.ditto.pub'; +const makeFeature = (sk, ui = {}) => { + const pk = getPublicKey(sk); + const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) }; + const walletSk = generateSecretKey(); + const wallet = { + mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [], nostrRelays: () => [], + nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) }, + loadFeatureState: () => null, saveFeatureState: () => {}, + _cacheKey: () => 'k0-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {}, + }; + const toasts = []; + const feature = messagesFeature({ + h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui, render: () => {}, wallet, toast: (m) => toasts.push(m), brandHeader: () => null, + hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null), + }); + return { feature, pk, toasts }; +}; +const newestOn = (url, pk) => (relayStore[url] || []).filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0]; +const kind0s = () => published.filter((p) => p.ev.kind === 0); + +console.log('[a first sign-in: the real profile lives only on an archive relay]'); +{ + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const old = { name: 'Nik kusanagi', display_name: 'Nik', lud16: '[email protected]', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' }; + relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)]; + const { feature } = makeFeature(sk); + // the payment-address publish that wiped him (lud16/nip05 empty or at the old address → repoint) + await feature.addressRenamed(null, '[email protected]'); + const sent = [...new Set(kind0s().map((p) => p.ev.id))]; + check('one kind 0 went out', sent.length === 1, sent.length); + const c = sent.length ? JSON.parse(kind0s()[kind0s().length - 1].ev.content) : {}; + check('it fills the empty nip05 and leaves the lightning address he had', c.nip05 === '[email protected]' && c.lud16 === '[email protected]', JSON.stringify(c)); + check('...and carries the name, face and cover the archive held', c.name === 'Nik kusanagi' && /face/.test(c.picture) && /cover/.test(c.banner), JSON.stringify(c)); + check('the archive\'s copy was asked for', reqs.some((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk))); + + // the onboarding wizard's suggestion must not rename him either + published.length = 0; + await feature.publishProfile({ name: 'satoshi1234', picture: 'https://punk/1.png' }, { fillOnly: ['name', 'picture'] }); + check('a fill-only suggestion changes nothing on a profile that has a name', kind0s().length === 0, kind0s().length); +} + +console.log('[a key with no profile anywhere: one deep look, then no waiting]'); +{ + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const { feature } = makeFeature(sk); + published.length = 0; reqs.length = 0; + const t0 = Date.now(); + await feature.publishProfile({ name: 'newbie' }); + const deep1 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length; + await feature.publishProfile({ about: 'hello' }); + const deep2 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length; + const last = JSON.parse(kind0s().pop().ev.content); + check('the new key still gets its profile', last.name === 'newbie' && last.about === 'hello', JSON.stringify(last)); + check('the archive was asked once, not again for the second publish', deep1 === 1 && deep2 === 1, `${deep1} ${deep2} in ${Date.now() - t0} ms`); +} + +console.log('[already hit: the editor finds the older copy and fills what is empty]'); +{ + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const old = { name: 'Nik kusanagi', display_name: 'Nik', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' }; + relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)]; + // what the wipe left: the address and a picture he re-uploaded by hand + const wiped = { lud16: '[email protected]', nip05: '[email protected]', picture: 'https://image.nostr.build/new.png' }; + for (const u of ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://purplepag.es', ARCHIVE]) (relayStore[u] ||= []).push(finalizeEvent({ kind: 0, created_at: 1793399187, tags: [], content: JSON.stringify(wiped) }, sk)); + const ui = { profEdit: { uname: 'nicstazzema', name: '', about: '', picture: wiped.picture, banner: '' } }; + const { feature, toasts } = makeFeature(sk, ui); + const versions = await feature.profileHistory(); + check('both copies found', versions.length === 2, versions.map((e) => e.created_at).join(' ')); + const older = versions.find((e) => /Nik/.test(e.content)); + const n = feature.fillProfileFrom(older); + check('name and banner filled, the picture he chose kept', n === 2 && ui.profEdit.name === 'Nik' && ui.profEdit.banner === old.banner && ui.profEdit.picture === wiped.picture, JSON.stringify(ui.profEdit)); + check('says what it filled', toasts.some((m) => /Filled 2 fields/.test(m)), toasts.join(' | ')); + published.length = 0; + await feature.saveProfileDraft(ui.profEdit); + const saved = JSON.parse(kind0s().pop().ev.content); + check('Save publishes the name back with the address intact', saved.name === 'Nik' && saved.display_name === 'Nik' && saved.lud16 === wiped.lud16 && /new\.png/.test(saved.picture) && /cover/.test(saved.banner), JSON.stringify(saved)); + check('newer than the wiped copy everywhere', newestOn('wss://relay.coinos.io', pk).created_at > 1793399187); +} + +console.log(fails ? `\n❌ ${fails} failing` : '\n✅ profiles are never written over unseen'); +process.exit(fails ? 1 : 0); diff --git a/tools/lightbox-scroll-test.js b/tools/lightbox-scroll-test.js index be03e9b..f507557 100644 --- a/tools/lightbox-scroll-test.js +++ b/tools/lightbox-scroll-test.js @@ -6,7 +6,7 @@ // thread had been opened from, or to the top. Back out of a thread still // lands on the row that opened it. // Run: bun tools/lightbox-scroll-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -56,9 +56,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/link-preview-test.js b/tools/link-preview-test.js index 52ddca1..190b220 100644 --- a/tools/link-preview-test.js +++ b/tools/link-preview-test.js @@ -4,7 +4,7 @@ // nothing to say stays a bare link; pictures and YouTube keep their own // boxes. // Run: bun tools/link-preview-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -80,9 +80,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); // follow the author, so the feed asks for their posts diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js index 2be6051..56dcb71 100644 --- a/tools/live-stream-test.js +++ b/tools/live-stream-test.js @@ -6,7 +6,7 @@ // profile says they're on, Back stops the player, the browser without HLS // of its own plays it through hls.js, and a zap carries the stream's `a`. // Run: bun tools/live-stream-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { naddrEncode } from 'nostr-tools/nip19'; @@ -150,9 +150,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive'); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/ln-pay-double-wake.test.js b/tools/ln-pay-double-wake.test.js new file mode 100644 index 0000000..c29d625 --- /dev/null +++ b/tools/ln-pay-double-wake.test.js @@ -0,0 +1,111 @@ +// Two wakes of the phone's worker, seconds apart, each over its own copy of +// the wallet, both drive one Lightning send (PullCap, 2026-10-24 23:21). The +// second's InitiateLightningPayment is refused — the HTLC is already 'ln-spent' +// to this very payment — and that once marked a PAID send 'revoking'. Now the +// send follows the payment instead, and a send in 'revoking' asks the ASP +// before revoking: paid → settled; still in flight → back to waiting. +// Run: bun test tools/ln-pay-double-wake.test.js +import { test, expect, mock } from 'bun:test'; +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import * as realLn from '../src/ark/lightning.js'; +import * as realProto from '../src/ark/proto.js'; +import * as realSend from '../src/ark/send.js'; + +const PRE = 'ab'.repeat(32); +const HASH = hex.encode(sha256(hex.decode(PRE))); +const asp = { initiate: null, check: null, revoked: 0, checks: 0 }; +mock.module('../src/ark/lightning.js', () => ({ + ...realLn, + initiateLightningPayment: async () => { if (asp.initiate) throw asp.initiate; }, + checkLightningPayment: async (_u, _h, wait) => { asp.checks++; (asp.waits = asp.waits || []).push(!!wait); if (asp.check instanceof Error) throw asp.check; return asp.check; }, + requestLightningPayHtlcRevocation: async () => { asp.revoked++; throw new Error('revocation should not be asked for'); }, +})); +mock.module('../src/ark/proto.js', () => ({ ...realProto, decodeVtxo: () => ({ id: 'htlc1', point: { raw: new Uint8Array(36) } }) })); +mock.module('../src/ark/send.js', () => ({ ...realSend, registerVtxoTransactions: async () => {}, + buildArkoorSend: () => ({}), genUserNonces: () => ({}), cosignPartBytes: () => new Uint8Array(1) })); +const { ArkManager } = await import('../src/ark/manager.js'); +const { GrpcError } = realProto; + +function mgrWith(step) { + const action = { id: 'lnpay-1700000000000', type: 'ln-pay', step, paymentHash: HASH, amountSat: 1, htlcExpiry: 1000, htlcBytesList: ['00'], htlcVtxoIds: ['htlc1'], changeBytesList: [], invoice: 'lnbc1' }; + const m = Object.create(ArkManager.prototype); + const own = { + arkUrl: 'http://ark.test', chain: { tipHeight: async () => 900 }, + state: { actions: [action], vtxos: [{ id: 'htlc1', state: 'pending', amountSat: 1 }], movements: [] }, + _save: () => {}, _lnDriving: new Set(), _spentRetried: new Set(), + _key: () => ({ pubkey: new Uint8Array(33) }), _mailboxKey: () => ({ pubkey: new Uint8Array(33) }), + }; + for (const [k, v] of Object.entries(own)) Object.defineProperty(m, k, { value: v, writable: true, configurable: true }); + return { m, action }; +} + +test("a second wake's initiate refused as 'ln-spent' follows the payment to done", async () => { + Object.assign(asp, { initiate: new GrpcError(3, 'bad user input: vtxo b47f…:0 is not spendable (state: ln-spent)', 'bark_server.ArkService/InitiateLightningPayment'), check: { status: 'success', preimage: PRE }, revoked: 0 }); + const { m, action } = mgrWith('cosigned'); + await m._driveLnPay(action); + expect(action.step).toBe('done'); + expect(action.preimage).toBe(PRE); + expect(asp.revoked).toBe(0); + expect(m.state.movements.filter((x) => x.type === 'ln-send' && x.status === 'complete').length).toBe(1); +}); + +test("a send in 'revoking' that the ASP paid settles, nothing revoked", async () => { + Object.assign(asp, { initiate: null, check: { status: 'success', preimage: PRE }, revoked: 0 }); + const { m, action } = mgrWith('revoking'); + await m._driveLnPay(action); + expect(action.step).toBe('done'); + expect(asp.revoked).toBe(0); +}); + +test("a send in 'revoking' still in flight before its expiry goes back to waiting", async () => { + Object.assign(asp, { initiate: null, check: { status: 'pending' }, revoked: 0 }); + const { m, action } = mgrWith('revoking'); + await m._driveLnPay(action); + expect(action.step).toBe('initiated'); + expect(asp.revoked).toBe(0); +}); + +test("a send the ASP never saw (not found) is still revoked", async () => { + Object.assign(asp, { initiate: null, check: new GrpcError(5, 'not found: payment attempt not found', 'x'), revoked: 0 }); + const { m, action } = mgrWith('revoking'); + await m._driveLnPay(action).catch(() => {}); + expect(asp.revoked).toBe(1); +}); + +// A zap's wait is made of round trips (398ja, 2026-11-10: NWC zaps "a lot more +// slower"). A send just handed to the ASP used to ask its status at once +// ("pending"), fetch the chain tip to go with that, hold a call until it +// settled, then ask once more for the preimage it had just been given: four +// round trips where the one held call does. +test('a fresh send: initiate, then the one held call settles it', async () => { + Object.assign(asp, { initiate: null, check: { status: 'success', preimage: PRE }, revoked: 0, checks: 0, waits: [] }); + const { m, action } = mgrWith('cosigned'); + let tips = 0; + m.chain = { tipHeight: async () => { tips++; return 900; } }; + await m._driveLnPay(action); + expect(action.step).toBe('initiated'); // handed over, nothing asked yet + expect(asp.checks).toBe(0); + expect(tips).toBe(0); + const a = await m.awaitLnPay(action.id, 5000); + expect(a.step).toBe('done'); + expect(a.preimage).toBe(PRE); + expect(asp.waits).toEqual([true]); // the held call alone + expect(m.state.movements.filter((x) => x.type === 'ln-send' && x.status === 'complete').length).toBe(1); +}); + +test('a held call that comes back failed is checked the ordinary way (and revoked)', async () => { + Object.assign(asp, { initiate: null, check: { status: 'failed' }, revoked: 0, checks: 0, waits: [] }); + const { m, action } = mgrWith('initiated'); + m._revokeLnPay = async (x) => { x.step = 'failed'; }; + const a = await m.awaitLnPay(action.id, 5000); + expect(asp.waits[0]).toBe(true); + expect(a.step).toBe('failed'); +}); + +test('a held call with a preimage that does not match the hash settles nothing', async () => { + Object.assign(asp, { initiate: null, check: { status: 'success', preimage: 'cd'.repeat(32) }, revoked: 0, checks: 0, waits: [] }); + const { m, action } = mgrWith('initiated'); + const a = await m.awaitLnPay(action.id, 5000); + expect(a.step).not.toBe('done'); +}); diff --git a/tools/lnurl-withdraw-links-test.js b/tools/lnurl-withdraw-links-test.js new file mode 100644 index 0000000..b349e17 --- /dev/null +++ b/tools/lnurl-withdraw-links-test.js @@ -0,0 +1,166 @@ +// Prepaid withdraw links (src/features/withdraw-links.js + coinosv3-server +// names/withdraw.js): create one from Send (it pays the registrar up front), +// get an LNURL QR, have it claimed — here by our own LNURL-withdraw redeem +// flow (zaps.js), the same road any Lightning wallet takes — and take back +// what's left. The registrar is the real withdraw service behind a stand-in +// HTTP server (NIP-98 checked) with a fake Lightning node; the Ark side is +// faked through the hooks. Run: bun tools/lnurl-withdraw-links-test.js +import assert from 'node:assert/strict'; +import { verifyEvent } from 'nostr-tools/pure'; +import puppeteer from './puppeteer.js'; +import { withdrawService } from '../../coinosv3-server/names/withdraw.js'; + +const app = await Bun.file('src/app.js').text(); +const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { withdrawLinks } from './src/features/withdraw-links.js'; +import { zapsFeature } from './src/features/zaps.js'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +${domHelper} +const sk = generateSecretKey(); +const ui = { screen: 'wallet', tab: 'send', giftMode: false, send: { recipients: [{ address: '', amount: '' }] } }; +let lnInvoices = 0; const watched = []; +const wallet = { watchOnly: false, nostrSign: async (e) => finalizeEvent(e, sk), loadFeatureState: (k, d) => d, saveFeatureState: () => {} }; +const api = async (p) => (await fetch(p)).json(); +const ctx = { + h, ui, wallet, render, toast: (m) => (window.toasts = (window.toasts || [])).push(m), + withdrawRegistrar: location.origin, + copyBtn: (v, label) => h('button', { class: 'btn-sm', onClick: () => { window.copied = v; } }, label), + goBack: (fn) => { fn(); render(); }, + blankSend: () => ({ recipients: [{ address: '', amount: '' }] }), + fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => h('span', { class: 'unit-tag' }, 'sats'), + parseAmount: (v) => { const n = Number(String(v).replace(/,/g, '')); return isFinite(n) && n > 0 ? Math.round(n) : null; }, getUnit: () => 'sats', + amountField: (sat) => String(sat), + hook(name, ...a) { + if (name === 'arkReady' || name === 'canLnPay') return true; + if (name === 'arkSpendableSat') return window.spendable ?? 100000; + if (name === 'arkPayInvoice') { window.paidInvoices = (window.paidInvoices || 0) + 1; return api('/__pay?bolt11=' + a[0]); } + // a coinos wallet's Lightning receive lives on the registrar's own node + if (name === 'arkLnInvoice') { lnInvoices++; return api('/__mkinv?own=1&sat=' + a[0]).then((r) => ({ id: 'ln' + lnInvoices, invoice: r.bolt11, amountSat: a[0] })); } + if (name === 'arkLnWatch') { watched.push(a[0]); return true; } + if (name === 'arkLnUnwatch' || name === 'arkLnCancel') return true; + return null; + }, +}; +const wl = withdrawLinks(ctx); +const zaps = zapsFeature(ctx); +function render() { + const v = (ui.giftMode === 'lnurl' && wl.view()) || zaps.sendView() || h('div', { id: 'closed' }, wl.entry()); + document.querySelector('#app').replaceChildren(v); +} +window.test = { ui, render, pk: getPublicKey(sk), redeem: (s) => zaps.matchSendText(s, false), watched }; +render(); +`; +const bundle = await Bun.build({ + entrypoints: ['wl-test-entry'], target: 'browser', + plugins: [{ name: 'wl-test', setup(build) { + build.onResolve({ filter: /^wl-test-entry$/ }, () => ({ path: 'entry', namespace: 'wl-test' })); + build.onLoad({ filter: /.*/, namespace: 'wl-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + } }], +}); +assert(bundle.success, bundle.logs.join('\n')); +const css = await Bun.file('src/style.css').text(); +const js = await bundle.outputs[0].text(); +const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}</style><div id="app"></div><script type="module">${js}</script>`; + +// ---- the stand-in registrar: the real service, a fake node ---- +const OUR = '02' + 'aa'.repeat(32), THEIRS = '03' + 'bb'.repeat(32); +const invoices = {}, decoded = {}, outbound = {}; +let seq = 0, circular = 0; +const mk = (sat, payee) => { const hash = (++seq).toString(16).padStart(64, '0'); const b = 'lnbc' + sat + 'n1fake' + seq; decoded[b] = { valid: true, payment_hash: hash, amount_msat: sat * 1000, payee, created_at: Math.floor(Date.now() / 1000), expiry: 3600 }; return { b, hash }; }; +const ln = { async call(m, p) { + if (m === 'invoice') { const { b, hash } = mk(p.amount_msat / 1000, OUR); invoices[hash] = { status: 'unpaid', bolt11: b }; return { bolt11: b, payment_hash: hash }; } + if (m === 'listinvoices') return { invoices: invoices[p.payment_hash] ? [invoices[p.payment_hash]] : [] }; + if (m === 'decode') { if (!decoded[p.string]) throw new Error('bad'); return decoded[p.string]; } + if (m === 'pay') { const d = decoded[p.bolt11]; outbound[d.payment_hash] = 'complete'; return { status: 'complete', amount_msat: d.amount_msat, amount_sent_msat: d.amount_msat }; } + if (m === 'waitsendpay') { outbound[p.payment_hash] = 'complete'; return { status: 'complete' }; } + if (m === 'listsendpays') return { payments: outbound[p.payment_hash] ? [{ status: outbound[p.payment_hash] }] : [] }; + throw new Error('unexpected ' + m); +} }; +const state = {}; +let svc; // made once the server has its port: the service's URLs are built on it +const nip98 = async (req, url, body) => { + const evt = JSON.parse(atob((req.headers.get('authorization') || '').replace(/^Nostr /, ''))); + assert(verifyEvent(evt) && evt.kind === 27235 && new URL(evt.tags.find((t) => t[0] === 'u')[1]).pathname === url.pathname); + return evt.pubkey; +}; +const server = Bun.serve({ port: 0, async fetch(req) { + const u = new URL(req.url); + const j = (b, s = 200) => Response.json(b, { status: s }); + if (u.pathname === '/__pay') { const d = decoded[u.searchParams.get('bolt11')]; invoices[d.payment_hash].status = 'paid'; return j({ ok: true }); } + if (u.pathname === '/__mkinv') { const { b } = mk(Number(u.searchParams.get('sat')), u.searchParams.get('own') ? OUR : THEIRS); return j({ bolt11: b }); } + const w = u.pathname.match(/^\/w\/([A-Za-z0-9_-]+)(\/cb)?$/); + if (w) return j(w[2] ? await svc.callback(w[1], u.searchParams.get('k1'), u.searchParams.get('pr')) : svc.params(w[1])); + if (u.pathname.startsWith('/withdraw/')) { + const body = req.method === 'POST' ? await req.text() : ''; + const pk = await nip98(req, u, body); + const b = body ? JSON.parse(body) : {}; + try { + if (u.pathname === '/withdraw/mine') return j({ links: svc.mine(pk) }); + if (u.pathname === '/withdraw/new') return j(await svc.create({ pubkey: pk, ...b })); + if (u.pathname === '/withdraw/funded') return j(await svc.confirmFunded(b.id, pk)); + if (u.pathname === '/withdraw/reclaim') return j(await svc.reclaim(b.id, pk, b.invoice)); + } catch (e) { return j({ error: e.message }, e.status || 500); } + } + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +svc = withdrawService({ state, persist: () => {}, lnFor: () => ln, ourNodeId: async () => OUR, + circularPick: async () => ({ cap: 1e9 }), payCircular: async () => { circular++; return { feeMsat: 0 }; }, publicBase: server.url.origin.replace(/\/$/, '') }); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +const pause = (ms) => new Promise((r) => setTimeout(r, ms)); +const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); }; +const text = () => page.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +const waitText = async (re, ms = 6000) => { for (let i = 0; i < ms / 100; i++) { if (re.test(await text())) return true; await pause(100); } return false; }; +const click = (label) => page.evaluate((x) => { const b = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().startsWith(x)); if (!b) return false; b.click(); return true; }, label); +const type = (sel, v) => page.evaluate(([s, val]) => { const i = document.querySelector(s); i.value = val; i.dispatchEvent(new Event('input', { bubbles: true })); }, [sel, v]); +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href); + await page.waitForSelector('#wl-open'); + console.log('[making a link]'); + await page.click('#wl-open'); + check('Send opens the withdraw-link page', await waitText(/A Lightning link or QR code that anyone can withdraw sats from/)); + await page.click('#wl-create'); + check('no amount: asked for one, nothing paid', await waitText(/Enter an amount/) && !(await page.evaluate(() => window.paidInvoices))); + await type('#wl-amount', '500'); await type('#wl-uses', '3'); + check('the button and the line say what gets paid now', /Create link for 1,500 sats/.test(await text()) && /1,500 sats paid now from Spending: 3 withdrawals of up to 500 sats/.test(await text()), await text()); + await page.evaluate(() => { window.spendable = 1000; }); + await page.click('#wl-create'); + check('more than Spending holds: refused before paying', await waitText(/Not enough in Spending for 1,500 sats/) && !(await page.evaluate(() => window.paidInvoices))); + await page.evaluate(() => { window.spendable = 100000; }); + await type('textarea', 'Meetup door prize'); + await page.click('#wl-create'); + check('it pays the registrar once and shows the link', await waitText(/Your withdraw link is ready/) && (await page.evaluate(() => window.paidInvoices)) === 1, await text()); + check('…with a QR, the terms and the note', !!(await page.$('svg')) && /Up to 500 sats each, 3 of 3 withdrawals left/.test(await text()) && /Meetup door prize/.test(await text()), await text()); + const lnurl = await page.evaluate(() => [...document.querySelectorAll('.addr-box')].map((e) => e.textContent.trim())[0]); + check('the link is an LNURL', /^LNURL1[0-9A-Z]+$/.test(lnurl), lnurl); + const link = Object.values(state.withdraws)[0]; + check('the registrar holds it funded', link.status === 'open' && link.totalSat === 1500 && link.pubkey === (await page.evaluate(() => test.pk))); + + console.log('\n[claiming it with a Lightning wallet: ours]'); + await click('Back'); + await page.evaluate(() => { test.ui.giftMode = false; }); + check('the redeem flow takes the LNURL', await page.evaluate((s) => test.redeem(s), lnurl)); + check('…and reads the link\'s terms', await waitText(/will pay you between 1 and 500 sats/) && /Meetup door prize/.test(await text()), await text()); + await click('Withdraw'); + check('the claim is paid out of the link', await waitText(/has your invoice for 500 sats/) && (await (async () => { await pause(100); return link.claims[0]?.status === 'paid'; })()), JSON.stringify(link.claims)); + check('…round the circular route (a coinos wallet\'s own invoice)', circular === 1); + + console.log('\n[taking back the rest]'); + await page.evaluate(() => { test.ui.lnWithdraw = null; test.ui.giftMode = false; test.render(); }); + await page.evaluate(() => document.querySelector('#wl-open').click()); + check('the list shows what is left', await waitText(/Your withdraw links/) && /1,000 sats left/.test(await text()), await text()); + await page.evaluate(() => document.querySelector('.wl-row').click()); + check('the link page shows 2 of 3 left', await waitText(/2 of 3 withdrawals left/)); + await click('Take back 1,000 sats'); + check('take back pays the remainder to the wallet and closes the link', await waitText(/This link is closed/) && link.status === 'closed' && link.spentSat === 1500, JSON.stringify(link)); + check('…and the wallet watched for that payment', (await page.evaluate(() => test.watched.length)) >= 1); + check('…the toast says so', (await page.evaluate(() => window.toasts || [])).some((m) => /1,000 sats is back in Spending/.test(m))); + check('a closed link refuses claims', svc.params(link.id).status === 'ERROR'); + check('no page errors', errors.length === 0, errors.join('; ')); + console.log('\n✅ prepaid withdraw links'); +} catch (e) { console.error('\n❌', e.message); process.exitCode = 1; } finally { await browser.close(); server.stop(true); } diff --git a/tools/lnurl-withdraw-test.js b/tools/lnurl-withdraw-test.js index cc1b152..0241f31 100644 --- a/tools/lnurl-withdraw-test.js +++ b/tools/lnurl-withdraw-test.js @@ -22,7 +22,7 @@ const ctx = { parseAmount: (v) => { const n = Number(String(v).replace(/,/g, '')); return isFinite(n) ? Math.round(n) : null; }, getUnit: () => 'sats', amountField: (sat) => String(sat), hook(name, ...a) { - if (name === 'canLnPay') return true; + if (name === 'canLnPay') return !window.noPay; if (name === 'arkLnInvoice') { invoices++; return Promise.resolve({ id: 'ln' + invoices, invoice: 'lnbc' + a[0] + 'n1testinvoice' + invoices, amountSat: a[0] }); } if (name === 'arkLnWatch') { watch = { id: a[0], cb: a[1] }; return true; } if (name === 'arkLnUnwatch') { if (watch && watch.id === a[0]) watch = null; return true; } @@ -94,6 +94,13 @@ try { await click('Back'); await pause(50); check('Back before it is paid stops watching', (await page.evaluate(() => test.watch)) === null && !!(await page.$('#closed'))); + console.log('[scanned while Spending is still connecting]'); + await page.evaluate(() => { window.noPay = true; }); + check('a withdraw lnurl is still taken (it pays us; nothing to spend)', await page.evaluate((s) => test.paste(s), lnurl('/withdraw'))); + check('…and opens as a withdraw, not a zap', await waitText(/Withdraw over Lightning/) && !/Zap/.test(await text()), await text()); + await click('Back'); await pause(50); + await page.evaluate(() => { window.noPay = false; }); + console.log('[a site that refuses]'); await page.evaluate((s) => test.paste(s), lnurl('/withdraw-spent')); await waitText(/will pay you 1 sats/); await click('Withdraw'); diff --git a/tools/media-grid-dups-test.js b/tools/media-grid-dups-test.js index e7b67da..1a00222 100644 --- a/tools/media-grid-dups-test.js +++ b/tools/media-grid-dups-test.js @@ -5,7 +5,7 @@ // that won't load leaves the Images grid instead of a broken-picture glyph. And on a big screen with a mouse, a // small clip opened in the pager is drawn at most 1.5× its own pixels, not // blown up across the monitor. Run: bun tools/media-grid-dups-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -92,9 +92,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/media-grid-order-test.js b/tools/media-grid-order-test.js index 9274d92..0e333f0 100644 --- a/tools/media-grid-order-test.js +++ b/tools/media-grid-order-test.js @@ -3,7 +3,7 @@ // jumped around): tiles found later go under the ones already showing, in // their own date order, even when they are dated between them; only a post // newer than the top tile goes above it. Run: bun tools/media-grid-order-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -97,9 +97,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const key = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk]) => { diff --git a/tools/media-moments-feed-test.js b/tools/media-moments-feed-test.js index a188c5d..5b4c8fb 100644 --- a/tools/media-moments-feed-test.js +++ b/tools/media-moments-feed-test.js @@ -1,6 +1,6 @@ // Full feed integration with local media and relay acknowledgements; no events leave the test. // Run: bun tools/media-moments-feed-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -111,9 +111,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const key = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { @@ -175,6 +175,18 @@ try { check('...and shows over the pager\'s seek bar', await page.waitForFunction(() => !!document.querySelector('.media-pager .mp-cur .mp-seek .mp-mark'), { timeout: 3000 }).then(() => true, () => false)); check('no timeline panel over the pager', await page.evaluate(() => !document.querySelector('.media-pager .media-timeline'))); await page.screenshot({ path: '/tmp/media-timeline-pager.png' }); + // back in the feed: the clip has a moment now, so the moments bar is its + // only seek bar (no player controls), with the middle button to play it + await page.keyboard.press('Escape'); await sleep(500); + await page.evaluate(() => { const v = document.querySelector('video.note-video'); v.pause(); v.dispatchEvent(new Event('timeupdate')); }); + await sleep(200); + const one = await page.evaluate(() => { const v = document.querySelector('video.note-video'); const b = v.closest('.note-video-box'); const tl = b.querySelector('.media-timeline'); + return { controls: v.controls, own: b.classList.contains('own-bar'), bar: !!tl && getComputedStyle(tl).display !== 'none', mark: !!b.querySelector('.media-mark'), pp: getComputedStyle(b.querySelector('.vid-pp')).display }; }); + check('in the feed, a clip with a moment has one seek bar: the moments bar, no player controls', !one.controls && one.own && one.bar && one.mark, JSON.stringify(one)); + check('...and the middle button plays it, on a touch screen too', one.pp === 'flex', JSON.stringify(one)); + const vid2 = await page.evaluate(() => { const b = document.querySelector('.note-video-box .vid-pp').getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; }); + await page.mouse.click(vid2.x, vid2.y); await sleep(600); + check('...which plays it in place', await page.evaluate(() => !document.querySelector('video.note-video').paused && !document.querySelector('.media-pager'))); check('no browser exceptions', errs.length === 0, errs.join(' | ')); await page.screenshot({ path: '/tmp/media-timeline-feed.png' }); } finally { await browser.close(); server.stop(); } diff --git a/tools/mention-ac-test.js b/tools/mention-ac-test.js index 52ba1ef..e476e61 100644 --- a/tools/mention-ac-test.js +++ b/tools/mention-ac-test.js @@ -2,7 +2,7 @@ // people — the ones you follow first, then the search — and picking one puts // nostr:npub… in the text; the post goes out with their p tag. // Run: bun tools/mention-ac-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import { npubEncode } from 'nostr-tools/nip19'; @@ -59,9 +59,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); await sleep(3000); // a new wallet's starter follows land first; ours go over them const base = cacheKeyFor(mn + '\n'); diff --git a/tools/moderation-test.js b/tools/moderation-test.js index 426115c..8856864 100644 --- a/tools/moderation-test.js +++ b/tools/moderation-test.js @@ -5,7 +5,7 @@ // are hidden; Settings → Nostr lays the mute list out and edits it. // No relay is written for real — the WebSocket shim swallows EVENT. // Run: bun tools/moderation-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -114,9 +114,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const ME_SK = privateKeyFromSeedWords(mn); diff --git a/tools/names-listening-test.js b/tools/names-listening-test.js new file mode 100644 index 0000000..acb84e2 --- /dev/null +++ b/tools/names-listening-test.js @@ -0,0 +1,70 @@ +// A wallet on another ark server tells the registrar it is listening once +// its offer listener is up, so Lightning receives queued for its name go +// out now instead of on the hourly retry. A wallet on the coinos server +// never does (it is paid while asleep). Run: bun tools/names-listening-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; + +const app = await Bun.file('src/app.js').text(); +const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { namesFeature } from './src/features/names.js'; +import { setArkProviderId, getArkProviderId, getNetwork } from './src/api.js'; +${domHelper} +const state = { names: { byDomain: { 'coinos.io': { name: 'manna', domain: 'coinos.io' } } } }; +const ui = { screen: 'wallet' }; +const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = v; }, nostrSign: async (e) => ({ ...e, id: 'ab'.repeat(32), sig: 'cd'.repeat(64), pubkey: 'aa'.repeat(32) }), nostrPubkey: () => 'aa'.repeat(32), _cacheKey: () => 'w1' }; +const ctx = { h, ui, wallet, render: () => {}, toast: () => {}, copy: () => {}, copyBtn: () => null, brandHeader: () => null, goBack: () => {}, setAccount: () => {}, hook: (name) => (name === 'arkReady' ? true : null) }; +const feature = namesFeature(ctx); +window.test = { listening: () => feature.namesListening(), server: (id) => setArkProviderId(id), dbg: () => ({ net: getNetwork(), prov: getArkProviderId(), avail: feature.namesAvailable() }) }; +`; +const bundle = await Bun.build({ + entrypoints: ['names-listen-entry'], target: 'browser', + plugins: [{ name: 'names-listen', setup(build) { + build.onResolve({ filter: /^names-listen-entry$/ }, () => ({ path: 'entry', namespace: 'names-listen' })); + build.onLoad({ filter: /.*/, namespace: 'names-listen' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + } }], +}); +assert(bundle.success, bundle.logs.join('\n')); +const html = `<!doctype html><meta charset="utf-8"><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +// the registrar, faked: every call recorded, answered as told +const calls = []; +let answer = { status: 200, body: '{"waiting":1,"sat":52}' }; +await page.setRequestInterception(true); +page.on('request', (r) => { + if (!r.url().startsWith('https://names.coinos.io/')) return r.continue(); + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*', 'access-control-allow-methods': '*' }; + if (r.method() === 'OPTIONS') return r.respond({ status: 204, headers: cors }); + calls.push({ method: r.method(), path: new URL(r.url()).pathname, auth: r.headers().authorization || '' }); + r.respond({ ...answer, contentType: 'application/json', headers: cors }); +}); +const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); }; +const listening = () => page.evaluate(() => test.listening()); +try { + await page.goto(server.url.href); + await page.waitForFunction(() => !!window.test); + check('a wallet on the coinos server says nothing', (await listening()) === null && !calls.length); + await page.evaluate(() => test.server('second')); + const r = await listening(); + const posts = () => calls.filter((c) => c.method === 'POST'); + check('on Second it tells the registrar it is listening', posts().length === 1 && posts()[0].path === '/listening', JSON.stringify(calls)); + check('...as itself (NIP-98)', /^Nostr /.test(posts()[0].auth)); + check('...and hears what is waiting', r && r.waiting === 1 && r.sat === 52, JSON.stringify(r)); + check('a second listen within ten minutes is not another call', (await listening()) === null && posts().length === 1); + await page.reload(); await page.waitForFunction(() => !!window.test); + answer = { status: 500, body: '{"error":"down"}' }; + const down = await listening(); + check('a registrar that is down is quietly left', down === null && posts().length === 2, JSON.stringify({ down, calls })); + answer = { status: 200, body: '{"waiting":0,"sat":0}' }; + check('...and asked again next time, not in ten minutes', (await listening())?.waiting === 0 && posts().length === 3); + check('no page errors', !errors.length, errors.join(' | ')); + console.log('\n✅ names: a wallet on another ark server says it is listening'); +} finally { + await browser.close(); + server.stop(true); +} diff --git a/tools/negentropy-sync-test.js b/tools/negentropy-sync-test.js index e0257ca..c7adb84 100644 --- a/tools/negentropy-sync-test.js +++ b/tools/negentropy-sync-test.js @@ -8,7 +8,7 @@ // backlog, and is remembered as such for a day: the next ask there goes // straight out as a REQ. // Run: bun tools/negentropy-sync-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; import { buildHtml } from '../build.js'; @@ -48,9 +48,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const ME = getPublicKey(privateKeyFromSeedWords(mn)); const MINE = finalizeEvent({ kind: 1, created_at: now - 600, tags: [], content: 'my post, replied to' }, FRIEND.sk); diff --git a/tools/nip29-group-test.js b/tools/nip29-group-test.js new file mode 100644 index 0000000..2ff1192 --- /dev/null +++ b/tools/nip29-group-test.js @@ -0,0 +1,102 @@ +// NIP-29 relay groups in Messages: New group → Relay group → paste an +// address → Join. Reads the real "pyramid runners" group off +// pyramid.fiatjaf.com (relays: 'read' — nothing published leaves the page: +// the join request, the kind 10009 list and the test message are answered +// OK in place and checked here). +// Run: bun tools/nip29-group-test.js +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const ADDR = process.env.GROUP || "https://chachi.chat/pyramid.fiatjaf.com/Tnq7x2ZTgrPZWFrC"; +const GID = 'Tnq7x2ZTgrPZWFrC'; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('console', (m) => { if (/nip29/.test(m.text())) console.log(' [page]', m.text()); }); +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === x); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const published = () => page.evaluate(() => window.__relayGuard.events); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await page.evaluate(() => [...document.querySelectorAll('button')].find((n) => /create a new wallet/i.test(n.textContent))?.click()); await sleep(600); + await seedWords(page); + await skipSeedCheck(page); + await waitText('receive', 20000); + await page.click('[aria-label="Messages"]'); + await waitText('new group', 10000); + await click('new group'); await sleep(300); + check('New group offers a relay group', await click('relay group')); + await sleep(200); + await page.type('.nip29-address', 'not an address'); + await click('join'); await sleep(500); + check('a bad address is refused', await waitText("isn't a group address", 3000)); + await page.evaluate(() => { const i = document.querySelector('.nip29-address'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); }); + await page.type('.nip29-address', ADDR); + await click('join'); + check('the group opens, named by its relay', await waitText('pyramid runners', 20000)); + check('its messages arrive', await waitText('paff.club', 20000)); + const evs = await published(); + const join = evs.find((e) => e.kind === 9021); + check('a join request went to the group', !!join && join.tags.some((x) => x[0] === 'h' && x[1] === GID)); + // the 10009 write reads the newest list first, everywhere (a first write) + let list = null; + for (let i = 0; i < 80 && !list; i++) { list = (await published()).filter((e) => e.kind === 10009).pop(); if (!list) await sleep(250); } + check('the group is added to the kind 10009 list', !!list && list.tags.some((x) => x[0] === 'group' && x[1] === GID && /pyramid\.fiatjaf\.com/.test(x[2])), JSON.stringify(list?.tags)); + + // say something (answered OK in place, never sent) + await page.click('#msg-draft'); + await page.type('#msg-draft', 'hello from the nip29 test'); + await page.keyboard.press('Enter'); + await sleep(1500); + const msg = (await published()).find((e) => e.kind === 9 && e.content === 'hello from the nip29 test'); + check('a message is a kind 9 with the group h tag', !!msg && msg.tags.some((x) => x[0] === 'h' && x[1] === GID), JSON.stringify(msg?.tags)); + check('...and previous tags from the timeline', !!msg && msg.tags.some((x) => x[0] === 'previous' && /^[0-9a-f]{8}$/.test(x[1]))); + check('it shows in the group', await waitText('hello from the nip29 test', 3000)); + + // members: the relay's list + await page.evaluate(() => document.querySelector('.chat-head .clickable')?.click()); await sleep(800); + check('the member panel says it is a relay group', await waitText('not encrypted', 3000)); + const n = await page.evaluate(() => document.querySelectorAll('.chat-members .chat-thread-row').length); + check('members come from the relay', n > 10, n + ' members'); + + // back home: a row of its own; reload keeps it + await page.evaluate(() => document.querySelector('.chat-head button')?.click()); await sleep(600); + check('the group is listed in Messages', await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners')))); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.click('[aria-label="Messages"]'); + let kept = false; + for (let i = 0; i < 40 && !kept; i++) { kept = await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners'))); if (!kept) await sleep(250); } + check('...and still is after a reload', kept); + + // leave: two taps, a 9022 to the relay, and off the list + await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => r.textContent.includes('pyramid runners'))?.click()); await sleep(800); + await page.click('[aria-label="Leave group"]'); await sleep(200); + await page.click('[aria-label="Leave group"]'); + let left = null, relist = null; + for (let i = 0; i < 80 && !(left && relist); i++) { + const evs = await published(); + left = evs.find((e) => e.kind === 9022); + relist = evs.filter((e) => e.kind === 10009).find((e) => !e.tags.some((x) => x[0] === 'group')); + if (!(left && relist)) await sleep(250); + } + check('leaving tells the relay (9022)', !!left && left.tags.some((x) => x[0] === 'h' && x[1] === GID)); + check('...and takes it off the 10009 list', !!relist); + check('...and out of Messages', !(await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners'))))); + check('no page errors', !errs.length, errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { + await browser.close(); server.stop(); +} +console.log(ok ? '\nPASS' : '\nFAIL'); +process.exit(ok ? 0 : 1); diff --git a/tools/notif-app-zap-test.js b/tools/notif-app-zap-test.js new file mode 100644 index 0000000..3ee2030 --- /dev/null +++ b/tools/notif-app-zap-test.js @@ -0,0 +1,109 @@ +// Notifications: a zap on your Zapstore app listing (kind 32267) reads +// "zapped your app PAFF" and opens the app's Zapstore page. It opened the +// zapper's profile: Zapstore's receipt carries no a tag (only the zap request +// inside it does), so the row looked like a zap on nothing (Adam, 2026-11-07: +// "clicking this franzap zap notification keeps bringing me to his profile"). +// A row saved before reads the receipt once on tap and goes there too. +// Run: bun tools/notif-app-zap-test.js +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const A = generateSecretKey(), B = generateSecretKey(); +const now = Math.floor(Date.now() / 1000); +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5317, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.endsWith('.png')) return new Response(PNG, { headers: { 'content-type': 'image/png' } }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5317/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await seedWords(page); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await skipSeedCheck(page); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + const ME = getPublicKey(privateKeyFromSeedWords(mn)); + const APP = '32267:' + 'c'.repeat(64) + ':club.paff.app'; + const zap = (sk, sats, ts, content) => { + const req = finalizeEvent({ kind: 9734, created_at: ts, content, tags: [['p', ME], ['amount', String(sats * 1000)], ['a', APP]] }, sk); + // the receipt Zapstore's LNURL server publishes: p and P, no a tag + return finalizeEvent({ kind: 9735, created_at: ts, content: '', tags: [['p', ME], ['P', req.pubkey], ['amount', String(sats * 1000)], ['description', JSON.stringify(req)]] }, generateSecretKey()); + }; + const OLD = zap(A, 10000, now - 120, 'Love it'); // saved by the old code, without the app + const NEW = zap(B, 2100, now - 60, 'great app'); + const LISTING = finalizeEvent({ kind: 32267, created_at: now - 3600, content: '', tags: [['d', 'club.paff.app'], ['name', 'PAFF']] }, generateSecretKey()); + await page.evaluateOnNewDocument((evs, listing) => { + window.__opened = []; + window.open = (u) => { window.__opened.push(u); return null; }; + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const fs = m.slice(2); + const hit = [ + // the notifications sync only hands the NEW one; the old row is already held + ...evs.slice(1).filter(() => fs.some((f) => (f.kinds || []).includes(9735) && (f['#p'] || []).length)), + ...evs.filter((ev) => fs.some((f) => (f.ids || []).includes(ev.id))), + ...(fs.some((f) => (f.kinds || []).includes(32267)) ? [listing] : []), + ]; + setTimeout(() => { + for (const e of hit) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })); + }, 0); + return; + } + if (m[0] === 'EVENT' || m[0] === 'NEG-OPEN') return; + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); + }, [OLD, NEW], LISTING); + await page.evaluate(([k, me, old]) => { + const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}'); + st.notifs = [{ id: old.id, what: 'zap', actor: JSON.parse(old.tags.find((t) => t[0] === 'description')[1]).pubkey, target: null, stream: null, hint: [], sats: 10000, text: 'Love it', ts: old.created_at }]; + st.notifsPk = me; st.notifsV = 2; + localStorage.setItem(k + ':messages', JSON.stringify(st)); + }, [base, ME, OLD]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Notifications"]')?.click()); + console.log('\n[a zap on your app]'); + check('a new one reads "zapped your app PAFF"', await waitText('zapped your app paff 2.1k sats', 15000), + await page.evaluate(() => [...document.querySelectorAll('.alert-row')].map((r) => r.innerText.replace(/\s+/g, ' ')).join(' | '))); + await page.evaluate(() => [...document.querySelectorAll('.alert-row')].find((r) => /great app/.test(r.innerText))?.click()); + await sleep(300); + check('...and opens its Zapstore page', (await page.evaluate(() => window.__opened)).includes('https://zapstore.dev/apps/club.paff.app'), JSON.stringify(await page.evaluate(() => window.__opened))); + await page.evaluate(() => { window.__opened = []; [...document.querySelectorAll('.alert-row')].find((r) => /Love it/.test(r.innerText))?.click(); }); + for (let i = 0; i < 40 && !(await page.evaluate(() => window.__opened.length)); i++) await sleep(250); + check('a row saved before reads its receipt on tap and opens the app too', (await page.evaluate(() => window.__opened)).includes('https://zapstore.dev/apps/club.paff.app'), JSON.stringify(await page.evaluate(() => window.__opened))); + check('...and is labelled as an app zap from then on', await waitText('zapped your app paff 10k sats', 5000)); + check('still in Notifications, not a profile', await page.evaluate(() => !!document.querySelector('.alert-row'))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ zaps on your app open the app' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/notif-categories-test.js b/tools/notif-categories-test.js index f38dd01..8bd0da1 100644 --- a/tools/notif-categories-test.js +++ b/tools/notif-categories-test.js @@ -1,7 +1,7 @@ // Settings → Notifications lists zaps and reactions as their own categories, // each with its own switch. Run: bun tools/notif-categories-test.js import assert from 'node:assert/strict'; -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { buildHtml } from '../build.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const html = await buildHtml({ minify: true, pwa: false }); @@ -19,7 +19,7 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload(); await sleep(500); await click('create a new wallet'); await sleep(600); - await click('skip verification'); await sleep(300); + await skipSeedCheck(page); await sleep(300); await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive'), { timeout: 20000 }); await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(600); await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /notification/i.test(e.textContent)).click()); await sleep(600); diff --git a/tools/notif-custom-emoji-test.js b/tools/notif-custom-emoji-test.js index 5847a0c..b1ff564 100644 --- a/tools/notif-custom-emoji-test.js +++ b/tools/notif-custom-emoji-test.js @@ -3,7 +3,7 @@ // :peepoClap: text, because the row kept the content and not the tag. // A row saved before that is healed when the relay hands the reaction again. // Run: bun tools/notif-custom-emoji-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -34,9 +34,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const ME = getPublicKey(privateKeyFromSeedWords(mn)); diff --git a/tools/notif-faces-test.js b/tools/notif-faces-test.js index a1c1c8f..0aa7a92 100644 --- a/tools/notif-faces-test.js +++ b/tools/notif-faces-test.js @@ -3,7 +3,7 @@ // opens with faces, not circles filling in. The stub relay answers the kind-0 // batch for the actors with pictures served by this test. // Run: bun tools/notif-faces-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -65,9 +65,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const ME = getPublicKey(privateKeyFromSeedWords(mn)); diff --git a/tools/notif-note-cache-test.js b/tools/notif-note-cache-test.js index e24c2e2..2b8be67 100644 --- a/tools/notif-note-cache-test.js +++ b/tools/notif-note-cache-test.js @@ -1,7 +1,7 @@ // Referenced notification notes are fetched before the page opens, persisted, // and available on the first paint after a reload. // Run: bun tools/notif-note-cache-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -84,9 +84,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await clickText('create a new wallet'); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mnemonic = (await page.$$eval('.words .w .t', (nodes) => nodes.map((node) => node.textContent.trim()))).join(' '); - await clickText('skip verification'); + await skipSeedCheck(page); await waitText('receive'); const base = cacheKeyFor(mnemonic + '\n'); const me = getPublicKey(privateKeyFromSeedWords(mnemonic)); diff --git a/tools/notif-report-fold-test.js b/tools/notif-report-fold-test.js index 678aefd..6a906ec 100644 --- a/tools/notif-report-fold-test.js +++ b/tools/notif-report-fold-test.js @@ -4,7 +4,7 @@ // (Adam's phone, 2026-10-14: a reply Amethyst showed was missing here). A // person one follow flagged, or a stranger nobody did, shows as ever. // Run: bun tools/notif-report-fold-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -36,9 +36,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const ME = getPublicKey(privateKeyFromSeedWords(mn)); diff --git a/tools/notif-spam-score.js b/tools/notif-spam-score.js new file mode 100644 index 0000000..c9e48ba --- /dev/null +++ b/tools/notif-spam-score.js @@ -0,0 +1,82 @@ +// Scores every notification the wallet can show against Chrome's on-device +// "unwanted notification" model — the one that replaces a web notification +// with "Possible spam" on Android when its title + body score above 90. +// +// Not part of the test suite: it launches a throwaway headful-less Chrome +// (/usr/bin/google-chrome) that fetches the model from Google's optimization +// guide on first use (~10 s, network), shows each line from src/notify-text.js +// SAMPLES as a persistent notification on a local page, and reads the score +// off chrome://histograms (SafeBrowsing.NotificationContentDetection +// .SuspiciousScore, one new sample per notification). Desktop Chrome runs the +// model too, flag or no flag; SystemNotifications is off so nothing reaches +// the desktop's notification tray. Exit 1 if any line scores 90 or more. +// +// bun tools/notif-spam-score.js # SAMPLES from src/notify-text.js +// bun tools/notif-spam-score.js "Title" "Body" ["Title" "Body" ...] +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import puppeteer from 'puppeteer-core'; +import { SAMPLES } from '../src/notify-text.js'; + +const CHROME = process.env.CHROME || '/usr/bin/google-chrome'; +const PORT = 8766, DEBUG = 9445, THRESHOLD = 90; +const argv = process.argv.slice(2); +const items = argv.length ? argv.reduce((a, v, i) => (i % 2 ? a[a.length - 1].push(v) : a.push([v]), a), []) : SAMPLES; + +const server = Bun.serve({ port: PORT, hostname: '127.0.0.1', fetch(req) { + const p = new URL(req.url).pathname; + if (p === '/sw.js') return new Response("self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));", { headers: { 'content-type': 'text/javascript' } }); + return new Response(`<!doctype html><title>score</title><script>navigator.serviceWorker.register('./sw.js'); + window.show = async (t, b) => { const r = await navigator.serviceWorker.ready; await r.showNotification(t, { body: b, tag: 's' + Date.now() + Math.random() }); };</script>score`, + { headers: { 'content-type': 'text/html' } }); +} }); +const profile = mkdtempSync(join(tmpdir(), 'notif-score-')); +const chrome = spawn(CHROME, ['--headless=new', `--user-data-dir=${profile}`, `--remote-debugging-port=${DEBUG}`, '--no-first-run', '--password-store=basic', + '--enable-features=ShowWarningsForSuspiciousNotifications', '--disable-features=SystemNotifications', 'about:blank'], { stdio: 'ignore' }); +const cleanup = () => { try { chrome.kill(); } catch {} try { server.stop(true); } catch {} try { rmSync(profile, { recursive: true, force: true }); } catch {} }; +process.on('exit', cleanup); +let browser; +for (let i = 0; i < 40 && !browser; i++) { + await Bun.sleep(500); + browser = await puppeteer.connect({ browserURL: `http://localhost:${DEBUG}`, defaultViewport: null }).catch(() => null); +} +if (!browser) { console.error('could not reach the scoring Chrome'); process.exit(2); } +await browser.defaultBrowserContext().overridePermissions(`http://127.0.0.1:${PORT}`, ['notifications']); +const page = await browser.newPage(); +await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'load' }); +await page.evaluate(() => navigator.serviceWorker.ready); +const hist = await browser.newPage(); +async function counts() { + await hist.goto('chrome://histograms/SafeBrowsing.NotificationContentDetection.SuspiciousScore', { waitUntil: 'load' }); + await Bun.sleep(250); + const txt = await hist.evaluate(() => document.body.innerText); + const m = {}; + for (const line of txt.split('\n')) { const r = /^(\d+)\s+.*\((\d+) = /.exec(line); if (r) m[r[1]] = +r[2]; } + const t = /recorded (\d+) samples/.exec(txt); + return { m, total: t ? +t[1] : 0 }; +} +// the model arrives on first use: warm up until one line scores +let before = await counts(); +for (let i = 0; i < 12 && before.total === 0; i++) { + await page.evaluate(() => window.show('warm up', 'the model is still downloading')); + await Bun.sleep(5000); + before = await counts(); +} +if (before.total === 0) { console.error('the model never scored anything (offline? no optimization-guide access?)'); process.exit(2); } +let worst = 0; +for (const [title, body] of items) { + await page.evaluate((t, b) => window.show(t, b), title, body); + let after, tries = 0; + do { await Bun.sleep(600); after = await counts(); } while (after.total === before.total && ++tries < 25); + const d = after.total - before.total; + const bucket = Object.keys(after.m).filter((k) => (after.m[k] || 0) > (before.m[k] || 0)).join('+'); + const score = d === 1 ? Number(bucket) : NaN; + worst = Math.max(worst, score || 0); + console.log(`${(d === 1 ? String(score) : d === 0 ? '??' : 'ambiguous').padStart(9)} | ${title} | ${body}`); + before = after; +} +await page.close(); await hist.close(); browser.disconnect(); +console.log(worst >= THRESHOLD ? `\nFAIL: a line scored ${worst} (warning at >${THRESHOLD - 1})` : `\nok: every line under ${THRESHOLD}, worst ${worst}`); +process.exit(worst >= THRESHOLD ? 1 : 0); diff --git a/tools/nwc-settings-ui-test.js b/tools/nwc-settings-ui-test.js index b88f9f7..ea52a6c 100644 --- a/tools/nwc-settings-ui-test.js +++ b/tools/nwc-settings-ui-test.js @@ -2,7 +2,7 @@ // budget that resets every day, week or month; the card says so, and // Revoke keeps it (greyed out of the list) answering for a while. // Run: bun tools/nwc-settings-ui-test.js (needs the regtest ark stack on :3535 for the card to show) -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { buildHtml } from '../build.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; @@ -47,8 +47,8 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); - await click('skip verification'); + await seedWords(page); + await skipSeedCheck(page); await waitText('receive', 20000); await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings'); b.click(); }); await sleep(600); await page.evaluate(() => { const b = [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Nostr')); if (b) b.click(); }); await sleep(800); diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index 1ce9944..18528a2 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -5,7 +5,8 @@ import { hex } from '@scure/base'; import * as nip04 from 'nostr-tools/nip04'; import * as nip44 from 'nostr-tools/nip44'; import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; -import { respondFromBg } from '../src/nwc-respond.js'; +import { respondFromBg, tendLnPays } from '../src/nwc-respond.js'; +import { maybeBolt11 } from '../src/ark/lightning.js'; let fails = 0; const check = (name, ok, detail = '') => { @@ -46,13 +47,21 @@ function makeReq(method, params = {}, scheme = 'nip44_v2', ageSec = 0) { function harness({ rec = baseRec(), recs = null, answered = false } = {}) { const published = []; + const declined = []; // wait replies left with the notifier (sent only if nobody answers) const fetchFn = async (url, opts) => { if (url.includes('/publish')) { published.push(JSON.parse(opts.body).event); return { ok: true, json: async () => ({ ok: true }) }; } + if (url.includes('/decline')) { + declined.push(JSON.parse(opts.body).event); + return { ok: true, json: async () => ({ ok: true, scheduled: true }) }; + } if (url.includes('/claim')) { - return { ok: true, json: async () => ({ ok: true }) }; + // as the notifier does: a request already replied to can't be claimed + const was = typeof answered === 'function' ? answered() : answered; + if (JSON.parse(opts.body).release) return { ok: true, json: async () => ({ ok: true, released: true }) }; + return { ok: true, json: async () => (was ? { ok: false, answered: true } : { ok: true }) }; } if (url.includes('/answered')) { return { ok: true, json: async () => ({ answered: typeof answered === 'function' ? answered() : answered }) }; @@ -72,7 +81,7 @@ function harness({ rec = baseRec(), recs = null, answered = false } = {}) { ? nip44.decrypt(ev.content, nip44.getConversationKey(clientSk, conn.servicePk)) : nip04.decrypt(clientSk, conn.servicePk, ev.content)); }; - return { deps, published, decryptReply }; + return { deps, published, declined, decryptReply }; } const ev = (e) => JSON.parse(JSON.stringify(e)); // strip verified symbol like a push payload would @@ -118,15 +127,37 @@ console.log('\n[merchant methods (BTCPay)]'); check('list_transactions includes receives', txs.some((t) => t.type === 'incoming' && t.amount === 21000), JSON.stringify(txs)); } { - const { deps } = harness(); // a mirror without receive keys - check('make_invoice on an old mirror → wake the user', (await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps)) === false); + // PullCap 2026-10-24: lookups of a send in flight came back "not found". + // Every copy of the connection is asked; an outgoing send answers as one. + const INV = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g'; + const H = '7e9d690de6b475ef4f9e964b8a5180afc5fb11af12f0056fece6fa1788398f7e'; + const fresher = baseRec(); fresher.updated = 2; fresher.ark = { ...fresher.ark, network: 'signet' }; + const live = baseRec(); live.updated = 1; + live.mgr.actions.push({ id: 'lnpay-1', type: 'ln-pay', step: 'done', paymentHash: H, preimage: 'cd'.repeat(32), amountSat: 21, invoice: INV }); + const { deps, published, decryptReply } = harness({ recs: [{ walletKey: 'old', rec: fresher }, { walletKey: 'w1', rec: live }] }); + await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, deps); + const r = decryptReply(published[0]).result || {}; + check('lookup_invoice finds a send in the older copy of the connection → outgoing, settled, its preimage', r.type === 'outgoing' && r.state === 'settled' && r.preimage === 'cd'.repeat(32), JSON.stringify(r)); + live.mgr.actions[0].step = 'initiated'; delete live.mgr.actions[0].preimage; + const t0 = Date.now(); + await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, { ...deps, deadline: Date.now() + 2500 }); + const r2 = decryptReply(published[1]).result || {}; + check('…one still in flight (ASP unreachable here) → outgoing, pending, inside the push budget', r2.type === 'outgoing' && r2.state === 'pending' && Date.now() - t0 < 4000, JSON.stringify(r2) + ` ${Date.now() - t0} ms`); +} +{ + const { deps, declined, decryptReply } = harness(); // a mirror without receive keys + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps); + const r = declined.length ? decryptReply(declined[0]) : null; + check('make_invoice on an old mirror → wake the user, wait reply left behind', handled === false && declined.length === 1 && r.result_type === 'make_invoice' && r.error?.code === 'OTHER', JSON.stringify(r)); } console.log('\n[guards]'); { - const { deps } = harness(); + const { deps, published, declined } = harness(); const stale = ev(makeReq('get_balance', {}, 'nip44_v2', 300)); - check('stale request refused', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === false); + // a push that sat at APNs past the request window: nothing to answer, and + // no "Payment request" for it either (true = handled quietly) + check('stale request ignored quietly', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === true && !published.length && !declined.length); } { const { deps } = harness(); @@ -166,16 +197,21 @@ const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0z { const rec = baseRec(); rec.mgr.vtxos = []; // nothing spendable in the mirror - const { deps } = harness({ rec }); + const { deps, published, declined, decryptReply } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); - check('empty mirror → wake the user', handled === false); + const r = declined.length ? decryptReply(declined[0]) : null; + // the wait reply is NOT published by the worker: the notifier holds it and + // sends it only if nobody answers — a sibling's payment can't be shouted over + check('empty mirror → wake the user, wait reply left with the notifier', handled === false && published.length === 0 && declined.length === 1 + && r.result_type === 'pay_invoice' && r.error?.code === 'OTHER' && /open the app/.test(r.error.message), JSON.stringify(r)); + check('…addressed to the request', declined[0].kind === 23195 && declined[0].tags.some((t) => t[0] === 'e')); } { const rec = baseRec(); delete rec.keys['5']; // change index outside the mirrored window - const { deps } = harness({ rec }); + const { deps, declined } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); - check('exhausted key window → wake the user', handled === false); + check('exhausted key window → wake the user, wait reply left behind', handled === false && declined.length === 1); } console.log('\n[pay outcome]'); @@ -203,6 +239,110 @@ console.log('\n[pay outcome]'); } } +console.log('\n[sends begun on an earlier wake]'); +// The worker died mid-payment on an earlier wake: the action sits in the +// mirror at 'created' with its coin reserved (PullCap, 2026-10-23). +{ + const { ArkManager } = await import('../src/ark/manager.js'); + const HASH = maybeBolt11(INV21).paymentHash; + const PRE = 'cd'.repeat(32); + const names = ['init', 'payLnInvoice', 'awaitLnPay', 'lnAction', 'driveLn']; + const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]])); + ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; }; + // a fresh attempt at an invoice with an action on the books is refused… + ArkManager.prototype.payLnInvoice = async function (invoice) { + const hash = maybeBolt11(invoice).paymentHash; + if (this.state.actions.some((a) => a.type === 'ln-pay' && a.paymentHash === hash && a.step !== 'failed')) throw new Error('a payment for this invoice is already in progress'); + const a = { id: 'lnpay-new', type: 'ln-pay', step: 'done', invoice, paymentHash: hash, amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) }; + this.state.actions.push(a); this.storage.save(this.state); + return a.id; + }; + // …and a drive takes the stuck one through to done, freeing its change + ArkManager.prototype.awaitLnPay = async function (id) { return this.driveLn(id); }; + ArkManager.prototype.driveLn = async function (id) { + const a = this.state.actions.find((x) => x.id === id); + if (a && a.step !== 'done') { + a.step = 'done'; a.preimage = PRE; + for (const v of this.state.vtxos) if (v.state === 'pending') v.state = 'spendable'; + this.storage.save(this.state); + } + return a; + }; + ArkManager.prototype.lnAction = function (id) { return this.state.actions.find((x) => x.id === id); }; + const stuckRec = (hash) => { + const rec = baseRec(); + rec.mgr.vtxos[0].state = 'pending'; + rec.mgr.actions.push({ id: 'lnpay-stuck', type: 'ln-pay', step: 'created', invoice: INV21, paymentHash: hash, amountSat: 21, feeSat: 0 }); + rec.pendingPays = [{ hash, connId: 'c1', ts: 1 }]; + return rec; + }; + try { + { + // the SAME invoice asked again: finish that send, answer with ITS preimage + const rec = stuckRec(HASH); + const log = []; + const { deps, published, decryptReply } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, log: (m) => log.push(m) }); + check('same invoice → the stuck send is finished and answered with its preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE, JSON.stringify(handled)); + check('…recorded once on the spend log, under its connection', rec.spends.length === 1 && rec.spends[0].connId === 'c1' && rec.spends[0].preimage === PRE && rec.pendingPays.length === 0, JSON.stringify(rec.spends)); + check('…with a "Payment sent" heads-up', handled && handled.notify && handled.notify.title === 'Payment sent'); + check('…and the diary names the step it reached', log.some((m) => /already created/.test(m)) && log.some((m) => /→ done/.test(m)), log.join(' | ')); + } + { + // a send that settled earlier (in the app, say): its preimage again, + // nothing new on the spend log + const rec = baseRec(); + rec.mgr.actions.push({ id: 'lnpay-old', type: 'ln-pay', step: 'done', invoice: INV21, paymentHash: HASH, amountSat: 21, feeSat: 0, preimage: PRE }); + const { deps, published, decryptReply } = harness({ rec }); + await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('an invoice paid earlier answers with the same preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE); + check('…without a second spend', (rec.spends || []).length === 0); + } + { + // a stuck send for ANOTHER invoice holds the only coin: finish it first, + // then pay the new request from the freed coin + const rec = stuckRec('ee'.repeat(32)); + const { deps, published, decryptReply } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('another invoice → the earlier send is finished, then this one paid', published.length === 1 && decryptReply(published[0]).result?.preimage === 'ab'.repeat(32), JSON.stringify(handled)); + check('…both on the spend log', rec.spends.length === 2 && rec.spends[0].preimage === PRE && rec.spends[1].preimage === 'ab'.repeat(32)); + check('…the earlier one gets its own "Payment sent" line', handled && handled.notices && handled.notices.length === 1 && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled)); + } + { + // any wake at all finishes it (a lookup, a push for something else) + const rec = stuckRec(HASH); + const notices = await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} }); + check('tendLnPays finishes a stuck send and reports it', notices.length === 1 && notices[0].title === 'Payment sent' && rec.spends.length === 1 && rec.spends[0].connId === 'c1', JSON.stringify(notices)); + check('…and a second wake has nothing left to do', (await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} })).length === 0); + } + } finally { + Object.assign(ArkManager.prototype, orig); + } +} + +console.log('\n[a send that fails]'); +// The ASP refused the cosign (a coin at the depth cap, PullCap 2026-10-29): +// the error is left with the notifier at once, not waited out in a worker +// iOS stops before it speaks. +{ + const { ArkManager } = await import('../src/ark/manager.js'); + const names = ['init', 'payLnInvoice']; + const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]])); + ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; }; + try { + for (const [why, code] of [ + ['insufficient ark balance (259 sat have a history too long to spend and are too few to renew; receive more to keep paying)', 'INSUFFICIENT_BALANCE'], + ['ln-pay failed: route not found', 'PAYMENT_FAILED'], + ]) { + ArkManager.prototype.payLnInvoice = async function () { throw new Error(why); }; + const { deps, published, declined, decryptReply } = harness(); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, errGraceMs: 60_000 }); + const r = declined.length ? decryptReply(declined[0]) : null; + check(`${code}: the error is handed to the notifier, nothing waited out here`, handled === true && declined.length === 1 && !published.length && r.error?.code === code && r.error.message === why, JSON.stringify(r)); + } + } finally { Object.assign(ArkManager.prototype, orig); } +} + console.log('\n[network fit]'); { // a mutinynet-era mirror must not answer a mainnet invoice with a nonsense diff --git a/tools/onboard-backup-test.js b/tools/onboard-backup-test.js index ddef8d7..8fc1071 100644 --- a/tools/onboard-backup-test.js +++ b/tools/onboard-backup-test.js @@ -43,11 +43,14 @@ async function signIn() { await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' }); await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); - // wait for the front door to actually be painted before poking it - await waitText(page, 'sign in with nostr'); + // wait for the front door to actually be painted before poking it; the + // sign-in ways are one step in + await waitText(page, 'sign in to an existing account'); + await page.evaluate(() => document.getElementById('onb-signin')?.click()); + await waitText(page, 'migrate a coinos account'); const sk = generateSecretKey(); const nsec = bech32.encode('nsec', bech32.toWords(sk), 1000); - await click(page, 'sign in with nostr'); + await click(page, 'nostr'); await waitText(page, 'log in with nostr'); await page.waitForSelector('input[placeholder*="nsec" i], textarea[placeholder*="nsec" i]', { timeout: 15000 }); await page.type('input[placeholder*="nsec" i], textarea[placeholder*="nsec" i]', nsec); @@ -62,41 +65,82 @@ try { await page.setViewport({ width: 390, height: 844 }); await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' }); await sleep(1500); + // Two choices and nothing else (2026-11-06): a new wallet, or one you + // already have — the sign-in methods wait behind the second. const front = await page.evaluate(() => { const btns = [...document.querySelectorAll('button')].filter((b) => b.offsetParent); const primary = document.getElementById('onb-create'); - const idx = btns.indexOf(primary); - const signins = btns.filter((b) => /sign in with/i.test(b.textContent)); + const signin = document.getElementById('onb-signin'); return { label: primary ? primary.textContent.trim() : null, - first: idx === 0 || (idx >= 0 && idx < btns.findIndex((b) => /sign in with/i.test(b.textContent))), - primaryTop: primary ? primary.getBoundingClientRect().top : -1, - signinTop: signins.length ? signins[0].getBoundingClientRect().top : -1, - divider: !!document.querySelector('.onb-or'), + labels: btns.map((b) => b.textContent.trim()), + order: !!primary && !!signin && primary.getBoundingClientRect().top < signin.getBoundingClientRect().top, + footer: (() => { const f = document.querySelector('.footer'); return f ? { buttons: f.querySelectorAll('button, a').length, selects: f.querySelectorAll('select').length } : null; })(), }; }); check('the way in says what it makes', /wallet/i.test(front.label || ''), front.label || 'missing'); - check('...and comes before the sign-in buttons', front.first && front.primaryTop < front.signinTop, - `create at ${Math.round(front.primaryTop)}, first sign-in at ${Math.round(front.signinTop)}`); - check('...with a line between them, so they read as the alternative', front.divider); + check('...and the only other choice is signing in to one you have', front.order && front.labels.length === 2, + front.labels.join(' | ')); + check('...with only the language picker under them', !!front.footer && front.footer.buttons === 0 && front.footer.selects === 1, JSON.stringify(front.footer)); // The welcome card is just the title and the buttons now (42c8d4e): the // way in itself says what the wallet is — tapping it puts the twelve // words on screen at once, with what they are. await page.evaluate(() => document.getElementById('onb-create')?.click()); await sleep(600); - const made = await page.evaluate(() => ({ - words: document.querySelectorAll('.words .w .t').length, - says: /12 words|twelve words/i.test(document.body.innerText), - })); - check('the way in shows what the wallet actually is: its twelve words', made.words === 12 && made.says, - made.words + ' words' + (made.says ? ', explained' : ', unexplained')); + // a heads-up first, no warning icon (2026-11-07): sensitive words next + const intro = await page.evaluate(() => ({ words: document.querySelectorAll('.words .w').length, text: document.body.innerText, warn: !!document.querySelector('.warn-box') })); + check('the words wait behind a heads-up to write them down', intro.words === 0 && /write them down/i.test(intro.text) && !intro.warn && !/⚠/.test(intro.text)); + await page.evaluate(() => document.getElementById('seed-intro-next')?.click()); + await sleep(400); + const made = await page.evaluate(() => document.querySelectorAll('.words .w .t').length); + check('the way in shows what the wallet actually is: its twelve words', made === 12, made + ' words'); await page.close(); } + // Three: a wallet made elsewhere comes in by its twelve words, and that + // door has to be on the front page too — it used to be a line under the + // freshly minted words behind "Create a new wallet", found by chance. + console.log('\n[restoring from twelve words]'); + { + const ctx = await browser.createBrowserContext(); + const page = await ctx.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText(page, 'sign in to an existing account'); + await page.evaluate(() => document.getElementById('onb-signin')?.click()); + await waitText(page, 'migrate a coinos account'); + const door = await page.evaluate(() => { + const btns = [...document.querySelectorAll('button')].filter((b) => b.offsetParent && b.textContent.trim()); + const b = document.getElementById('onb-restore'); + return b ? { label: b.textContent.trim(), first: btns[0] === b, visible: !!b.offsetParent } : null; + }); + check('signing in offers the seed phrase', !!door && door.visible && /seed phrase/i.test(door.label), door ? door.label : 'missing'); + check('...first, above the other ways', !!door && door.first); + await page.evaluate(() => document.getElementById('onb-restore')?.click()); + await sleep(600); + const pane = await page.evaluate(() => ({ + title: /enter seed phrase/i.test(document.body.innerText), + field: !!document.querySelector('textarea[placeholder*="seed words" i]'), + minted: document.querySelectorAll('.words .w .t').length, + })); + check('it opens straight onto the phrase field, no minted words', pane.title && pane.field && pane.minted === 0, + `${pane.title ? 'titled' : 'untitled'}, ${pane.field ? 'field' : 'no field'}, ${pane.minted} minted words`); + const phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + await page.type('textarea[placeholder*="seed words" i]', phrase); + await click(page, 'open wallet'); + check('the typed phrase opens the wallet', await waitText(page, 'receive')); + await ctx.close(); + } + console.log('\n[signing in, then writing it down]'); { const { page, ctx, seed } = await signIn(); check('a sign-in is asked to write the phrase down', await waitText(page, 'write down your recovery phrase')); + check('...with the words behind Show seed phrase', await page.evaluate(() => !document.querySelector('.words'))); + await click(page, 'show seed phrase'); + await sleep(300); const shown = await page.evaluate(() => [...document.querySelectorAll('.words .w .t')].map((e) => e.textContent.trim())); check('...and the real twelve words are on screen', shown.length === 12 && shown.join(' ') === seed, shown.length + ' words' + (shown.join(' ') === seed ? ', matching the derived seed' : ', WRONG')); diff --git a/tools/onboard-cover-test.js b/tools/onboard-cover-test.js index 4262bc1..733a515 100644 --- a/tools/onboard-cover-test.js +++ b/tools/onboard-cover-test.js @@ -3,7 +3,7 @@ // twelve nature covers with a counter; the preview copies are what load. // Run: bun run build && bun tools/onboard-cover-test.js [screenshot.png] import assert from 'node:assert/strict'; -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const server = Bun.serve({ port: 0, async fetch(req) { const p = new URL(req.url).pathname; @@ -21,7 +21,7 @@ try { await page.reload(); await sleep(1200); const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); e?.click(); return !!e; }, x); await click('create a new wallet'); await sleep(1500); - await click('skip verification'); await sleep(1500); + await skipSeedCheck(page); await sleep(1500); await page.evaluate(() => localStorage.setItem('btc-wallet-onb-step', 'avatar')); await page.reload(); await page.waitForSelector('.onb-cover', { timeout: 10000 }); diff --git a/tools/onboard-migrate-door-test.js b/tools/onboard-migrate-door-test.js index 3b16dd0..ac2252c 100644 --- a/tools/onboard-migrate-door-test.js +++ b/tools/onboard-migrate-door-test.js @@ -29,6 +29,9 @@ try { await page.setViewport({ width: 390, height: 844 }); await page.goto('http://localhost:5293/', { waitUntil: 'domcontentloaded' }); check('the front door paints', await waitText(page, 'create a new wallet')); + // the sign-in ways (migrating included) wait behind "Sign in to an existing account" + await page.click('#onb-signin'); + await waitText(page, 'migrate a coinos account', 5000); const btns = await visible(page); const iMigrate = btns.findIndex((b) => b.id === 'onb-migrate'); const iLastSignin = btns.map((b) => /sign in/i.test(b.text)).lastIndexOf(true); @@ -44,7 +47,7 @@ try { doors.map((b) => b.text).join(' | ')); await page.reload({ waitUntil: 'domcontentloaded' }); check('a refresh stays on the move page', await waitText(page, 'move your coinos.io account', 8000)); - check('Back returns to the front door', await clickText(page, 'back') && await waitText(page, 'create a new wallet', 5000)); + check('Back returns to the sign-in choices', await clickText(page, 'back') && await waitText(page, 'migrate a coinos account', 5000)); const forgot = await page.evaluate(() => !localStorage.getItem('btc-wallet-migrate-link')); check('…and forgets the intent', forgot); await page.click('#onb-migrate'); diff --git a/tools/own-post-feeds-test.js b/tools/own-post-feeds-test.js index a3e8fc3..a877749 100644 --- a/tools/own-post-feeds-test.js +++ b/tools/own-post-feeds-test.js @@ -6,7 +6,7 @@ // whose tag it wears, which is now a t tag on the event) — not to a topic // feed that happens to be open and doesn't match. // Run: bun tools/own-post-feeds-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; @@ -63,9 +63,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const ME = getPublicKey(privateKeyFromSeedWords(mn)); diff --git a/tools/padlock-flow-test.js b/tools/padlock-flow-test.js index dcc0a85..f56d557 100644 --- a/tools/padlock-flow-test.js +++ b/tools/padlock-flow-test.js @@ -110,7 +110,9 @@ try { await p2.evaluate(() => document.querySelector('.header-avatar')?.click()); await sleep(800); await click2('Sign into another account'); await sleep(500); await click2('Sign into a new account'); await sleep(500); - await click2('Import existing'); await sleep(300); // the sign-in screen's import tab + // the front door's layout (2026-11-07): sign in → Seed phrase + await click2('Sign in to an existing account'); await sleep(300); + await click2('Seed phrase'); await sleep(300); return importWallet(); }; // the wallets on this device, as the identity switcher lists them (the diff --git a/tools/pager-caption-test.js b/tools/pager-caption-test.js index 5952d81..a3d7800 100644 --- a/tools/pager-caption-test.js +++ b/tools/pager-caption-test.js @@ -4,7 +4,7 @@ // caption leaves the pager and opens the post, where the whole caption // reads. A link to the web opens in its own tab and the pager stays. // Run: bun tools/pager-caption-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { npubEncode } from 'nostr-tools/nip19'; @@ -55,6 +55,8 @@ const openClip = async (word) => { await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word); await sleep(400); const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); const opened = await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000); // the caption and the counts show on a tap on the clip (details), as a reader sees them @@ -72,6 +74,8 @@ const clickIn = async (sel, where) => { // the caption's first word sits at its left edge; a link in the middle return where === 'start' ? { x: r.left + 12, y: r.top + 9 } : { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, ['(' + CAP + ')', sel, where]); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); }; const toFeed = async () => { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 8000); }; @@ -83,9 +87,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, valley, ns]) => { diff --git a/tools/pager-close-test.js b/tools/pager-close-test.js index 73aa15b..2f0bece 100644 --- a/tools/pager-close-test.js +++ b/tools/pager-close-test.js @@ -6,7 +6,7 @@ // the reader on that page, and that entry becomes it. And the ordinary case // still steps Back, so the phone's Back doesn't reopen the pager. // Run: bun tools/pager-close-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -84,6 +84,8 @@ const st = () => page.evaluate(() => ({ pager: !!document.querySelector('.media- const openFirst = async () => { const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); await sleep(400); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]); await page.mouse.click(r.x, r.y); return waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); }; @@ -94,9 +96,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/pager-controls-test.js b/tools/pager-controls-test.js index a02591d..79e9bca 100644 --- a/tools/pager-controls-test.js +++ b/tools/pager-controls-test.js @@ -4,7 +4,7 @@ // the reaction button offers the emoji sheet, over the pager, as the feed's // does; the emoji picked shows on the button, and a tap again takes it back. // Run: bun tools/pager-controls-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -54,9 +54,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/pager-eager-test.js b/tools/pager-eager-test.js index 3b5c5c3..f1cd150 100644 --- a/tools/pager-eager-test.js +++ b/tools/pager-eager-test.js @@ -8,7 +8,7 @@ // Saver on, the pager is careful again: such a clip waits for a tap and // nothing loads ahead. // Run: bun tools/pager-eager-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -93,9 +93,9 @@ async function run(saveData, slow = false) { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { @@ -117,6 +117,8 @@ async function run(saveData, slow = false) { await waitFor(() => { const v = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')).querySelector('video.note-video'); return v && !v.paused && v.currentTime > 0; }, null, 10000); const r = await feedRow('clip c0 here'); log = log.filter((e) => e.clip === 'c0'); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]); await page.mouse.click(r.x, r.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); await sleep(slow ? 8000 : 3000); diff --git a/tools/pager-first-frame-test.js b/tools/pager-first-frame-test.js index 6507607..63de5ac 100644 --- a/tools/pager-first-frame-test.js +++ b/tools/pager-first-frame-test.js @@ -5,7 +5,7 @@ // opening, and a clip beyond gets no picture (and stays out of the // carousel) until a first frame comes — never the post's still first. The slide left behind keeps its frame for the swipe // back. Run: bun tools/pager-first-frame-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -81,9 +81,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { @@ -99,6 +99,8 @@ try { await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }); await sleep(600); const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000)); check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000)); diff --git a/tools/pager-gone-picture-test.js b/tools/pager-gone-picture-test.js index 34c89b6..b7e3c8f 100644 --- a/tools/pager-gone-picture-test.js +++ b/tools/pager-gone-picture-test.js @@ -2,7 +2,7 @@ // nor the original can load) never joins the pager's carousel: a picture // joins once decoded (2026-10-17), and a step passes over it to the next. // (Its slide is marked gone, with the host's name, as before — out of sight.) Run: bun tools/pager-gone-picture-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -43,9 +43,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/pager-no-poster-test.js b/tools/pager-no-poster-test.js index 0930834..c40f73e 100644 --- a/tools/pager-no-poster-test.js +++ b/tools/pager-no-poster-test.js @@ -7,7 +7,7 @@ // connection to be careful with (Data Saver), where a clip may never load, // the thumbnail poster stays. // Run: bun tools/pager-no-poster-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -64,6 +64,8 @@ const openClip = async (word) => { await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word); await sleep(400); const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); }; @@ -75,9 +77,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/pager-prime-test.js b/tools/pager-prime-test.js index 7263ee1..df20cc7 100644 --- a/tools/pager-prime-test.js +++ b/tools/pager-prime-test.js @@ -15,7 +15,7 @@ // that); on a link slower than the clip, more — to the 4 MB cap. With Data // Saver on, the pager is careful again: such a clip waits for a tap and // nothing loads ahead. -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -97,9 +97,9 @@ async function run() { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { @@ -114,6 +114,8 @@ async function run() { await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 }); const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); row.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = row.querySelector('video.note-video').getBoundingClientRect(); return { x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; }); await sleep(800); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]); await page.mouse.click(r.x, r.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); check('the next clip\'s warm player has played a moment, unheard, and waits paused a frame or two in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s && s.querySelector('video.mp-media'); return v && v.paused && v.currentTime >= 0.03 && v.currentTime < 0.6; }, null, 10000), JSON.stringify(await vid('c1'))); diff --git a/tools/pager-ready-gate-test.js b/tools/pager-ready-gate-test.js index dd6982d..20cb337 100644 --- a/tools/pager-ready-gate-test.js +++ b/tools/pager-ready-gate-test.js @@ -5,7 +5,7 @@ // grid — shows the clips the screen's way round (tall ones on a phone held // upright), a square either way, until the toggle asks for all shapes. // Run: bun tools/pager-ready-gate-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -81,9 +81,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/pager-resize-test.js b/tools/pager-resize-test.js index 07fcbba..d18745b 100644 --- a/tools/pager-resize-test.js +++ b/tools/pager-resize-test.js @@ -5,7 +5,7 @@ // button on a clip 23 posts down showed him another post's picture. The // resize can come before the observer has even named the first slide. // Run: bun tools/pager-resize-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -68,9 +68,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/pager-rotate-test.js b/tools/pager-rotate-test.js index 9912b7a..4d453ad 100644 --- a/tools/pager-rotate-test.js +++ b/tools/pager-rotate-test.js @@ -4,7 +4,7 @@ // autoplay used to start it again, from the start, with sound if it had been // unmuted there), and not the slide a turn's half-way layout passes over. // Run: bun tools/pager-rotate-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -68,9 +68,9 @@ async function run(unmuteFirst) { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { @@ -91,6 +91,8 @@ async function run(unmuteFirst) { await sleep(800); } const at = await rowOf(); await sleep(300); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); await waitFor(() => { const v = document.querySelector('.media-pager video[src]'); return v && !v.paused && v.currentTime > 1.5; }, null, 8000); diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js index f25ff49..59a0353 100644 --- a/tools/pager-seek-hide-test.js +++ b/tools/pager-seek-hide-test.js @@ -8,7 +8,7 @@ // position counter is the other way // round: it shows while the pager moves and fades out once it has settled. // Run: bun tools/pager-seek-hide-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -70,9 +70,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { @@ -88,6 +88,8 @@ try { await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }); await sleep(400); const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000)); diff --git a/tools/pager-sharpen-test.js b/tools/pager-sharpen-test.js index ea5c878..e61b51d 100644 --- a/tools/pager-sharpen-test.js +++ b/tools/pager-sharpen-test.js @@ -3,7 +3,7 @@ // comes to rest; only the slide at rest carries the zoom layer. (Originals // decoded and uploaded as they passed made a flight stutter — Adam, // 2026-10-16.) Run: bun tools/pager-sharpen-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -42,9 +42,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/pager-swipe-fly-test.js b/tools/pager-swipe-fly-test.js index 657ea75..1184e7b 100644 --- a/tools/pager-swipe-fly-test.js +++ b/tools/pager-swipe-fly-test.js @@ -5,7 +5,7 @@ // mid-flight stops it on the post most on screen, and a swipe back from // there goes to the one before. // Run: bun tools/pager-swipe-fly-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -85,9 +85,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/pager-tail-test.js b/tools/pager-tail-test.js index b294b8f..96227f8 100644 --- a/tools/pager-tail-test.js +++ b/tools/pager-tail-test.js @@ -4,7 +4,7 @@ // moved onto and plays by itself. With nothing older to be had, the end says // that's everything. Escape from there lands on the last clip's post. // Run: bun tools/pager-tail-test.js (SHOT=path.png saves the loading screen) -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -84,9 +84,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { @@ -103,6 +103,8 @@ try { const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); await sleep(400); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]); await page.mouse.click(r.x, r.y); check('the pager opens on the first clip', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000) && await resting('c0'), await at()); check('a loading screen waits at the end, after the clips', (await tail())?.last, JSON.stringify(await tail())); diff --git a/tools/pager-tap-slop-test.js b/tools/pager-tap-slop-test.js index 4de87df..c184e3b 100644 --- a/tools/pager-tap-slop-test.js +++ b/tools/pager-tap-slop-test.js @@ -4,7 +4,7 @@ // 30px pauses (or plays) the clip and leaves the pager where it was; only a // longer one pages. // Run: bun tools/pager-tap-slop-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -66,9 +66,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/pager-wheel-test.js b/tools/pager-wheel-test.js index 45a5115..7f59c34 100644 --- a/tools/pager-wheel-test.js +++ b/tools/pager-wheel-test.js @@ -5,7 +5,7 @@ // the wheel pans it while zoomed and pages again at 1×; and arrows at the // edges for the pointer, the sideways pair only where the post has more. // Run: bun tools/pager-wheel-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -68,9 +68,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/pic-proxy-test.js b/tools/pic-proxy-test.js index 881867c..69a09c1 100644 --- a/tools/pic-proxy-test.js +++ b/tools/pic-proxy-test.js @@ -5,7 +5,7 @@ // paints the original as before. The server is stood in for: its answers are // canned. // Run: bun tools/pic-proxy-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -76,9 +76,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/picture-pager-ready-test.js b/tools/picture-pager-ready-test.js index 7cfaa45..08a4e21 100644 --- a/tools/picture-pager-ready-test.js +++ b/tools/picture-pager-ready-test.js @@ -5,7 +5,7 @@ // the screen stay out, with the "Tall only" / "All shapes" toggle in the // top bar; and with fewer than ten ready and more still warming, no // counter shows and a flick goes one post. Run: bun tools/picture-pager-ready-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -50,9 +50,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/poll-test.js b/tools/poll-test.js index edcb061..41aa02e 100644 --- a/tools/poll-test.js +++ b/tools/poll-test.js @@ -4,7 +4,7 @@ // caught at the socket so the test never votes on someone's real poll. // // Run: bun tools/poll-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { SimplePool } from 'nostr-tools/pool'; import { cacheKeyFor } from '../src/wallet.js'; import { neventOf } from '../src/nostr.js'; @@ -41,9 +41,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const Q = 'a'.repeat(63) + '9'; diff --git a/tools/post-edit-test.js b/tools/post-edit-test.js index 0b8bf6f..08d1190 100644 --- a/tools/post-edit-test.js +++ b/tools/post-edit-test.js @@ -5,7 +5,7 @@ // deletion is remembered across a reload. // No relay is written for real — the WebSocket shim swallows EVENT. // Run: bun tools/post-edit-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -95,9 +95,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const ME_SK = privateKeyFromSeedWords(mn); @@ -160,6 +160,7 @@ try { await openFeed(); check('the feed is back', await waitText('a post a stranger tried to delete', 15000)); check('my deleted post stays gone', !(await has('my own post'))); + check('...with no "was deleted" row in its place', !(await has('deleted by its author'))); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ post edits and deletions' : '\n❌ failed'); diff --git a/tools/post-search-test.js b/tools/post-search-test.js new file mode 100644 index 0000000..81e5b62 --- /dev/null +++ b/tools/post-search-test.js @@ -0,0 +1,83 @@ +// Full-text search: words typed into Search and submitted with Enter (no +// suggestion picked) open a feed of the notes AND replies that say them, +// asked of NIP-50 relays ({ kinds: [1], search }). The search relays are +// answered in the page; nothing leaves the machine. +// Run: bun tools/post-search-test.js +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +import { generateSecretKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const SK = generateSecretKey(); +const now = Math.floor(Date.now() / 1000); +const post = finalizeEvent({ kind: 1, created_at: now - 300, tags: [], content: 'talked to fiatjaf about relays today' }, SK); +const parent = 'a'.repeat(64); +// one newsletter, posted by bot keys with a word or emoji changed each time +const NL = 'In this week\'s issue: The Quiet Coup: Fiatjaf Wipes Authorship From 57 NIPs. In a 12-hour window, the documentation layer was rewritten without consent. https://nostrmag.com/article/w45'; +const copies = ['📰 ' + NL, '🗞️ BREAKING ' + NL, NL + ' 👤 NM team'].map((content, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i, tags: [], content }, generateSecretKey())); +const reply = finalizeEvent({ kind: 1, created_at: now - 200, tags: [['e', parent, '', 'reply']], content: 'fiatjaf would say just use a relay' }, SK); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument((hits) => { + window.__searches = []; + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ' && m[2] && m[2].search) { + window.__searches.push({ url: ws.url, f: m[2] }); + const r = send(data); + setTimeout(() => { for (const ev of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })); }, 2); + return r; + } + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, Real); +}, [post, reply, ...copies]); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await seedWords(page); + await skipSeedCheck(page); + await waitText('receive', 20000); + await page.click('[aria-label="Search"]'); + await page.waitForSelector('.user-search-input'); + await page.type('.user-search-input', 'fiatjaf'); + await sleep(300); + check('the search page offers the words themselves', await waitText('posts and replies that say this', 3000)); + await page.keyboard.press('Enter'); + check('Enter opens a feed of posts that say it', await waitText('talked to fiatjaf about relays', 10000)); + check('...replies too', await waitText('fiatjaf would say just use a relay', 3000)); + await waitText('quiet coup', 10000); await sleep(1500); + const nl = await page.evaluate(() => (document.body.innerText.match(/Quiet Coup/g) || []).length); + check('a newsletter posted by three bot keys shows once', nl === 1, nl + ' copies'); + const s = await page.evaluate(() => window.__searches.filter((x) => x.f.search === 'fiatjaf' && !(x.f.kinds || []).includes(0))); + check('asked NIP-50 relays for kind 1 with the words', s.length > 0 && s.every((x) => JSON.stringify(x.f.kinds) === '[1]'), JSON.stringify(s.map((x) => x.url))); + check('the feed is named for the search', await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].some((c) => c.classList.contains('on') && c.textContent.includes('fiatjaf'))), + JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent.trim())))); + check('no page errors', !errs.length, errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { + await browser.close(); server.stop(); +} +console.log(ok ? '\nPASS' : '\nFAIL'); +process.exit(ok ? 0 : 1); diff --git a/tools/profile-pager-test.js b/tools/profile-pager-test.js index 2a78598..5593192 100644 --- a/tools/profile-pager-test.js +++ b/tools/profile-pager-test.js @@ -3,7 +3,7 @@ // else's — and closing it after moving on lands the profile on that post. // (It opened alone, in the lightbox, before: no pager on a profile.) // Run: bun tools/profile-pager-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -67,9 +67,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns, other]) => { diff --git a/tools/profile-publish-race-test.js b/tools/profile-publish-race-test.js index 390c01b..becf375 100644 --- a/tools/profile-publish-race-test.js +++ b/tools/profile-publish-race-test.js @@ -67,9 +67,10 @@ const wallet = { loadFeatureState: () => null, saveFeatureState: () => {}, _cacheKey: () => 'race-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {}, }; +let namesAddr = null; // what the registrar says our address is const feature = messagesFeature({ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui: {}, render: () => {}, wallet, toast: () => {}, brandHeader: () => null, - hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null), + hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : name === 'namesAddress' ? namesAddr : null), }); console.log('[the migration: rename and avatar import fired together]'); @@ -91,5 +92,20 @@ await Promise.all([feature.addressRenamed('[email protected]', '[email protected]'), const last = JSON.parse(store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0].content); check('both changes survive', last.lud16 === '[email protected]' && last.about === 'hi' && /face/.test(last.picture), JSON.stringify(last)); +console.log('[Save in the editor repoints a dead same-domain address]'); +// The heal never ran (an Amber user: no background signatures), so the kind 0 +// still names the released placeholder. The domain says jar is dead, other +// is alive. +store.push(finalizeEvent({ kind: 0, created_at: now + 1000, tags: [], content: JSON.stringify({ name: 'Jarryd', lud16: '[email protected]', nip05: '[email protected]' }) }, sk)); +namesAddr = '[email protected]'; +globalThis.fetch = async (url) => /lnurlp\/jar$/.test(url) + ? { ok: false, status: 500, json: async () => null } + : { ok: true, status: 200, json: async () => ({ tag: 'payRequest', callback: 'https://x/cb' }) }; +published.length = 0; +await feature.saveProfileDraft({ uname: 'jarryd', name: 'Jarryd' }); +const saved = JSON.parse(published.filter((e) => e.kind === 0).pop().content); +check('the dead placeholder moves to the claimed address', saved.lud16 === '[email protected]', JSON.stringify(saved)); +check('a same-domain address that still answers is left alone', saved.nip05 === '[email protected]', saved.nip05); + console.log(fails ? `\n❌ ${fails} failing` : '\n✅ kind-0 publishes take turns'); process.exit(fails ? 1 : 0); diff --git a/tools/profile-repost-test.js b/tools/profile-repost-test.js index 7825efe..5ee97e7 100644 --- a/tools/profile-repost-test.js +++ b/tools/profile-repost-test.js @@ -4,7 +4,7 @@ // boost, a video event still has its clip — they were all saved as kind 1 // notes, so a boost came back as "reply · {"content":…}". // Run: bun tools/profile-repost-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { npubEncode } from 'nostr-tools/nip19'; import { buildHtml } from '../build.js'; @@ -57,8 +57,8 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); - await click('skip verification'); + await seedWords(page); + await skipSeedCheck(page); await waitText('receive', 20000); await page.goto(server.url.href + npubEncode(ME.pk), { waitUntil: 'domcontentloaded' }); await look('from the relays'); diff --git a/tools/public-chat-join-test.js b/tools/public-chat-join-test.js new file mode 100644 index 0000000..3f0eee4 --- /dev/null +++ b/tools/public-chat-join-test.js @@ -0,0 +1,75 @@ +// The public chat page (/chat) on a device that already has a wallet: its +// button opens the coinos community's room itself, not the wallet, and the +// page doesn't tell a signed-in reader to sign in. And /chat's loading shell +// is not a profile named "chat". (Andy, 2026-11-10: "I can see the feed etc +// fine, so I am logged in".) Relays are the stand-ins: nothing is sent. +// +// Run: bun tools/public-chat-join-test.js +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { buildHtml } from '../build.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5247, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 390, height: 844 }); +const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); +const waitFor = async (fn, ms = 15000, ...a) => { for (let i = 0; i < ms / 200; i++) { try { if (await page.evaluate(fn, ...a)) return true; } catch {} await sleep(200); } return false; }; +// did the static profile shell ever show while the page loaded? +await page.evaluateOnNewDocument(() => { + window.__shellShown = false; + const look = () => { const s = document.getElementById('boot-shell'); if (s && s.style.display !== 'none') window.__shellShown = true; }; + new MutationObserver(look).observe(document, { childList: true, subtree: true, attributes: true }); + document.addEventListener('DOMContentLoaded', look); +}); +const head = () => page.evaluate(() => { + const c = document.querySelector('.chat-head'); + return c ? { text: c.innerText, button: c.querySelector('button')?.textContent.trim() } : null; +}); + +try { + console.log('\n[a visitor]'); + await page.goto('http://localhost:5247/chat', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitFor(() => !!document.querySelector('.chat-head')); + let hd = await head(); + check('the public chat mounts', !!hd); + check('a visitor is asked to sign in', hd?.button === 'Sign in to chat' && /sign in to join/i.test(hd.text), hd?.button); + check("/chat's loading shell is not a profile", !(await page.evaluate(() => window.__shellShown))); + await page.goto('http://localhost:5247/someone', { waitUntil: 'domcontentloaded' }); + check('a name still gets its profile shell', await page.evaluate(() => window.__shellShown)); + + console.log('\n[with a wallet on the device]'); + await page.goto('http://localhost:5247/', { waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('button', 'Create a new wallet'); + await sleep(300); + await click('button', 'Have an existing seed'); + await sleep(300); + await page.waitForSelector('textarea'); + await page.type('textarea', generateMnemonic(wordlist)); + await click('button', 'Open wallet'); + check('wallet opens', await waitFor(() => /receive/i.test(document.body.innerText))); + await page.goto('http://localhost:5247/chat', { waitUntil: 'domcontentloaded' }); + await waitFor(() => !!document.querySelector('.chat-head')); + hd = await head(); + check('no "sign in" for someone signed in', !!hd && !/sign in/i.test(hd.text), hd?.text.replace(/\n/g, ' | ')); + check('the button offers the chat', hd?.button === 'Join the chat', hd?.button); + await Promise.all([page.waitForNavigation({ waitUntil: 'domcontentloaded' }), click('.chat-head button', 'Join the chat')]); + const inRoom = await waitFor(() => !!document.querySelector('.chat-compose textarea, .chat-compose input, .chat-compose [contenteditable]'), 20000); + check('it lands in the community room, composer ready', inRoom, await page.evaluate(() => document.body.innerText.slice(0, 120).replace(/\n/g, ' | '))); + check('the room is the coinos community', await page.evaluate(() => /coinos/i.test(document.querySelector('.chat-head, .chat-title')?.innerText || ''))); + check('the marker is gone from the address', await page.evaluate(() => location.pathname === '/' && !location.search)); +} finally { + await browser.close(); + server.stop(true); +} +console.log(ok ? '\nPASS' : '\nFAIL'); +process.exit(ok ? 0 : 1); diff --git a/tools/puppeteer.js b/tools/puppeteer.js index 2609981..8cefd6e 100644 --- a/tools/puppeteer.js +++ b/tools/puppeteer.js @@ -126,3 +126,20 @@ export default new Proxy(real, { return typeof v === 'function' ? v.bind(t) : v; }, }); + +// The create flow (2026-11-07): a heads-up, then the words, then the +// passphrase page, then a one-word check. seedWords() gets a test from +// "Create a new wallet" to the words on screen; skipSeedCheck() from there +// (or from the heads-up) into the wallet, no passphrase, check skipped. +export async function seedWords(page) { + await page.waitForSelector('#seed-intro-next, .words .w .t', { timeout: 30000 }); + if (await page.$('#seed-intro-next')) await page.evaluate(() => document.getElementById('seed-intro-next').click()); + await page.waitForSelector('.words .w .t'); +} +export async function skipSeedCheck(page) { + if (await page.$('#seed-intro-next')) await seedWords(page); + for (const id of ['seed-next', 'pass-next', 'seed-skip']) { + await page.waitForSelector('#' + id, { timeout: 10000 }); + await page.evaluate((i) => document.getElementById(i).click(), id); + } +} diff --git a/tools/quote-pager-test.js b/tools/quote-pager-test.js index 7f8b76c..d125edd 100644 --- a/tools/quote-pager-test.js +++ b/tools/quote-pager-test.js @@ -6,7 +6,7 @@ // quoted picture's details name its author, and closing lands on the post // that quoted it. // Run: bun tools/quote-pager-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { noteEncode } from 'nostr-tools/nip19'; @@ -80,9 +80,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pa, pq]) => { diff --git a/tools/quoted-clip-fullscreen-test.js b/tools/quoted-clip-fullscreen-test.js index bd8dd45..ce235f6 100644 --- a/tools/quoted-clip-fullscreen-test.js +++ b/tools/quoted-clip-fullscreen-test.js @@ -5,7 +5,7 @@ // handler took the clip back out of full screen for a pager that then // wouldn't open. // Run: bun tools/quoted-clip-fullscreen-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -47,9 +47,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, alice, bob, roots, replies]) => { @@ -70,6 +70,8 @@ try { console.log('\n[a tap on the picture]'); const box = await (await page.$(SEL)).boundingBox(); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [box.x + box.width / 2, box.y + box.height * 0.25]); await page.mouse.click(box.x + box.width / 2, box.y + box.height * 0.25); // above the middle: not the play button check('the clip goes full screen', await waitFor(() => document.fullscreenElement && document.fullscreenElement.matches('video'), 4000), JSON.stringify(await state())); await sleep(600); diff --git a/tools/receive-batch-fold-test.js b/tools/receive-batch-fold-test.js new file mode 100644 index 0000000..8e38265 --- /dev/null +++ b/tools/receive-batch-fold-test.js @@ -0,0 +1,20 @@ +// One payment delivered as several coins — a gift whose 886 sats sat in two +// (595 + 291, dust padding) swept in one claim — is one Received row. +// sameDelivery decides which receive rows fold; nothing else does. +// Run: bun tools/receive-batch-fold-test.js +import { sameDelivery } from '../src/ark/manager.js'; +let ok = true; +const check = (n, c) => { console.log(` ${c ? '✓' : '✗'} ${n}`); if (!c) ok = false; }; +const T = 1_790_000_000_000; +const rcv = (id, sat, ts, extra = {}) => ({ id, type: 'receive', status: 'complete', amountSat: sat, ts, vtxoId: 'v-' + id, ...extra }); +check('two coins of one mailbox message fold', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7' }))); +check('...even when their instants differ (live, no checkpoint time)', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T + 40, { batch: '7' }))); +check('two messages stay two rows, same instant or not', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '8' }))); +check('a stamped row never folds an unstamped one', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T))); +check('older rows (no stamp) fold on the same delivery instant', sameDelivery(rcv('a', 595, T), rcv('b', 291, T))); +check('...not a millisecond apart', !sameDelivery(rcv('a', 595, T), rcv('b', 291, T + 1))); +check('a ⚡ receive is its own payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', viaLn: true }))); +check('a rejected coin is not part of the payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', status: 'rejected' }))); +check('only receives fold', !sameDelivery({ ...rcv('a', 595, T), type: 'send' }, rcv('b', 291, T))); +console.log(ok ? '\n✅ one delivery, one row' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/relay-budget-test.js b/tools/relay-budget-test.js new file mode 100644 index 0000000..d9798a3 --- /dev/null +++ b/tools/relay-budget-test.js @@ -0,0 +1,167 @@ +// What a Following feed asks of a relay, and how. Measured from the laptop +// (2026-11-02) an open of Following sent some 560 REQs in twenty seconds: +// a relay list asked per quoted note's author (one REQ per author per +// relay), the live subscriptions closed and reopened at the end of every +// refresh pass, the follows' reports fetched afresh at every reload, and +// every one-shot REQ held open on our fast relay until the slowest relay +// in the ask had answered. damus answered the home IP "banned: too many +// rate-limit violations". Now: relay lists go out in one REQ per batch, +// unchanged live subscriptions stay up, the reports sync honours the saved +// clock, each relay's REQ closes on its own EOSE, and no relay carries more +// than ten one-shot REQs at once. Run: bun tools/relay-budget-test.js +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { neventEncode } from 'nostr-tools/nip19'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const html = await buildHtml({ minify: true, pwa: false }); +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = () => Math.floor(Date.now() / 1000); +const post = (sk, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now() - ago, tags, content }, sk); +// six people the follow quotes, each with one note on the relay +const quoters = Array.from({ length: 6 }, () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }); +const quotedNotes = quoters.map((q, i) => post(q.sk, `quoted thought ${i}`, 7200 + i)); +const stored = [...quotedNotes, post(SK, 'morning one', 3600), post(SK, 'morning two', 3000)]; +const quotePosts = quotedNotes.map((n, i) => post(SK, `look at this ${i} nostr:${neventEncode({ id: n.id, author: n.pubkey })}`, 1200 + i * 10, [['q', n.id, '', n.pubkey]])); +stored.push(...quotePosts); +// one quoted note only a far relay holds (the wide net's job) +const farQ = generateSecretKey(); +const farNote = finalizeEvent({ kind: 1, created_at: now() - 9000, tags: [], content: 'kept only on the far relay' }, farQ); +const farPost = post(SK, `and this nostr:${neventEncode({ id: farNote.id, author: farNote.pubkey })}`, 1100, [['q', farNote.id, '', farNote.pubkey]]); +stored.push(farPost); +const wideStore = [farNote]; +const cached = [...quotePosts, ...stored.filter((e) => e.pubkey === AUTHOR && !quotePosts.includes(e))].sort((a, b) => b.created_at - a.created_at); +const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since); + +// two relays on one server: /fast answers at once, /slow holds its EOSE +const SLOW_MS = 3000; +const log = []; // { t, relay, kind: 'REQ'|'CLOSE'|'EOSE', id, filters } +const sockets = new Set(); +let maxOpen = { fast: 0, slow: 0 }; +const server = Bun.serve({ port: 0, fetch: (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/fast' || path === '/slow' || path === '/wide') return srv.upgrade(req, { data: { relay: path.slice(1), subs: new Map() } }) ? undefined : new Response('ws', { status: 426 }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +}, websocket: { + open(ws) { sockets.add(ws); }, + message(ws, raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + const relay = ws.data.relay; + if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; } + if (m[0] === 'REQ') { + const filters = m.slice(2); + ws.data.subs.set(m[1], filters); + maxOpen[relay] = Math.max(maxOpen[relay], ws.data.subs.size); + log.push({ t: Date.now(), relay, kind: 'REQ', id: m[1], filters }); + const answer = () => { + if (!ws.data.subs.has(m[1])) return; + for (const e of (relay === 'wide' ? wideStore : stored)) if (filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); + ws.send(JSON.stringify(['EOSE', m[1]])); + log.push({ t: Date.now(), relay, kind: 'EOSE', id: m[1] }); + }; + if (relay === 'slow') setTimeout(answer, SLOW_MS); else answer(); + } else if (m[0] === 'CLOSE') { ws.data.subs.delete(m[1]); log.push({ t: Date.now(), relay, kind: 'CLOSE', id: m[1] }); } + }, + close(ws) { sockets.delete(ws); }, +} }); +const PORT = server.port; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 390, height: 844 }); +await page.evaluateOnNewDocument((port) => { + const Real = window.WebSocket; + window.WebSocket = function (url, p) { + if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/fast`; + else if (/relay\.damus\.io/.test(String(url))) url = `ws://localhost:${port}/slow`; + else if (/nostr\.wine/.test(String(url))) url = `ws://localhost:${port}/wide`; + return p ? new Real(url, p) : new Real(url); + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}, PORT); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const reqs = (relay, pred) => log.filter((x) => x.kind === 'REQ' && x.relay === relay && pred(x.filters)); +const isLive = (fs) => fs.some((f) => f.authors && f.since && !f.limit && f.kinds && f.kinds.includes(1)); +// a subscription meant to stay up: since with no limit, or limit 0 (the DM inbox) +const isSub = (fs) => fs.some((f) => (f.since && !f.limit) || f.limit === 0); + +try { + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await seedWords(page); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await skipSeedCheck(page); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Early Bird', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, cached]); + log.length = 0; maxOpen = { fast: 0, slow: 0 }; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + const t0 = Date.now(); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the quote posts are on screen', await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 6, null, 20000)); + await sleep(9000); // the refresh pass, the quotes' lookups, the reports sync + + console.log('\n[relay lists for the quoted notes\' authors]'); + const qpks = new Set(quoters.map((q) => q.pk)); + const listAsks = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds.length === 1 && f.kinds[0] === 10002 && (f.authors || []).some((a) => qpks.has(a)))); + const covered = new Set(listAsks.flatMap((x) => x.filters.flatMap((f) => f.authors || [])).filter((a) => qpks.has(a))); + check('asked for in one REQ, not one per author', listAsks.length === 1 && covered.size === 6, listAsks.length + ' REQ(s) covering ' + covered.size + ' of 6 authors'); + + console.log('\n[quoted notes]'); + const wideIds = new Set(reqs('wide', (fs) => fs.some((f) => f.ids)).flatMap((x) => x.filters.flatMap((f) => f.ids || []))); + const nearHeld = quotedNotes.filter((n) => wideIds.has(n.id)).length; + check('the ones our relay holds were never asked of the wide net', nearHeld === 0, nearHeld + ' of 6 asked on the far relay'); + check('the one only the far relay holds was found there', wideIds.has(farNote.id) && (await page.evaluate(() => document.body.innerText.includes('kept only on the far relay'))), wideIds.has(farNote.id) ? 'asked' : 'never asked'); + + console.log('\n[the live subscriptions]'); + const live = reqs('fast', isLive); + const liveClosed = live.filter((x) => log.some((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === x.id)); + check('opened once and kept across the refresh', live.length === 1 && liveClosed.length === 0, live.length + ' live REQs, ' + liveClosed.length + ' closed'); + + console.log('\n[one-shot REQs]'); + const fastReqs = reqs('fast', (fs) => !isSub(fs)); + const lag = []; + for (const r of fastReqs) { + const eose = log.find((y) => y.kind === 'EOSE' && y.relay === 'fast' && y.id === r.id); + const close = log.find((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === r.id && y.t >= r.t); + if (eose && close) { lag.push(close.t - eose.t); if (close.t - eose.t > 1500) console.log(' late close ' + (close.t - eose.t) + ' ms: ' + JSON.stringify(r.filters).slice(0, 160)); } + } + const slowLag = lag.filter((x) => x > 1500).length; + check('closed on the fast relay\'s own EOSE, not the slow relay\'s', lag.length >= 10 && slowLag === 0, lag.length + ' closed, ' + slowLag + ' later than 1.5 s (slowest ' + Math.max(0, ...lag) + ' ms; the slow relay holds EOSE ' + SLOW_MS + ' ms)'); + const slowReqs = reqs('slow', () => true).length; + check('no more than ten one-shot REQs open on a relay at once', maxOpen.slow <= 10 + 5, slowReqs + ' REQs to the slow relay, at most ' + maxOpen.slow + ' open at once (fast: ' + maxOpen.fast + ')'); + const totalFast = reqs('fast', () => true).length; + console.log(' ' + totalFast + ' REQs to the fast relay in ' + ((Date.now() - t0) / 1000).toFixed(0) + ' s'); + + console.log('\n[a reload within the half hour]'); + const reportsBefore = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length; + log.length = 0; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + await sleep(6000); + const reportsAfter = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length; + check('the follows\' reports are not fetched again', reportsBefore >= 1 && reportsAfter === 0, reportsBefore + ' REQ(s) on the first open, ' + reportsAfter + ' after the reload'); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the feed asks the relays for less, and asks it in fewer REQs' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/relays-settings-test.js b/tools/relays-settings-test.js index 9e18f59..771e3cd 100644 --- a/tools/relays-settings-test.js +++ b/tools/relays-settings-test.js @@ -3,7 +3,7 @@ // both only after a fresh lookup, newer than the copies it replaces. // No relay is written for real — the WebSocket shim swallows EVENT. // Run: bun tools/relays-settings-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; import { buildHtml } from '../build.js'; @@ -71,9 +71,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const ME_SK = privateKeyFromSeedWords(mn); const ME = getPublicKey(ME_SK); diff --git a/tools/resume-wake-test.js b/tools/resume-wake-test.js new file mode 100644 index 0000000..c142dfb --- /dev/null +++ b/tools/resume-wake-test.js @@ -0,0 +1,111 @@ +// Back from the background on a phone, the app's relay sockets read OPEN +// and are dead (the tab was frozen; the network forgot them), and the +// catch-up it sends on them finds nothing. The library noticed a dead one +// only after its 29 s ping + 20 s timeout, then waited 10 s to redial — a +// Following feed stuck at the morning's posts for a minute or more after +// opening the app in the evening ("no new notes since 11:35?", Adam, +// 2026-11-01). On resume every open socket is now asked one tiny REQ; a +// silent one is closed and redialed at once, its subscriptions refired, and +// the feed refresh runs on the live sockets. Run: bun tools/resume-wake-test.js +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const html = await buildHtml({ minify: true, pwa: false }); +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = () => Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now() - ago, tags: [], content }, SK); +const stored = [post('morning one', 3600), post('morning two', 3000), post('morning three', 2400)]; +const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since); +// one relay; a socket marked dead keeps its connection and answers nothing, +// the way a phone's frozen sockets do once the network has forgotten them +const sockets = new Set(); let opened = 0, closed = 0; +const server = Bun.serve({ port: 0, fetch: (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +}, websocket: { + open(ws) { ws.data = { subs: new Map(), dead: false }; sockets.add(ws); opened++; }, + message(ws, raw) { + if (ws.data.dead) return; + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; } + if (m[0] === 'REQ') { + const s = { id: m[1], filters: m.slice(2) }; + ws.data.subs.set(m[1], s); + for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); + ws.send(JSON.stringify(['EOSE', m[1]])); + } else if (m[0] === 'CLOSE') ws.data.subs.delete(m[1]); + }, + close(ws) { sockets.delete(ws); closed++; }, +} }); +const PORT = server.port; +const liveSubs = () => [...sockets].filter((s) => !s.data.dead).flatMap((s) => [...s.data.subs.values()]); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 390, height: 844 }); +await page.evaluateOnNewDocument((port) => { + const Real = window.WebSocket; + window.WebSocket = function (url, p) { if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}, PORT); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const warns = []; +page.on('console', (m) => { if (/redialing|silent after resume/.test(m.text())) warns.push(m.text()); }); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { const t0 = Date.now(); for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return (Date.now() - t0) / 1000; await sleep(100); } return null; }; +const rowTexts = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 40))); + +try { + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await seedWords(page); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await skipSeedCheck(page); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Early Bird', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, stored]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the morning\'s posts are in the feed', (await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 3, null, 20000)) != null, (await rowTexts()).join(' | ')); + check('the feed watches the relay live', (await waitFor(() => true, null, 100), await (async () => { for (let i = 0; i < 100; i++) { if (liveSubs().some((s) => s.filters.some((f) => f.authors && f.since))) return true; await sleep(100); } return false; })()), liveSubs().length + ' subs on ' + sockets.size + ' sockets'); + const openedBefore = opened; + + console.log('\n[the phone sleeps: its sockets die quietly, a follow posts]'); + for (const s of sockets) s.data.dead = true; + const fresh = post('evening news', 0); + stored.push(fresh); + await page.evaluate((k) => { try { localStorage.setItem('btc-wallet-bg-at', String(Date.now() - 10 * 60_000)); } catch {} }, base); + await sleep(300); + + console.log('\n[back on screen]'); + const t0 = Date.now(); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + const redial = await (async () => { for (let i = 0; i < 150; i++) { if (opened > openedBefore) return (Date.now() - t0) / 1000; await sleep(100); } return null; })(); + check('the dead socket is found out and redialed within seconds', redial != null && redial < 6, redial != null ? redial.toFixed(1) + 's (' + warns[0] + ')' : 'no new socket in 15 s'); + const shown = await waitFor(() => { const p = document.querySelector('.feed-new-pill'); if (p) p.click(); return [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes('evening news')); }, null, 15000); + check('the post from while it slept is on screen within seconds', shown != null && (Date.now() - t0) / 1000 < 12, shown != null ? ((Date.now() - t0) / 1000).toFixed(1) + 's after resume' : (await rowTexts()).join(' | ')); + check('the dead sockets were let go', closed >= 1, closed + ' closed'); + check('…and the live subscriptions are back on the new socket', liveSubs().some((s) => s.filters.some((f) => f.authors && f.since)), liveSubs().length + ' subs'); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ back from the background, dead sockets are redialed at once and the feed catches up' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/room-faces-test.js b/tools/room-faces-test.js index cc4c6fe..ecbcc14 100644 --- a/tools/room-faces-test.js +++ b/tools/room-faces-test.js @@ -4,7 +4,7 @@ // profiles cached — the worst case — against the real coinos community. // // Run: bun tools/room-faces-test.js [--no-hover] -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { buildHtml } from '../build.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; @@ -22,8 +22,8 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); - await click('skip verification'); + await seedWords(page); + await skipSeedCheck(page); await waitText('receive', 20000); await page.evaluate(() => [...document.querySelectorAll('.app-bottom-nav .app-nav-button')][1].click()); await waitText('members', 20000); diff --git a/tools/scheduled-post-test.js b/tools/scheduled-post-test.js new file mode 100644 index 0000000..e4a61f1 --- /dev/null +++ b/tools/scheduled-post-test.js @@ -0,0 +1,99 @@ +// Scheduled posts: the composer's clock button picks a time; Schedule signs +// the note with that created_at and hands it to the notifier (POST +// nwcpush /schedule), nothing goes to a relay, the composer closes and the +// post waits in a "Scheduled" list, where Cancel sends a kind 5 for it to +// /schedule/cancel. The notifier is stubbed. +// Run: bun tools/scheduled-post-test.js +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +import { verifyEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5316, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); return new Response(html, { headers: { 'content-type': 'text/html' } }); } }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { + window.__published = []; + const Real = window.WebSocket; + window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') { window.__published.push(m[1]); setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); return; } } catch {} return send(d); }; return ws; }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}); +const held = new Map(); +const calls = []; +await page.setRequestInterception(true); +page.on('request', (r) => { + const u = new URL(r.url()); + if (u.host !== 'nwcpush.coinos.io' || !u.pathname.startsWith('/schedule')) return r.continue(); + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type' }; + if (r.method() === 'OPTIONS') return r.respond({ status: 200, headers: cors, body: '' }); + const body = r.postData() ? JSON.parse(r.postData()) : null; + calls.push({ path: u.pathname, body }); + let res = { ok: true }; + if (u.pathname === '/schedule') held.set(body.event.id, body.event); + if (u.pathname === '/schedule/cancel') { let n = 0; for (const [k, id] of body.event.tags) if (k === 'e' && held.delete(id)) n++; res = { ok: true, cancelled: n }; } + if (u.pathname === '/schedule/status') res = Object.fromEntries(u.searchParams.get('ids').split(',').map((id) => [id, held.has(id) ? 'pending' : 'gone'])); + r.respond({ status: 200, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(res) }); +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, ms = 15000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5316/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await seedWords(page); + await skipSeedCheck(page); + await waitText('receive', 20000); + + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); + await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000); + await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + await waitFor(() => !!document.querySelector('.post-input'), 8000); + await page.focus('.post-input'); + await page.keyboard.type('later, #coinos', { delay: 10 }); + await page.evaluate(() => document.querySelector('.post-actions button[aria-label="Schedule for later"]').click()); + check('the clock button shows a time field, about an hour ahead', await waitFor(() => !!document.querySelector('.sched-pick input[type=datetime-local]'), 5000)); + const shown = await page.$eval('.sched-pick input', (e) => e.value); + const shownAt = new Date(shown).getTime(); + check('...on a quarter hour', shownAt - Date.now() > 3500_000 && shownAt - Date.now() < 4500_000 && new Date(shownAt).getMinutes() % 15 === 0, shown); + check('the main button reads Schedule', await page.evaluate(() => document.querySelector('.post-actions .btn-primary').textContent.trim() === 'Schedule')); + // two hours ahead, typed as the user would + const want = new Date(Date.now() + 2 * 3600_000); want.setSeconds(0, 0); + const local = new Date(want.getTime() - want.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + await page.evaluate((v) => { const i = document.querySelector('.sched-pick input'); i.value = v; i.dispatchEvent(new Event('input', { bubbles: true })); }, local); + await page.evaluate(() => { window.__published = []; document.querySelector('.post-actions .btn-primary').click(); }); + await waitFor(() => !document.querySelector('.post-input'), 8000); + const sc = calls.find((c) => c.path === '/schedule'); + const ev = sc && sc.body.event; + check('the notifier gets a signed kind 1 dated at the chosen time', !!ev && ev.kind === 1 && verifyEvent(ev) && ev.created_at === Math.floor(want.getTime() / 1000), ev && new Date(ev.created_at * 1000).toISOString()); + check('...with its tags (t coinos, client) and the relays to send it to', !!ev && ev.tags.some((x) => x[0] === 't' && x[1] === 'coinos') && ev.tags.some((x) => x[0] === 'client') && sc.body.relays.length > 0, JSON.stringify(sc?.body.relays)); + check('nothing went to a relay', !(await page.evaluate(() => window.__published.some((e) => e.kind === 1)))); + check('the composer closed and the draft is cleared', await page.evaluate(() => !document.querySelector('.post-input'))); + check('the post is not in the feed yet', await page.evaluate(() => !document.body.innerText.includes('later, #coinos') || !!document.querySelector('.sched-list')) && !(await page.evaluate(() => [...document.querySelectorAll('.note-text')].some((n) => n.textContent.includes('later, #coinos'))))); + + // the list shows with the composer open and on your profile + await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + check('the composer lists it under Scheduled (1)', await waitFor(() => /Scheduled \(1\)/.test(document.querySelector('.sched-list')?.innerText || ''), 5000)); + check('...with its words', await page.evaluate(() => document.querySelector('.sched-item').innerText.includes('later, #coinos'))); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); + await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000); + await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + check('it survives a reload (and the status check keeps it)', await waitFor(() => !!document.querySelector('.sched-item'), 8000) && calls.some((c) => c.path === '/schedule/status')); + await page.evaluate(() => [...document.querySelectorAll('.sched-item button')].find((b) => b.textContent.trim() === 'Cancel').click()); + await waitFor(() => !document.querySelector('.sched-item'), 8000); + const cc = calls.find((c) => c.path === '/schedule/cancel'); + check('Cancel sends a kind 5 naming it, signed by the author', !!cc && cc.body.event.kind === 5 && verifyEvent(cc.body.event) && cc.body.event.pubkey === ev.pubkey && cc.body.event.tags.some((x) => x[0] === 'e' && x[1] === ev.id)); + check('...and it leaves the list', await page.evaluate(() => !document.querySelector('.sched-list'))); + check('no page errors', !errs.length, errs.join(' | ')); +} catch (e) { check('ran', false, e.stack); } +await browser.close(); server.stop(); +console.log(ok ? '\nALL PASS' : '\nFAILED'); +process.exit(ok ? 0 : 1); diff --git a/tools/seed-cache-test.js b/tools/seed-cache-test.js new file mode 100644 index 0000000..6250d65 --- /dev/null +++ b/tools/seed-cache-test.js @@ -0,0 +1,60 @@ +// The seed a phrase stretches to, and the vault under the blank password: +// both now come from caches, and must come out exactly as before. +// seed.js: WebCrypto's PBKDF2 (warmSeed) gives @scure/bip39's seed, byte +// for byte, for plain, passphrased and non-ASCII (NFKD) input; seedSync +// finds it waiting, and works cold. nostr.js's NIP-06 key matches +// nostr-tools' privateKeyFromSeedWords. +// wallet.js: a blank vault's key is kept and reused (no scrypt on the +// second open); a vault sealed under a real password keeps nothing. +// Run: bun tools/seed-cache-test.js +import assert from 'node:assert/strict'; +import { mnemonicToSeedSync, generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english.js'; +import * as nip06 from 'nostr-tools/nip06'; + +const store = new Map(); +globalThis.localStorage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) }; + +const { seedSync, warmSeed, forgetSeeds } = await import('../src/seed.js'); +const { nostrKeyFromSeed } = await import('../src/nostr.js'); +const { encryptVault, decryptVault } = await import('../src/wallet.js'); +const hex = (u) => Buffer.from(u).toString('hex'); + +const cases = [ + [generateMnemonic(wordlist), ''], + [generateMnemonic(wordlist, 256), 'correct horse'], + [generateMnemonic(wordlist), 'pässwörd Ⅸ'], // NFKD changes these +]; +for (const [m, p] of cases) { + forgetSeeds(); + await warmSeed(m, p); + const t = performance.now(); + const warm = seedSync(m, p); + assert.ok(performance.now() - t < 5, 'a warmed seed is read, not stretched'); + assert.equal(hex(warm), hex(mnemonicToSeedSync(m, p)), 'WebCrypto PBKDF2 = @scure/bip39'); + forgetSeeds(); + assert.equal(hex(seedSync(m, p)), hex(mnemonicToSeedSync(m, p)), 'cold seedSync = @scure/bip39'); + for (const i of [0, 3]) assert.equal(hex(nostrKeyFromSeed(m, p, i)), hex(nip06.privateKeyFromSeedWords(m, p || undefined, i)), 'NIP-06 key = nostr-tools'); +} +await warmSeed('not a phrase', ''); +assert.throws(() => seedSync('not a phrase', ''), 'a malformed phrase is still refused'); +console.log('✓ seeds match @scure/bip39 and nostr-tools, warmed or cold'); + +const list = [{ label: 'a', mnemonic: cases[0][0], passphrase: '' }]; +const blob = encryptVault(list, ''); +assert.ok(store.get('btc-wallet-vault-k0'), 'the blank vault key is kept'); +let t = performance.now(); +assert.deepEqual(decryptVault(blob, ''), list); +assert.ok(performance.now() - t < 30, `opening a blank vault needs no scrypt (${Math.round(performance.now() - t)} ms)`); +const blob2 = encryptVault(list, ''); +assert.equal(blob2.salt, blob.salt, 'a new blank seal reuses the kept salt'); +assert.notEqual(blob2.nonce, blob.nonce, 'never the nonce'); +assert.deepEqual(decryptVault(blob2, ''), list); +assert.throws(() => decryptVault(blob, 'guess'), 'a wrong password still fails'); +assert.ok(store.get('btc-wallet-vault-k0'), 'and a failed guess drops nothing'); +const sealed = encryptVault(list, 'hunter2'); +assert.equal(store.get('btc-wallet-vault-k0'), undefined, 'sealing under a real password keeps nothing'); +assert.deepEqual(decryptVault(sealed, 'hunter2'), list); +assert.throws(() => decryptVault(sealed, '')); +console.log('✓ blank vault key kept and reused; real passwords untouched'); +console.log('\n✅ seed + vault caches behave'); diff --git a/tools/sensitive-feed-test.js b/tools/sensitive-feed-test.js index a050269..626da2c 100644 --- a/tools/sensitive-feed-test.js +++ b/tools/sensitive-feed-test.js @@ -4,7 +4,7 @@ // accounts whose own profile says they post adult work. Elsewhere a marked // post is folded behind its warning with a Show. // Run: bun tools/sensitive-feed-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import { buildHtml, buildVerifyWorker } from '../build.js'; @@ -74,9 +74,9 @@ try { await sleep(400); const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await page.waitForFunction(() => /receive/i.test(document.body.innerText), { timeout: 20000 }); await sleep(3000); // the starter follows land first const base = cacheKeyFor(mn + '\n'); diff --git a/tools/signer-resume-test.js b/tools/signer-resume-test.js new file mode 100644 index 0000000..9ca3950 --- /dev/null +++ b/tools/signer-resume-test.js @@ -0,0 +1,140 @@ +// A remote signer (Amethyst) that wasn't heard the moment the app needed it +// — the tab was frozen, a socket cut, the signer app asleep — put up +// "Reconnect your signer" and then never tried the stored session again, so +// every time meant a brand-new connection (Adam's phone, 2026-10-29: the +// stored session answered in 0.7 s the moment anyone asked). Now the screen +// keeps trying the stored session and closes itself once the signer answers. +// The remote signer is played by this script over a shimmed relay. +// Run: bun tools/signer-resume-test.js +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import * as nip44 from 'nostr-tools/nip44'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const SK = generateSecretKey(); const PK = getPublicKey(SK); // the account, held by the remote signer +const CLIENT_SK = generateSecretKey(); const CLIENT = getPublicKey(CLIENT_SK); // the client key it authorised long ago + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5319, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p === '/nip46.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); return new Response(html, { headers: { 'content-type': 'text/html' } }); } }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Mobile Safari/537.36'); +await page.evaluateOnNewDocument((fx) => { + window.__amberUrls = []; + window.__coinosAmberLaunch = (u) => { window.__amberUrls.push(u); }; + window.__published = []; + const subs = []; + const fits = (ev, f) => (!f.kinds || f.kinds.includes(ev.kind)) && (!f['#p'] || ev.tags.some((t) => t[0] === 'p' && f['#p'].includes(t[1]))) + && (!f.authors || f.authors.includes(ev.pubkey)) && (!f.since || ev.created_at >= f.since); + // the test pushes the signer's answers through here, to every live sub that wants them + window.__inject = (ev) => { for (const s of subs) if (s.fs.some((f) => fits(ev, f))) s.ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', s.id, ev]) })); }; + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (d) => { + try { + const m = JSON.parse(d); + if (m[0] === 'REQ') { + const fs = m.slice(2); + for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1); + subs.push({ ws, id: m[1], fs }); + const evs = fx.filter((ev) => fs.some((f) => fits(ev, f))); + setTimeout(() => { + for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })); + }, 0); + return; + } + if (m[0] === 'CLOSE') { for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1); return; } + if (m[0] === 'EVENT') { + window.__published.push(m[1]); + setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); + return; + } + } catch {} + return send(d); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}, []); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const text = () => page.evaluate(() => document.body.innerText); +const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; + +// The remote signer: answers the connect, then get_public_key / nip44_decrypt. +let client = CLIENT, awake = false, handled = new Set(), decrypts = 0; +const signerLoop = async () => { + if (!awake) return; + const pub = await page.evaluate(() => window.__published.filter((e) => e.kind === 24133)); + for (const ev of pub) { + if (handled.has(ev.id) || !client || ev.pubkey !== client) continue; + handled.add(ev.id); + const ck = nip44.getConversationKey(SK, ev.pubkey); + const req = JSON.parse(nip44.decrypt(ev.content, ck)); + let result; + if (req.method === 'get_public_key') result = PK; + else if (req.method === 'nip44_decrypt') { decrypts++; result = nip44.decrypt(req.params[1], nip44.getConversationKey(SK, req.params[0])); } + else if (req.method === 'nip44_encrypt') result = nip44.encrypt(req.params[1], nip44.getConversationKey(SK, req.params[0])); + else if (req.method === 'sign_event') { const u = JSON.parse(req.params[0]); result = JSON.stringify(finalizeEvent(u, SK)); } + else if (req.method === 'ping') result = 'pong'; + else if (req.method === 'connect') result = 'ack'; + const resp = finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [['p', ev.pubkey]], content: nip44.encrypt(JSON.stringify({ id: req.id, result }), ck) }, SK); + await page.evaluate((e) => window.__inject(e), resp); + } +}; + + +const waitFor = async (fn, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +const pump = async (ms) => { for (let i = 0; i < ms / 250; i++) { await signerLoop(); await sleep(250); } }; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5319/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await seedWords(page); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await skipSeedCheck(page); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + const local = Array.from(CLIENT_SK, (b) => b.toString(16).padStart(2, '0')).join(''); + await page.evaluate(([k, pk, local]) => { + localStorage.setItem(k + ':nostrlogin', JSON.stringify({ pubkey: pk, linked: Date.now(), via: 'bunker', + session: { local, bp: { pubkey: pk, relays: ['wss://relay.coinos.io'], secret: null } } })); + }, [base, PK, local]); + + console.log('\n[the signer is asleep when the app needs it]'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); + await waitFor(() => !!document.querySelector('.feed-post-fab')); + await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + await waitFor(() => !!document.querySelector('.post-input'), 8000); + await page.focus('.post-input'); + await page.keyboard.type('hello', { delay: 10 }); + await page.evaluate(() => document.querySelector('.post-actions .btn-primary').click()); + check('the reconnect screen comes up', await waitText('reconnect your signer', 45000)); + await sleep(16000); // let the screen's first try run out unanswered + check('...and stays while the signer is silent', (await text()).toLowerCase().includes('reconnect your signer')); + + console.log('\n[the signer wakes up]'); + awake = true; + const t0 = Date.now(); + let gone = false; + for (let i = 0; i < 160 && !gone; i++) { await signerLoop(); gone = !(await text()).toLowerCase().includes('reconnect your signer'); if (!gone) await sleep(250); } + check('the screen closes by itself, no new connection', gone, gone ? `${Math.round((Date.now() - t0) / 1000)} s after the signer answered` : 'still up after 40 s'); + const st = await page.evaluate((k) => JSON.parse(localStorage.getItem(k + ':nostrlogin') || '{}'), base); + check('the stored session is the same one', st.session && st.session.local === local); + check('no nostrconnect link was needed', !(await page.evaluate((pk) => window.__published.some((e) => e.kind === 24133 && e.pubkey !== pk), CLIENT))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a stored remote-signer session comes back by itself' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/slow-image-test.js b/tools/slow-image-test.js index 103f6da..43eff97 100644 --- a/tools/slow-image-test.js +++ b/tools/slow-image-test.js @@ -4,7 +4,7 @@ // inside a fixed box (nothing moves when it lands); a picture that refused to // load is still a link. // Run: bun tools/slow-image-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -46,9 +46,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/standalone-file-test.js b/tools/standalone-file-test.js index 743bc7d..13445f6 100644 --- a/tools/standalone-file-test.js +++ b/tools/standalone-file-test.js @@ -3,7 +3,7 @@ // Run: bun run build && bun tools/standalone-file-test.js import assert from 'node:assert/strict'; import { resolve } from 'node:path'; -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); try { @@ -25,7 +25,7 @@ try { await page.evaluate(() => { const s = [...document.querySelectorAll('select')].find((x) => [...x.options].some((o) => o.value === 'en')); s.value = 'en'; s.dispatchEvent(new Event('change', { bubbles: true })); }); await sleep(500); await click('create a new wallet'); await sleep(1500); - await click('skip verification'); await sleep(1500); + await skipSeedCheck(page); await sleep(1500); // the avatar step comes after a name claim; resume the wizard there await page.evaluate(() => localStorage.setItem('btc-wallet-onb-step', 'avatar')); await page.reload(); await sleep(2500); diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js index e7d00f2..798b913 100644 --- a/tools/sw-push-quota.test.js +++ b/tools/sw-push-quota.test.js @@ -4,8 +4,9 @@ import { test, expect, mock } from 'bun:test'; let respond = async () => true; -mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {} })); -mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn() })); +mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {}, tendLnPays: async () => [] })); +mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn(), withWorkLock: async (fn) => fn() })); +mock.module('../src/nwc-bg.js', () => ({ appendBgLog: async () => {}, takeLease: async () => ({ waited: 0, release: async () => {} }) })); mock.module('../src/bg-autowithdraw.js', () => ({ bgAutoWithdraw: async () => {} })); mock.module('../src/dm-inbox.js', () => ({ allInboxes: async () => [{ muted: ['m'.repeat(64)] }], @@ -50,6 +51,13 @@ test('apple: every quiet path shows the stand-in', async () => { expect(closed.length).toBe(0); }); +test('apple: a send finished from an earlier wake shows its own line and drops the stand-in', async () => { + endpoint = 'https://web.push.apple.com/abc'; windows = []; + respond = async () => ({ notices: [{ title: 'Payment sent', body: '-21 sats via a connected app', tag: 'nwc-sent' }] }); + expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']); + expect(closed.length).toBe(1); +}); + test('apple: an app on screen gets the stand-in closed at once', async () => { windows = [visible]; expect(await push({ type: 'notify', reason: 'payment', amountSat: 5 })).toEqual(['Coinos']); @@ -60,7 +68,9 @@ test('apple: a suspended background app does not swallow the request', async () windows = [hidden]; let asked = 0; respond = async () => { asked++; return { notify: { title: 'Payment sent', body: '' } }; }; - expect(await push(nwc)).toEqual(['Payment sent']); + // the stand-in goes up first and the outcome replaces it (91dbee4) + expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']); + expect(closed.length).toBe(1); expect(asked).toBe(1); }); @@ -72,8 +82,33 @@ test('elsewhere: quiet stays quiet', async () => { let asked = 0; respond = async () => { asked++; return true; }; expect(await push(nwc)).toEqual([]); - expect(asked).toBe(0); // the open (if hidden) window answers on Android + expect(asked).toBe(0); // on a computer the open (if hidden) window answers windows = []; respond = async () => false; - expect(await push(nwc)).toEqual(['Payment request']); + expect(await push(nwc)).toEqual(['Spend request']); +}); + +// An Android phone freezes an app behind another one, minutes in: its relay +// sockets are dead and the worker's nudge sits unread until it is opened, so +// a zap sent from another app spun until then (wake to payment: 15 s at the +// median on Android, 2026-11-10). On a phone only a window on screen is +// handed the request; behind another app, the worker answers (and nudges the +// window anyway — the request's lock decides if it is still awake). +test('an Android phone: the app behind another one does not swallow the request', async () => { + endpoint = 'https://fcm.googleapis.com/fcm/send/abc'; + self.navigator = { userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Mobile Safari/537.36' }; + try { + let asked = 0, nudged = 0; + respond = async () => { asked++; return true; }; + windows = [{ visibilityState: 'hidden', postMessage: () => { nudged++; } }]; + expect(await push(nwc)).toEqual([]); + expect(asked).toBe(1); + expect(nudged).toBe(1); + // on screen, it answers itself with everything it knows + asked = 0; nudged = 0; + windows = [{ visibilityState: 'visible', postMessage: () => { nudged++; } }]; + expect(await push(nwc)).toEqual([]); + expect(asked).toBe(0); + expect(nudged).toBe(1); + } finally { delete self.navigator; } }); diff --git a/tools/sw-reload-test.js b/tools/sw-reload-test.js index 6ad4fed..d626514 100644 --- a/tools/sw-reload-test.js +++ b/tools/sw-reload-test.js @@ -1,11 +1,12 @@ -// A deploy should not refresh the page out from under you. +// Opening the app is instant, and a deploy never refreshes the page out from +// under you. // -// The app is inlined in the page, so when a new worker takes over, a page -// still running the old build has to reload. But navigations are -// network-first: the refresh that triggered the update has usually already -// fetched the new page, and reloading then is a second, unasked-for refresh a -// beat after your own. Both halves are asserted here — the quiet case and the -// case where a reload is genuinely owed. +// Navigations to / are cache-first: a launch paints the installed build's +// shell without waiting on the network, and a deploy that lands meanwhile +// installs in the background and opens on the next launch. A refresh goes to +// the network and gets the new build at once, without a second reload. A page +// that installs a newer build while open reloads only if nothing was touched, +// and only as it is put away. // // Run: bun tools/sw-reload-test.js (needs a built dist/) import puppeteer from './puppeteer.js'; @@ -21,15 +22,25 @@ if (!token) { console.log('no build token in dist/index.html — run bun run bui // A "new deploy" is the same app under a different build token. const restamp = (tok) => ({ html: html1.replaceAll(token, tok), sw: sw1.replaceAll(token, tok) }); -const builds = { v1: { html: html1, sw: sw1 }, v2: restamp('deadbeef00001'), v3: restamp('deadbeef00002') }; +const builds = { v1: { html: html1, sw: sw1 }, v2: restamp('deadbeef00001'), v3: restamp('deadbeef00002'), v4: restamp('deadbeef00003') }; let current = builds.v1; +let pageHits = 0; // network requests for the page itself +let preloads = 0; const server = Bun.serve({ port: 5223, fetch(req) { const p = new URL(req.url).pathname; if (p === '/sw.js') return new Response(current.sw, { headers: { 'content-type': 'text/javascript', 'cache-control': 'no-store' } }); - if (p === '/' || p === '/index.html') return new Response(current.html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } }); + if (p === '/' || p === '/index.html') { + pageHits++; + // as cs nginx does: a launch's preload (no Cache-Control) gets no body + if (req.headers.get('service-worker-navigation-preload')) { + preloads++; + if (!req.headers.get('cache-control')) return new Response(null, { status: 204 }); + } + return new Response(current.html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } }); + } // The worker precaches the shell during install and addAll rejects on any // miss — a 404 here means it never activates and the test proves nothing. // The split build names its entry after the token (app-<token>.js), so @@ -43,9 +54,9 @@ const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome const page = await browser.newPage(); let loads = 0; page.on('load', () => { loads++; }); -page.on('console', (m) => { if (m.type() === 'error') console.log(' [page]', m.text().slice(0, 160)); }); page.on('pageerror', (e) => console.log(' [pageerror]', e.message.slice(0, 160))); +const running = () => page.evaluate(() => (document.documentElement.outerHTML.match(/_v='([a-z0-9]+)'/) || [])[1]); const controlled = async (ms = 15000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(() => !!navigator.serviceWorker.controller)) return true; @@ -53,45 +64,81 @@ const controlled = async (ms = 15000) => { } return false; }; +// the worker's own build, once the newest one has taken over +const workerBuild = () => page.evaluate(() => new Promise((res) => { + const c = navigator.serviceWorker.controller; + if (!c) return res(null); + const on = (e) => { if (e.data && e.data.type === 'sw-version') { navigator.serviceWorker.removeEventListener('message', on); res(e.data.version); } }; + navigator.serviceWorker.addEventListener('message', on); + c.postMessage({ type: 'sw-version' }); + setTimeout(() => res(null), 2000); +})); +const waitWorker = async (tok) => { for (let i = 0; i < 60; i++) { if ((await workerBuild()) === tok) return true; await sleep(250); } return false; }; +const putAway = () => page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); + document.dispatchEvent(new Event('visibilitychange')); +}); try { console.log('[first visit]'); await page.goto('http://localhost:5223/', { waitUntil: 'load' }); - const reg = await page.evaluate(async () => { - try { - const r = await navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }); - return { ok: true, scope: r.scope, active: !!r.active, installing: !!r.installing, waiting: !!r.waiting }; - } catch (e) { return { ok: false, err: String(e).slice(0, 200) }; } - }); - console.log(' register:', JSON.stringify(reg)); + await page.evaluate(() => navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' })); check('worker takes control', await controlled()); await sleep(3000); check('a first visit does not reload itself', loads === 1, `${loads} load(s)`); - console.log('\n[refreshing after a deploy]'); + console.log('\n[opening the app after a deploy]'); current = builds.v2; - const before = loads; + let hits = pageHits, pre = preloads, before = loads; + await page.goto('http://localhost:5223/?u=x', { waitUntil: 'load' }); + check('the launch paints the installed build', (await running()) === token, `running ${await running()}`); + check('without fetching the page (only an empty launch preload)', pageHits - hits === preloads - pre, `${pageHits - hits} page request(s), ${preloads - pre} of them preloads`); + check('the new build installs meanwhile', await waitWorker('deadbeef00001')); + await sleep(1000); + check('and does not reload a page on screen', loads === before + 1, `${loads - before} load(s)`); + before = loads; + await page.goto('http://localhost:5223/', { waitUntil: 'load' }); + check('the next launch opens the new build', (await running()) === 'deadbeef00001', `running ${await running()}`); + + console.log('\n[refreshing after a deploy]'); + current = builds.v3; + before = loads; await page.reload({ waitUntil: 'load' }); - await sleep(6000); // well past install + activate + claim + check('a refresh gets the new build at once', (await running()) === 'deadbeef00002', `running ${await running()}`); + await waitWorker('deadbeef00002'); + await sleep(2000); check('your refresh is the only refresh', loads === before + 1, `${loads - before} load(s) — one is yours`); - check('and the new build is what is running', await page.evaluate(() => document.documentElement.outerHTML.includes("_v='deadbeef00001'"))); - console.log('\n[a deploy while the page sits open]'); - current = builds.v3; - const before2 = loads; - // no navigation: just let the worker notice, the way the periodic update - // check would + console.log('\n[a deploy while the page sits open, untouched]'); + current = builds.v4; + before = loads; await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update())); - for (let i = 0; i < 40 && loads === before2; i++) await sleep(250); - check('a stale page still gets reloaded', loads === before2 + 1, `${loads - before2} reload(s)`); - await sleep(3000); - check('and only once', loads === before2 + 1, `${loads - before2} reload(s)`); - check('now running the newest build', await page.evaluate(() => document.documentElement.outerHTML.includes("_v='deadbeef00002'"))); + check('the new build installs', await waitWorker('deadbeef00003')); + await sleep(1000); + check('no reload while on screen', loads === before, `${loads - before} reload(s)`); + await putAway(); + for (let i = 0; i < 40 && loads === before; i++) await sleep(250); + check('it reloads as it is put away', loads === before + 1, `${loads - before} reload(s)`); + check('now running the newest build', (await running()) === 'deadbeef00003'); + + console.log('\n[a deploy while someone is using the page]'); + current = builds.v1; + before = loads; + await page.mouse.click(5, 5); + await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update())); + check('the new build installs', await waitWorker(token)); + await putAway(); + await sleep(2000); + check('a page in use is never reloaded', loads === before, `${loads - before} reload(s)`); + check('an older build\'s chunks are still cached', await page.evaluate(async () => { + const tok = (document.documentElement.outerHTML.match(/_v='([a-z0-9]+)'/) || [])[1]; + return !!(await caches.match('app-' + tok + '.js')); + })); } catch (e) { check('run completed', false, e.message); } finally { await browser.close(); server.stop(true); } -console.log(ok ? '\n✅ sw reload behaviour is right' : '\n❌ failures above'); +console.log(ok ? '\n✅ sw launch + reload behaviour is right' : '\n❌ failures above'); process.exit(ok ? 0 : 1); diff --git a/tools/thread-instant-test.js b/tools/thread-instant-test.js index bd80408..e9a9138 100644 --- a/tools/thread-instant-test.js +++ b/tools/thread-instant-test.js @@ -3,7 +3,7 @@ // is seeded from them — the relays only add to it. A thread with nothing in // hand paints its replies as they arrive, not after the slowest relay. // Run: bun tools/thread-instant-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -95,9 +95,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, alice, bob, carol, notes]) => { diff --git a/tools/thread-like-count-test.js b/tools/thread-like-count-test.js new file mode 100644 index 0000000..6d81b3f --- /dev/null +++ b/tools/thread-like-count-test.js @@ -0,0 +1,91 @@ +// The heart's number in a thread matches the people the who-panel lists. +// A thread remembers its counts between visits; a reaction that arrived +// through the feed's own count fetch before the thread was opened again +// must raise the remembered number, not be outvoted by it. +// Run: bun tools/thread-like-count-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { generateSecretKey, finalizeEvent } from 'nostr-tools/pure'; +import { nip19 } from 'nostr-tools'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000); +const notes = Array.from({ length: 12 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 60 - i * 60, tags: [], + content: `Post number ${i}. The tide was out and the gulls had the whole beach to themselves.` }, sk)); +const target = notes[0]; +const react = (n) => finalizeEvent({ kind: 7, created_at: now - 30 + n, content: '+', tags: [['e', target.id], ['p', target.pubkey]] }, generateSecretKey()); +const reactions = [react(1), react(2)]; +const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Poster' }) }, sk); +const html = await buildHtml({ minify: true, pwa: false }); +const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes, profiles: [profile], popUntil: now - 3600 }); +const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/api/feed' + ? new Response(snapshot, { headers: { 'content-type': 'application/json' } }) + : new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); + // The relay serves as many of the two reactions as localStorage says + // (one on the first visit, both on the second). + await page.evaluateOnNewDocument(([notes, profile, reactions]) => { + class FakeSocket extends EventTarget { + constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set(); + setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); } + emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); } + message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); } + send(raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; } + if (m[0] !== 'REQ') return; + const [, id, f] = m; this.subs.add(id); + const n = Number(localStorage.getItem('test-reactions') || 1); + let evs = []; + if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until) + && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))).slice(0, f.limit || 100); + if (f.kinds?.includes(0) && f.authors?.includes(profile.pubkey)) evs = [profile]; + if (f['#e']) evs = f.kinds?.includes(7) ? reactions.slice(0, n).filter((r) => f['#e'].includes(r.tags[0][1])) : []; + if (f.ids) evs = notes.filter((e) => f.ids.includes(e.id)); + setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30); + } + close() { this.readyState = 3; this.emit('close', new Event('close')); } + } + Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); + window.WebSocket = FakeSocket; + }, [notes, profile, reactions]); + const heartSel = (scope, id) => `${scope} .note-post[data-zap-post="${id}"] .note-acts > .note-act:nth-child(4) .note-act-n`; + const heart = (scope, id) => page.evaluate((s) => document.querySelector(s)?.textContent || '', heartSel(scope, id)); + const openRow = (id) => page.evaluate((id) => document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), id); + const visit = async (n, path = '/feed') => { + await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' }); + await page.evaluate((n) => localStorage.setItem('test-reactions', String(n)), n); + await page.goto(server.url.origin + path, { waitUntil: 'domcontentloaded' }); + }; + // first visit: the thread itself, from a link (a notification, say), with + // one heart on the relay — the count answer lands while the thread is + // open, so the thread remembers it + await visit(1, '/' + nip19.neventEncode({ id: target.id })); + await page.waitForFunction((s) => document.querySelector(s)?.textContent === '1', { timeout: 15000 }, heartSel('.thread-page', target.id)); + await sleep(1500); // the thread is persisted + // second visit: the feed, where the count fetch brings both hearts; the + // thread is then opened from the row + await visit(2); + await page.waitForFunction(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 5, { timeout: 20000 }); + await page.waitForFunction((s) => document.querySelector(s)?.textContent === '2', { timeout: 10000 }, heartSel('.notes-feed', target.id)); + await openRow(target.id); + await page.waitForFunction((s) => document.querySelector(s), { timeout: 10000 }, heartSel('.thread-page', target.id)); + await sleep(800); + const n = await heart('.thread-page', target.id); + const who = await page.evaluate((id) => { + const row = document.querySelector('.thread-page .note-post[data-zap-post="' + id + '"]'); + row.querySelector('.note-acts > .note-act:last-child').click(); + return new Promise((r) => setTimeout(() => r(row.querySelectorAll('.note-who-person').length), 300)); + }, target.id); + assert.equal(who, 2, 'the panel lists both people'); + assert.equal(n, '2', `the heart in the thread says 2, not the remembered 1 (got "${n}")`); + assert.deepEqual(errors, []); + console.log('✓ the thread\'s heart count catches up with the people who reacted'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/thread-reply-test.js b/tools/thread-reply-test.js index 738a31c..f3bda91 100644 --- a/tools/thread-reply-test.js +++ b/tools/thread-reply-test.js @@ -78,6 +78,27 @@ try { await page.click('#app .note-act'); check('Reply immediately shows its composer beneath the tapped post', await inline()); check('the input receives focus before relay queries finish', await page.evaluate(() => document.activeElement.matches('.thread-reply-input'))); + // Cancel with text typed: the box folds and the draft is gone (it used to + // stay open — the box shows while a draft has text, and Cancel only + // cleared the replying flag) + await page.type('.thread-reply-input', 'Half a thought'); + await page.evaluate(() => document.querySelector('.thread-reply-cancel').click()); + check('Cancel with text typed hides the box and discards the draft', await page.evaluate(() => + !document.querySelector('.thread-reply-input') && !(test.ui.noteThread.draft || '').length)); + await page.evaluate(() => document.querySelector('#app .note-act').click()); // one atomic tap, as below + await page.waitForSelector('.thread-reply-input'); + check('Reply again opens an empty field', await page.evaluate(() => document.querySelector('.thread-reply-input')?.value === '')); + // a phone: Return is a new line, not a send + await page.evaluate(() => { const mm = window.matchMedia; window._mm = mm; window.matchMedia = (q) => q === '(pointer: coarse)' ? { matches: true } : mm(q); }); + await page.type('.thread-reply-input', 'Line one'); + await page.keyboard.press('Enter'); + await page.type('.thread-reply-input', 'two'); + check("on a phone, Return adds a line instead of sending", await page.evaluate(() => + document.querySelector('.thread-reply-input')?.value === 'Line one\ntwo' && sent.length === 0), await page.evaluate(() => document.querySelector('.thread-reply-input')?.value)); + await page.evaluate(() => { window.matchMedia = window._mm; }); + await page.focus('.thread-reply-input'); + await page.keyboard.down('Control'); await page.keyboard.press('a'); await page.keyboard.up('Control'); + await page.keyboard.press('Backspace'); await page.type('.thread-reply-input', 'A reply before the root loads'); // (one atomic tap: a repaint landing between finding the button and clicking it replaces it) await page.waitForFunction(() => !document.querySelector('.thread-reply-send')?.disabled); diff --git a/tools/translate-phone-lang-test.js b/tools/translate-phone-lang-test.js index 9e1eefc..28676eb 100644 --- a/tools/translate-phone-lang-test.js +++ b/tools/translate-phone-lang-test.js @@ -6,7 +6,7 @@ // the target and takes a DeepL key, which then rides with each request. // The registrar's /translate is stubbed (window.fetch shim). // Run: bun tools/translate-phone-lang-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -57,9 +57,9 @@ try { await page.evaluate(() => { localStorage.setItem('btc-wallet-network', 'regtest'); localStorage.setItem('btc-wallet-lang', 'en'); }); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/translate-test.js b/tools/translate-test.js index d3fe095..f00ba22 100644 --- a/tools/translate-test.js +++ b/tools/translate-test.js @@ -3,7 +3,7 @@ // with the switch off they offer Translate instead; ⋯ → Translate asks for // any post. The registrar's /translate is stubbed here (window.fetch shim). // Run: bun tools/translate-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -70,9 +70,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/video-autoplay-test.js b/tools/video-autoplay-test.js index 6b943f2..6d245b3 100644 --- a/tools/video-autoplay-test.js +++ b/tools/video-autoplay-test.js @@ -4,7 +4,7 @@ // loaded for a box that never comes into view. Set CLIP to a webm to serve; // the default is made with ffmpeg on the fly. // Run: bun tools/video-autoplay-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -53,9 +53,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/video-compress-test.js b/tools/video-compress-test.js index 19735c8..2b83edc 100644 --- a/tools/video-compress-test.js +++ b/tools/video-compress-test.js @@ -4,7 +4,7 @@ // upload is caught and its bytes checked with ffprobe. // // Run: bun tools/video-compress-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; @@ -73,8 +73,8 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(600); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); - await click('skip verification'); + await seedWords(page); + await skipSeedCheck(page); await waitText('receive', 20000); check('the browser can encode H.264 (WebCodecs)', await page.evaluate(async () => (await VideoEncoder.isConfigSupported({ codec: 'avc1.42001f', width: 1280, height: 720, bitrate: 2.5e6 })).supported)); // the post composer: the pencil on the feed diff --git a/tools/video-events-test.js b/tools/video-events-test.js index bef0956..56ea486 100644 --- a/tools/video-events-test.js +++ b/tools/video-events-test.js @@ -6,7 +6,7 @@ // version. A note linking such a file, typed video/* in its imeta, plays it // too. And the clips are in the pager. // Run: bun tools/video-events-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -90,9 +90,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk]) => { @@ -128,6 +128,8 @@ try { await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }); await sleep(500); const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); check('a tap opens the pager', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); const slides = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('|')[1].replace(location.origin, ''))); diff --git a/tools/video-fallback-test.js b/tools/video-fallback-test.js index 93726e1..ff45846 100644 --- a/tools/video-fallback-test.js +++ b/tools/video-fallback-test.js @@ -4,7 +4,7 @@ // post gave no poster wears the server's still. The server (vmedia) is // stood in for here: its answers are canned. // Run: bun tools/video-fallback-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -81,9 +81,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/video-gone-test.js b/tools/video-gone-test.js new file mode 100644 index 0000000..48f2956 --- /dev/null +++ b/tools/video-gone-test.js @@ -0,0 +1,129 @@ +// A post whose clip its host no longer has (deleted from Primal's blossom: +// a 404 with no CORS headers, which the probe can't read) sat in the feed +// as a dead player under "Tap to play" that did nothing when tapped — +// "Video in this post won't play" (Adam, 2026-11-01). The player is the +// first to know the file is gone: it now says so where it stood, with the +// file's link, its controls and cover gone; the next paint of the post says +// it at once, with no request. A clip on a live host plays as before. +// Run: bun tools/video-gone-test.js +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { existsSync } from 'node:fs'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const GOOD = '/tmp/coinos-vgone-good.webm'; +if (!existsSync(GOOD)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=10', '-t', '8', '-c:v', 'libvpx', '-b:v', '100k', GOOD]); +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const html = await buildHtml({ minify: !process.env.VGONE_LOG, pwa: false }); +const app = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +// the clips' host: another origin, no CORS headers at all (Primal's blossom) +let deadHits = 0; +const host = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path === '/dead.mp4') { deadHits++; if (process.env.VGONE_LOG) console.error('dead.mp4 asked:', req.method, req.headers.get('range') || '-', req.headers.get('sec-fetch-dest'), req.headers.get('sec-fetch-mode')); return new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } }); } + if (path === '/good.webm') { + const f = Bun.file(GOOD), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || ''); + if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } }); + const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1; + return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } }); + } + return new Response('no', { status: 404 }); +} }); +const DEAD = host.url.origin + '/dead.mp4', LIVE = host.url.origin + '/good.webm'; +const post = (content, url, ago, m) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'm ' + m]], content }, SK); +const seeded = [post('lol beat it ' + DEAD, DEAD, 100, 'video/mp4'), post('a clip that is there ' + LIVE, LIVE, 200, 'video/webm'), + ...Array.from({ length: 4 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 600 - i * 60, tags: [], content: 'filler ' + i + ' lorem ipsum dolor sit amet. '.repeat(4) }, SK))]; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { + const Real = window.WebSocket; + window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +if (process.env.VGONE_LOG) page.on('request', (r) => { if (r.url().endsWith('/dead.mp4')) console.error('REQ', r.resourceType(), JSON.stringify(r.initiator()).slice(0, 900)); }); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const boxOf = (word) => page.evaluate((w) => { + const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); + const b = r && r.querySelector('.note-video-box'); if (!b) return null; + const v = b.querySelector('video'); + return { gone: b.classList.contains('gone'), line: (b.querySelector('.vid-nopic') || {}).textContent || '', link: (b.querySelector('.vid-nopic a') || {}).href || '', cover: !!b.querySelector('.vid-heavy'), pp: !!b.querySelector('.vid-pp'), controls: !!v.controls, height: Math.round(b.getBoundingClientRect().height), playing: !v.paused, src: v.getAttribute('src') }; +}, word); +const scrollTo = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); }, word); + await sleep(1500); +}; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(app.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await seedWords(page); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await skipSeedCheck(page); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + const open = async () => { + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 2, null, 20000)); + }; + + console.log('\n[the clip whose host answers 404, without CORS]'); + await open(); + await scrollTo('beat it'); + let b = await boxOf('beat it'); + check('the probe could not tell: the clip waits under a cover', !!b && b.cover && !b.gone, JSON.stringify(b)); + // the tap that used to do nothing + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('.vid-heavy').click(); }, 'beat it'); + check('the player finds the file gone and says so', await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return !!r.querySelector('.note-video-box.gone .vid-nopic'); }, 'beat it', 8000)); + b = await boxOf('beat it'); + check('…the line names the host, with the file\'s link', /couldn’t be loaded from its host/.test(b.line) && b.link === DEAD, JSON.stringify(b)); + check('…no cover, no play button, no controls', !b.cover && !b.pp && !b.controls, JSON.stringify(b)); + check('…a short box, not a screen of black', b.height > 40 && b.height <= 200, String(b.height)); + const hitsThen = deadHits; + await sleep(2500); // repaints, the in-view hook: nothing asks again + b = await boxOf('beat it'); + check('the line stays through repaints', b.gone && !!b.line && !b.cover, JSON.stringify(b)); + + console.log('\n[the clip on a live host]'); + await scrollTo('that is there'); + // the same quiet host: the cover, and the tap that plays it + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const c = r.querySelector('.vid-heavy'); if (c) c.click(); }, 'that is there'); + check('plays as before', await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const v = r.querySelector('video'); return !!v && !v.paused && v.readyState >= 2; }, 'that is there', 10000), JSON.stringify(await boxOf('that is there'))); + b = await boxOf('that is there'); + check('…and is not marked gone', !b.gone && !b.line, JSON.stringify(b)); + + console.log('\n[the next time the post is painted]'); + await open(); + await scrollTo('beat it'); + b = await boxOf('beat it'); + check('the post says it at once', !!b && b.gone && /couldn’t be loaded/.test(b.line) && !b.cover, JSON.stringify(b)); + await sleep(1500); + check('…without asking the host again', deadHits === hitsThen, deadHits + ' vs ' + hitsThen); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); app.stop(true); host.stop(true); } +console.log(ok ? '\n✅ a clip its host no longer has says so, in place of a dead player' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/video-hls-play-test.js b/tools/video-hls-play-test.js index f1aea1c..4a18e76 100644 --- a/tools/video-hls-play-test.js +++ b/tools/video-hls-play-test.js @@ -4,7 +4,7 @@ // and a post linking an .m3u8 stream plays that. vmedia runs here for real, // on a local copy of the clip. // Run: bun tools/video-hls-play-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -90,9 +90,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/video-tap-test.js b/tools/video-tap-test.js index f82238b..f03aef6 100644 --- a/tools/video-tap-test.js +++ b/tools/video-tap-test.js @@ -1,10 +1,9 @@ -// Tapping a video: in the feed it opens the media pager (feed-video-pager- -// test.js has the rest). In a thread, anywhere on the picture (not the -// player's own play button or control bar) goes full screen — the corner -// button is a reach on a phone. Full screen, a tap pauses a playing clip; a -// tap on a paused one comes back out. Taps on the controls stay the player's. +// Tapping a video: inline, a playing clip pauses. A paused one, in the feed, +// and in a thread opens the media pager (feed-video-pager-test.js has the +// rest). With a mouse the middle of the clip is a big play/pause button that +// never goes full screen. // Run: bun tools/video-tap-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -50,9 +49,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { @@ -69,8 +68,18 @@ try { for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250); check('the clip autoplays in the feed', !(await st()).paused); + // the hover button: pauses and plays in place + const pp = async () => { const b = await page.evaluate(() => { const r = document.querySelector('.notes-feed .note-video-box').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.move(b.x - 40, b.y); await page.mouse.move(b.x, b.y, { steps: 3 }); await sleep(250); if (!(await page.evaluate(() => getComputedStyle(document.querySelector('.notes-feed .vid-pp')).display === 'flex'))) check('a mouse over the clip brings up the middle button', false); await page.mouse.click(b.x, b.y); await sleep(700); }; + check('no middle button until a mouse comes over the clip', await page.evaluate(() => getComputedStyle(document.querySelector('.notes-feed .vid-pp')).display === 'none')); + await pp(); + check('the middle button pauses the clip in place', await page.evaluate(() => document.querySelector('video.note-video').paused && !document.querySelector('.media-pager') && !document.fullscreenElement)); + check('...and shows play while paused', await page.evaluate(() => !document.querySelector('.vid-pp').classList.contains('playing'))); + await pp(); + check('the middle button plays it again, still inline', await page.evaluate(() => !document.querySelector('video.note-video').paused && !document.querySelector('.media-pager') && !document.fullscreenElement)); await tap(0.2, 0.3); - check('in the feed, a tap opens the media pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); + check('in the feed, a tap on a playing clip pauses it, no pager', await page.evaluate(() => document.querySelector('video.note-video').paused && !document.querySelector('.media-pager'))); + await tap(0.2, 0.3); + check('a tap on the paused clip opens the media pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); await page.keyboard.press('Escape'); await sleep(700); check('...which Escape closes', await page.evaluate(() => !document.querySelector('.media-pager') && !document.fullscreenElement)); // the thread: the post's words open it @@ -87,22 +96,11 @@ try { for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250); await tap(0.2, 0.3); let s = await st(); - check('in a thread, a tap on the picture goes full screen', s.fs, JSON.stringify(s)); - check('...and not the pager', await page.evaluate(() => !document.querySelector('.media-pager'))); - await tap(0.25, 0.3); - s = await st(); - check('full screen, a tap pauses the playing clip', s.fs && s.paused, JSON.stringify(s)); - await tap(0.5, 0.5); - s = await st(); - check('a tap on the paused clip\'s play button is left to the player', s.fs, JSON.stringify(s)); - await page.evaluate(() => document.querySelector('.thread-card video.note-video').pause()); await sleep(300); + check('in a thread, a tap on the playing clip pauses it in place', !s.fs && s.paused, JSON.stringify(s)); await tap(0.2, 0.3); - s = await st(); - check('a tap elsewhere on the paused clip comes out of full screen', !s.fs, JSON.stringify(s)); - await tap(0.3, 0.97); - s = await st(); - check('a tap on the control bar stays the player\'s', !s.fs, JSON.stringify(s)); + check('a tap on the paused clip opens the media pager there too', await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); + await page.keyboard.press('Escape'); await sleep(700); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await browser.close(); server.stop(true); } -console.log(ok ? '\n✅ video taps: full screen, pause, back out' : '\n❌ failed'); +console.log(ok ? '\n✅ video taps: pause in place, pager when paused, middle button' : '\n❌ failed'); process.exit(ok ? 0 : 1); diff --git a/tools/video-tile-poster-test.js b/tools/video-tile-poster-test.js index 48385fe..ed672c2 100644 --- a/tools/video-tile-poster-test.js +++ b/tools/video-tile-poster-test.js @@ -4,7 +4,7 @@ // picture for falls back to the frame grab, and one the browser can't load // either (deleted, its 404 without CORS headers) leaves the grid. // Run: bun tools/video-tile-poster-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -75,9 +75,9 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/video-touch-tap-test.js b/tools/video-touch-tap-test.js index 71ffe9c..ff25095 100644 --- a/tools/video-touch-tap-test.js +++ b/tools/video-touch-tap-test.js @@ -5,7 +5,7 @@ // on the control bar or the paused clip's play button stays the player's, a // drag is a scroll, and where the click does come it isn't taken twice. // Run: bun tools/video-touch-tap-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -51,9 +51,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { diff --git a/tools/web-comment-test.js b/tools/web-comment-test.js new file mode 100644 index 0000000..513217b --- /dev/null +++ b/tools/web-comment-test.js @@ -0,0 +1,123 @@ +// Comments scoped to a web address (NIP-22 kind 1111 with an I tag: +// DaddyTube's, on a film's IMDb page) belong with the person's replies. +// Adam's own ("Charming and wholesome", 2026-11-02, on relay.primal.net) +// showed on neither his profile's Replies tab — the profile page never +// asked for kind 1111 — nor said what it was a comment on. Now the page +// asks for comments, and a web-scoped comment carries the page it is on +// where "Replying to" would go, named by its alt tag when it has one. +// Also here: a report naming a post as 'other', with the author's bare p +// tag beside it, counted as a report of the PERSON (es stayed 0 for an +// 'other' e tag) — two such reports from follows folded the author's every +// post. Run: bun tools/web-comment-test.js +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const html = await buildHtml({ minify: true, pwa: false }); +const keys = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }; +const B = keys(), C = keys(), D = keys(); // B comments; C and D report +const now = () => Math.floor(Date.now() / 1000); +const ev = (sk, kind, content, ago, tags = []) => finalizeEvent({ kind, created_at: now() - ago, tags, content }, sk); +const IMDB = 'https://www.imdb.com/title/tt32565993/'; +const post = ev(B.sk, 1, 'a plain post about sheep', 3600); +const comment = ev(B.sk, 1111, 'Charming and wholesome', 1800, [['I', IMDB], ['K', 'web'], ['i', IMDB], ['k', 'web']]); +const named = ev(B.sk, 1111, 'The dog steals every scene', 1700, [['I', IMDB], ['K', 'web'], ['i', IMDB], ['k', 'web'], ['alt', 'Comment on Sheep Detectives (2025) in DaddyTube']]); +const reports = [C, D].map((r) => ev(r.sk, 1984, '', 900, [['e', post.id, 'other'], ['p', B.pk]])); +const stored = [post, comment, named, ...reports]; +const reqLog = [], sentLog = []; +const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since); +const server = Bun.serve({ port: 0, fetch: (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +}, websocket: { + message(ws, raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; } + if (m[0] === 'REQ') { + reqLog.push(m.slice(2)); + for (const e of stored) if (m.slice(2).some((f) => match(f, e))) { sentLog.push(e.content.slice(0, 20)); ws.send(JSON.stringify(['EVENT', m[1], e])); } + ws.send(JSON.stringify(['EOSE', m[1]])); + } + }, +} }); +const PORT = server.port; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 390, height: 844 }); +await page.evaluateOnNewDocument((port) => { + const Real = window.WebSocket; + window.WebSocket = function (url, p) { if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}, PORT); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { const r = await page.evaluate(fn, arg); if (r) return r; await sleep(100); } return null; }; +const rowWith = (text) => page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return r ? { folded: !!r.querySelector('.note-folded') || r.classList.contains('note-folded'), scope: r.querySelector('.note-scope a') ? { href: r.querySelector('.note-scope a').href, text: r.querySelector('.note-scope a').textContent.trim() } : null, text: r.textContent.replace(/\s+/g, ' ').trim().slice(0, 120) } : null; }, text); + +try { + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await seedWords(page); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await skipSeedCheck(page); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pks, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pks[0]]: { name: 'Shaun', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, [B.pk, C.pk, D.pk], [post]]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the post is in Following', !!(await waitFor((x) => [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes(x)), 'plain post', 20000))); + await sleep(5000); // the follows' reports arrive and the feed repaints + const plain = await rowWith('plain post'); + check('two "other" reports of the post, each with the author\'s bare p tag, do not fold the author', plain && !plain.folded, plain ? plain.text : 'row gone'); + + console.log('\n[Conversations]'); + await click('conversations'); + const row = await waitFor((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return !!r; }, 'Charming and wholesome', 15000); + check('the web-scoped comment is listed', !!row); + if (process.env.DEBUG) { + console.log(' rows:', JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 50))))); + console.log(' chips:', JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : ''))))); + console.log(' REQs with 1111:', JSON.stringify(reqLog.filter((fs) => fs.some((f) => (f.kinds || []).includes(1111))).map((fs) => fs.map((f) => ({ kinds: f.kinds, authors: (f.authors || []).length, limit: f.limit, since: !!f.since }))))); + console.log(' sent:', JSON.stringify(sentLog)); + } + const c1 = await rowWith('Charming and wholesome'); + check('…under the page it is on, linked', !!c1?.scope && c1.scope.href === IMDB && c1.scope.text.includes('Comment on imdb.com/title/tt32565993'), c1 ? JSON.stringify(c1.scope) : ''); + check('…not tagged as a plain reply', !!c1 && !/↩\s*reply/i.test(c1.text), c1 ? c1.text : ''); + const c2 = await rowWith('dog steals'); + check('a comment with an alt tag is labelled by it', !!c2?.scope && c2.scope.text.includes('Comment on Sheep Detectives (2025) in DaddyTube'), c2 ? JSON.stringify(c2.scope) : 'row missing'); + + console.log('\n[the profile\'s Replies tab]'); + await page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-avatar').click(); }, 'Charming and wholesome'); + check('the profile opens', await waitText('replies', 10000)); + await click('replies'); + const onProfile = await waitFor((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return !!r; }, 'Charming and wholesome', 12000); + check('the comment is on the Replies tab', !!onProfile); + const p1 = await rowWith('Charming and wholesome'); + check('…with its page above it', !!p1?.scope && p1.scope.href === IMDB, p1 ? JSON.stringify(p1.scope) : ''); + console.log('\n[the comment opened]'); + await page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-content').click(); }, 'Charming and wholesome'); + const opened = await waitFor((id) => location.pathname.startsWith('/nevent1') && !!document.querySelector('.note-post[data-zap-post="' + id + '"] .note-scope a'), comment.id, 10000); + check('the thread opens on it, its page above it', !!opened); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ web-scoped comments show with their page, and an "other" report stays off the author' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/wn-only-device-test.js b/tools/wn-only-device-test.js index a91262b..7b19876 100644 --- a/tools/wn-only-device-test.js +++ b/tools/wn-only-device-test.js @@ -6,7 +6,7 @@ // a fresh one of ours. A device whose key was retired by another sees the // deletion and stops putting a key back; the card says so there. // Run: bun tools/wn-only-device-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; @@ -76,9 +76,9 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const SK = privateKeyFromSeedWords(mn), ME = getPublicKey(SK); diff --git a/tools/youtube-tap-test.js b/tools/youtube-tap-test.js index 271277f..bf2832f 100644 --- a/tools/youtube-tap-test.js +++ b/tools/youtube-tap-test.js @@ -3,8 +3,10 @@ // sound, full screen. The player's own taps never reach the page, so a layer // over the picture takes them; YouTube's control bar along the bottom is // left to YouTube, and full screen the layer steps aside. +// Full screen, YouTube's own full-screen button leaves it. Under a mouse +// there is no layer: the player takes its own clicks (pause, full screen). // Run: bun tools/youtube-tap-test.js -import puppeteer from './puppeteer.js'; +import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { buildHtml } from '../build.js'; @@ -26,7 +28,7 @@ const server = Bun.serve({ port: 0, fetch: (req) => { return new Response(html, { headers: { 'content-type': 'text/html' } }); } }); // YouTube, stood in for: the player page notes every command it is sent -const PLAYER = '<!doctype html><html><body style="margin:0;background:#222;height:100vh"><script>window.__cmds=[];addEventListener("message",(e)=>{try{const m=JSON.parse(e.data);if(m.event==="command")window.__cmds.push(m.func)}catch{}});</script></body></html>'; +const PLAYER = '<!doctype html><html><body style="margin:0;background:#222;height:100vh"><script>window.__cmds=[];addEventListener("message",(e)=>{try{const m=JSON.parse(e.data);if(m.event==="command")window.__cmds.push(m.func)}catch{}});window.__clicks=0;addEventListener("click",()=>{window.__clicks++;if(!window.__fsButton)return;if(document.fullscreenElement)document.exitFullscreen();else document.documentElement.requestFullscreen().catch(()=>{})});</script></body></html>'; const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); async function run(saveData) { @@ -55,14 +57,15 @@ async function run(saveData) { const st = () => page.evaluate(() => { const b = document.querySelector('.yt-embed'); const f = b && b.querySelector('iframe'); const sh = b && b.querySelector('.yt-shield'); return { fs: !!b && document.fullscreenElement === b, iframe: !!f, muted: !!f && /mute=1/.test(f.src), shield: !!sh && getComputedStyle(sh).display !== 'none', thread: !!document.querySelector('.thread-card') }; }); const cmds = async () => { const f = page.frames().find((x) => x.url().startsWith('https://www.youtube-nocookie.com/')); return f ? f.evaluate(() => window.__cmds.slice()) : null; }; try { - await page.setViewport({ width: 390, height: 844 }); + // a phone: a finger, no hover (a mouse is run apart, below) + await page.setViewport({ width: 390, height: 844, hasTouch: true, isMobile: true }); await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); - await page.waitForSelector('.words .w .t'); + await seedWords(page); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); - await click('skip verification'); + await skipSeedCheck(page); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); await page.evaluate(([k, pk, ns]) => { @@ -85,13 +88,13 @@ async function run(saveData) { await sleep(500); // YouTube's control bar along the bottom is YouTube's let b = await box(); - await page.mouse.click(b.x + b.w * 0.3, b.y + b.h - 20); await sleep(500); + await page.touchscreen.tap(b.x + b.w * 0.3, b.y + b.h - 20); await sleep(500); check('a tap on YouTube\'s control bar stays YouTube\'s', !(await st()).fs && !(await st()).thread, JSON.stringify(await st())); } else { check('with Data Saver, the still and a play button', !s.iframe && await page.evaluate(() => !!document.querySelector('.yt-embed .yt-play')), JSON.stringify(s)); } const b = await box(); - await page.mouse.click(b.x + b.w * 0.3, b.y + b.h * 0.35); + await page.touchscreen.tap(b.x + b.w * 0.3, b.y + b.h * 0.35); check('one tap on the picture: full screen', await waitFor(() => document.fullscreenElement === document.querySelector('.yt-embed'), null, 3000), JSON.stringify(await st())); s = await st(); check('…the player there', s.iframe); @@ -102,9 +105,82 @@ async function run(saveData) { await page.evaluate(() => document.exitFullscreen()); await sleep(500); s = await st(); check('out of full screen, the layer is back', s.shield && !s.fs, JSON.stringify(s)); + // YouTube's own full-screen button, pressed while we hold the screen: + // the frame asks for full screen from inside — that press means "out" + const b2 = await box(); + await page.touchscreen.tap(b2.x + b2.w * 0.3, b2.y + b2.h * 0.35); + await waitFor(() => document.fullscreenElement === document.querySelector('.yt-embed'), null, 3000); + await page.frames().find((x) => x.url().startsWith('https://www.youtube-nocookie.com/')).evaluate(() => { window.__fsButton = true; }); + await page.touchscreen.tap(200, 400); // inside the frame now + check('full screen, the player\'s own full-screen button leaves it', await waitFor(() => !document.fullscreenElement, null, 3000), await page.evaluate(() => String(document.fullscreenElement && document.fullscreenElement.tagName))); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await ctx.close(); } } -try { await run(false); await run(true); } finally { await browser.close(); server.stop(true); } +// A mouse reaches the player's own buttons, so its clicks are the player's: +// the middle one pauses, the corner one goes full screen and back. +async function runMouse() { + console.log('\n[a mouse: the player takes its own clicks]'); + const ctx = await browser.createBrowserContext(); + const page = await ctx.newPage(); + await page.setRequestInterception(true); + page.on('request', (r) => { + const u = r.url(); + if (u.startsWith('https://www.youtube-nocookie.com/embed/')) return r.respond({ status: 200, contentType: 'text/html', body: PLAYER }); + if (u.startsWith('https://i.ytimg.com/')) return r.respond({ status: 200, contentType: 'image/png', body: png }); + return r.continue(); + }); + await page.evaluateOnNewDocument(() => { + const Real = window.WebSocket; + window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); + }); + const errs = []; + page.on('pageerror', (e) => errs.push(String(e))); + const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); + const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; + const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; + const box = () => page.evaluate(() => { const b = document.querySelector('.notes-feed .yt-embed').getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height }; }); + const fsTag = () => page.evaluate(() => (document.fullscreenElement ? document.fullscreenElement.tagName : '')); + const player = () => page.frames().find((x) => x.url().startsWith('https://www.youtube-nocookie.com/')); + try { + await page.setViewport({ width: 1280, height: 900 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await seedWords(page); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await skipSeedCheck(page); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Tuber', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, POSTS]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => { const n = document.querySelector('.app-nav-button[aria-label="Feed"]') || [...document.querySelectorAll('a,button')].find((x) => /^feed$/i.test((x.getAttribute('aria-label') || x.textContent || '').trim())); if (n) n.click(); }); + await page.waitForSelector('.notes-feed .yt-embed', { timeout: 20000 }); + await page.evaluate(() => document.querySelector('.notes-feed .yt-embed').scrollIntoView({ block: 'center' })); + await waitFor(() => !!document.querySelector('.yt-embed iframe'), null, 6000); + await sleep(800); + const b = await box(); + await page.mouse.move(b.x + b.w * 0.4, b.y + b.h * 0.4); await sleep(200); + check('under a mouse, no layer over the player', await page.evaluate(() => { const sh = document.querySelector('.yt-embed .yt-shield'); return !sh || getComputedStyle(sh).display === 'none'; })); + await player().evaluate(() => { window.__fsButton = true; }); // from here every click is the corner button + await page.mouse.click(b.x + b.w * 0.5, b.y + b.h * 0.5); await sleep(600); + check('a click in the middle reaches the player', (await player().evaluate(() => window.__clicks)) === 1); + check('…and the page does not take the box full screen', (await fsTag()) !== 'DIV', await fsTag()); + check('…nor open the thread', !(await page.evaluate(() => !!document.querySelector('.thread-card')))); + // (the stand-in goes full screen on a click, as YouTube's corner button does) + check('the player\'s own full screen is left to it', (await fsTag()) === 'IFRAME', await fsTag()); + await page.mouse.click(400, 300); await sleep(600); + check('…and its button brings it back', (await fsTag()) === '', await fsTag()); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); + } finally { await ctx.close(); } +} +try { await run(false); await run(true); await runMouse(); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ a YouTube clip: one tap, full screen, with sound' : '\n❌ failed'); process.exit(ok ? 0 : 1); |
