Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1daf61a65 | |||
| 02cd63dbd9 | |||
| 56d65be19a | |||
| d23db1e5c2 | |||
| 0416b20a46 | |||
| eae9c1d00d | |||
| 72cbc871f5 | |||
| c848e8b51e | |||
| 30886bf9fa | |||
| 156c5f5388 | |||
| e7d35c71c6 | |||
| a074e7c730 | |||
| 9c70c2b42b | |||
| 9822fd2a0b | |||
| c1147063c6 | |||
| eacb0e4371 | |||
| 647b3d414d | |||
| ad7f053129 | |||
| 075025bceb | |||
| 32908f7b4f | |||
| bd333b9584 | |||
| 3ac1dc6b0a | |||
| 025ecd8645 | |||
| 0fca39a1bd | |||
| 3152f7f0ec | |||
| 7cba044b9d | |||
| 4245b2aede | |||
| 3cdec3ceb6 | |||
| aa8f7539ae | |||
| c6b3cb8758 | |||
| 59f68efdc7 | |||
| dc81585f9a | |||
| 54e6c964db | |||
| dceda199c3 | |||
| 8967012035 | |||
| 0b73d4aac5 | |||
| 6f53f7ad99 | |||
| 399df4da4d | |||
| c06a66ade4 | |||
| 1fca26ae2e | |||
| ccd8f213f6 | |||
| 1c25702453 | |||
| 357ba7d8c8 | |||
| 207ca6893a | |||
| 37df5d0bd1 | |||
| 19906cf918 | |||
| 874010c4fe | |||
| 126dce1dfc | |||
| 105da53e2e | |||
| 7bc4a632b0 | |||
| 0222248d76 | |||
| a542dd3b36 | |||
| fc292a8654 | |||
| 9214bd823b | |||
| 8f5b8264c9 | |||
| 94f821d064 | |||
| 6d73e6d06b | |||
| bd724de1e8 | |||
| 9d899cfe87 | |||
| 173f789242 | |||
| 72268dfde6 | |||
| 7b63f6112c | |||
| ce61d8d1a6 | |||
| c4a10b1303 | |||
| 76c6846e91 | |||
| ac1e82b52d | |||
| 437b8de652 | |||
| adadb6ed53 | |||
| f7c90a4a23 | |||
| 82632bb76c | |||
| 3a70d34e6d | |||
| 221d3f4aff | |||
| 6a1a462ab0 | |||
| 5ee8bc1cc0 | |||
| 76d53859cf | |||
| e482afbd3f | |||
| 11ff27efe2 | |||
| 8f6f678132 | |||
| f25139103c | |||
| 0028b506e7 | |||
| 926c27d51c | |||
| c4454ee2a1 | |||
| e56737f776 | |||
| feb6c1a9f6 | |||
| 6f8d225597 | |||
| 9ecd99a6a1 | |||
| 287097627d | |||
| 3ee491a63b | |||
| 7944f73da3 | |||
| 17c1936817 | |||
| c570f4689d | |||
| 064ab1e101 | |||
| 9c0d49b904 | |||
| 69634e7c05 | |||
| db48ce7c40 | |||
| 36c6e537a7 | |||
| cbc3df0bef | |||
| 2ecd557430 | |||
| e6efdc3539 | |||
| ebe0cfdf03 | |||
| a501337fd3 | |||
| e3916b3bc1 | |||
| de22e921d4 | |||
| 3a512f04e2 | |||
| bfd1daf7ba | |||
| f4363dcbff |
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: lockdown-mode
|
||||
description: Apple Lockdown Mode restrictions and their impact on web APIs inside WKWebView/Safari/WebView. Reference when debugging or building features for lockdown-enabled devices.
|
||||
---
|
||||
|
||||
# Apple Lockdown Mode
|
||||
|
||||
Apple's Lockdown Mode is an opt-in security hardening profile that disables or restricts many web platform APIs inside Safari and WKWebView. Since this app ships inside a Capacitor WKWebView shell, **every restriction that applies to Safari also applies to our app**.
|
||||
|
||||
## Platform Availability
|
||||
|
||||
Lockdown Mode is available on:
|
||||
|
||||
- **iOS 16** or later (iPhone)
|
||||
- **iPadOS 16** or later (iPad)
|
||||
- **watchOS 10** or later (Apple Watch)
|
||||
- **macOS Ventura** or later (Mac)
|
||||
|
||||
Additional protections are available starting in iOS 17, iPadOS 17, watchOS 10, and macOS Sonoma.
|
||||
|
||||
For full details, see [About Lockdown Mode](https://support.apple.com/en-us/105120) on Apple Support.
|
||||
|
||||
## Testing Baseline
|
||||
|
||||
This document is based on testing against **iOS 18.7 / Safari 26.4** on an iPhone with Lockdown Mode enabled (April 2026). The web API restrictions documented below apply to Safari and WKWebView across all supported platforms (iOS, iPadOS, and macOS).
|
||||
|
||||
## Blocked APIs
|
||||
|
||||
These APIs are **completely unavailable** (return `undefined`, `null`, or throw) when Lockdown Mode is active.
|
||||
|
||||
| API | Impact | Notes |
|
||||
|-----|--------|-------|
|
||||
| **IndexedDB** | Critical | `indexedDB` global is missing entirely. Any library that relies on IndexedDB for storage will fail (Dexie, idb, localForage with IndexedDB driver, etc.). |
|
||||
| **Service Workers** | High | `navigator.serviceWorker` is absent. No offline caching, no background sync, no push notifications via SW. |
|
||||
| **Cache API** | High | `caches` global is absent. Often used alongside Service Workers for offline strategies. |
|
||||
| **WebAssembly** | High | `WebAssembly` global is `undefined`. Libraries compiled to WASM (e.g. libsodium-wrappers, secp256k1-wasm, SQLite WASM) will not load. |
|
||||
| **Web Locks** | High | `navigator.locks` is absent. Cross-tab coordination patterns that depend on this will break silently. |
|
||||
| **WebRTC** | High | `RTCPeerConnection` is absent. No peer-to-peer audio/video/data channels. |
|
||||
| **WebGL / WebGL2** | Medium | All canvas `getContext('webgl'*)` calls return `null`. GPU-accelerated rendering, maps (Mapbox GL, deck.gl), and 3D are broken. |
|
||||
| **FileReader** | Medium | `FileReader` constructor is absent. Cannot read `Blob`/`File` objects client-side (e.g. image preview before upload). Use the `File` constructor + `URL.createObjectURL()` as a workaround for previews. |
|
||||
| **SharedArrayBuffer** | Medium | `SharedArrayBuffer` is `undefined`. May also require COOP/COEP headers on non-lockdown browsers, so this is often already unavailable. |
|
||||
| **Speech Synthesis** | Low | `window.speechSynthesis` is absent. Text-to-speech features won't work. |
|
||||
| **Notifications API** | Low | `Notification` is absent. Web push permission prompts won't appear. (Capacitor local notifications via the native plugin are unaffected.) |
|
||||
| **WebCodecs** | Low | `VideoDecoder` / `VideoEncoder` are absent (`AudioDecoder` remains). Low-level media processing is unavailable. |
|
||||
| **Gamepad API** | Low | `navigator.getGamepads` is absent. |
|
||||
| **OPFS** | Medium | `navigator.storage.getDirectory` method does not exist. The `navigator.storage` object is present but the Origin Private File System API is stripped. SQLite-over-OPFS and any other OPFS-based storage will fail. |
|
||||
| **Web Share API** | Low | `navigator.share` is absent. Use Capacitor's `@capacitor/share` plugin instead -- the native share sheet still works. |
|
||||
|
||||
## Available APIs
|
||||
|
||||
These APIs **still work** under Lockdown Mode and can be relied on.
|
||||
|
||||
| API | Notes |
|
||||
|-----|-------|
|
||||
| **File constructor** | `new File(...)` works. You can create File/Blob objects. |
|
||||
| **FontFace API** | Dynamic font loading via `new FontFace()` succeeds. Remote font fetches may fail with a network error (data URIs rejected). |
|
||||
| **JIT compilation** | JavaScript JIT appears active (~110ms for 1M iterations). Performance is not interpreter-level degraded. |
|
||||
| **PDF viewer** | `navigator.pdfViewerEnabled` is `true`. Inline `<embed type="application/pdf">` works. |
|
||||
| **Cookies** | `navigator.cookieEnabled` is `true`. |
|
||||
| **Credential Management** | `navigator.credentials` is available. |
|
||||
| **localStorage / sessionStorage** | Standard Web Storage APIs remain functional. |
|
||||
|
||||
## Implications for This App
|
||||
|
||||
### Storage
|
||||
|
||||
- **localStorage works** -- our primary client-side storage (app config, relay lists, etc.) is unaffected.
|
||||
- **IndexedDB is gone** -- if any dependency silently uses IndexedDB (e.g. some Nostr caching layers, TanStack Query persisters), it will fail. Ensure all storage paths fall back to localStorage or in-memory.
|
||||
- **OPFS is gone** -- `navigator.storage.getDirectory` is stripped (the method doesn't exist, though the `navigator.storage` object itself remains). SQLite-over-OPFS (e.g. wa-sqlite, sql.js with OPFS backend) and any other OPFS-based persistence will not work.
|
||||
|
||||
### Cryptography
|
||||
|
||||
- **WebAssembly is blocked** -- any WASM-based crypto libraries (secp256k1 compiled to WASM, libsodium WASM builds) will not load. Use pure-JS implementations (e.g. `@noble/secp256k1`, `@noble/hashes`) which are already what nostr-tools uses.
|
||||
- **WebCrypto (`crypto.subtle`)** -- not listed as blocked in testing. The SubtleCrypto API should still be available for NIP-44 encryption via the standard Web Crypto path.
|
||||
|
||||
### Media & Rendering
|
||||
|
||||
- **WebGL is gone** -- map libraries that require WebGL (Mapbox GL JS, Google Maps WebGL renderer) will show blank canvases. Use raster tile alternatives or static map images.
|
||||
- **FileReader is gone** -- image/file preview workflows that use `FileReader.readAsDataURL()` need a workaround. Use `URL.createObjectURL(file)` directly for `<img src>` previews instead.
|
||||
|
||||
### Communication
|
||||
|
||||
- **WebRTC is gone** -- any peer-to-peer features (voice/video calls, WebRTC data channels) are completely unavailable.
|
||||
- **Fetch / XMLHttpRequest** -- standard network requests appear unaffected. Relay WebSocket connections should work normally.
|
||||
|
||||
### Native Plugin Workarounds
|
||||
|
||||
Several blocked web APIs have Capacitor plugin equivalents that bypass WKWebView restrictions entirely:
|
||||
|
||||
| Blocked Web API | Capacitor Alternative |
|
||||
|---|---|
|
||||
| Web Share | `@capacitor/share` (already installed) |
|
||||
| Notifications | `@capacitor/local-notifications` (already installed) |
|
||||
| File downloads | `@capacitor/filesystem` + share (already implemented in `downloadFile.ts`) |
|
||||
|
||||
### Detection
|
||||
|
||||
The report used a scoring heuristic (8/12 key APIs blocked = 70%) to detect Lockdown Mode. There is no official API to query Lockdown Mode status. Detection relies on probing for the absence of multiple APIs that are specifically disabled by Lockdown Mode but normally present in Safari.
|
||||
|
||||
## Raw Diagnostic Report
|
||||
|
||||
For exact error messages, navigator properties, weight scores, and per-API diagnostic output, see [ios-report.txt](ios-report.txt).
|
||||
|
||||
## Guidance for Feature Decisions
|
||||
|
||||
When building new features, consider:
|
||||
|
||||
1. **Always provide pure-JS fallbacks** for any crypto or data-processing library that might ship a WASM build.
|
||||
2. **Never depend on IndexedDB or OPFS** as the sole storage mechanism. Both are completely stripped. Always fall back to localStorage or in-memory stores.
|
||||
3. **Avoid WebGL-dependent UI** for core functionality. Use it as a progressive enhancement with a CSS/Canvas 2D fallback.
|
||||
4. **Use Capacitor plugins** for sharing, notifications, and file operations rather than web APIs -- they work on all native platforms regardless of Lockdown Mode.
|
||||
5. **Test on a Lockdown Mode device** when shipping features that touch storage, crypto, or media APIs.
|
||||
@@ -0,0 +1,229 @@
|
||||
============================================================
|
||||
LOCKDOWN MODE DETECTOR REPORT
|
||||
2026-04-06T23:40:58.170Z
|
||||
============================================================
|
||||
|
||||
VERDICT: Lockdown Mode Likely Active
|
||||
8 of 12 key APIs are blocked, consistent with iOS/macOS Lockdown Mode.
|
||||
Score: 70% (8/12 key APIs blocked)
|
||||
|
||||
============================================================
|
||||
API TEST RESULTS (detailed)
|
||||
============================================================
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] IndexedDB (weight: 3)
|
||||
Client-side structured storage
|
||||
Result: Can't find variable: indexedDB
|
||||
Diagnostics:
|
||||
uncaught: Can't find variable: indexedDB
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] WebAssembly (weight: 2)
|
||||
Binary instruction execution
|
||||
Result: WebAssembly is undefined
|
||||
Diagnostics:
|
||||
typeof WebAssembly: undefined
|
||||
WebAssembly global does not exist
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Web Locks API (weight: 3)
|
||||
Cross-tab resource coordination
|
||||
Result: navigator.locks is undefined
|
||||
Diagnostics:
|
||||
typeof navigator.locks: undefined
|
||||
'locks' in navigator: false
|
||||
navigator.locks is falsy
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Speech Synthesis (weight: 3)
|
||||
Web Speech API (text-to-speech)
|
||||
Result: window.speechSynthesis is undefined
|
||||
Diagnostics:
|
||||
typeof window.speechSynthesis: undefined
|
||||
'speechSynthesis' in window: false
|
||||
typeof SpeechSynthesisUtterance: undefined
|
||||
speechSynthesis is falsy
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] FileReader API (weight: 2)
|
||||
Local file reading interface
|
||||
Result: FileReader is undefined
|
||||
Diagnostics:
|
||||
typeof FileReader: undefined
|
||||
FileReader constructor does not exist
|
||||
|
||||
------------------------------------------------------------
|
||||
[AVAILABLE] File Constructor (weight: 2)
|
||||
File object creation
|
||||
Result: File created: name=test.txt size=4
|
||||
Diagnostics:
|
||||
typeof File: function
|
||||
calling new File(['test'], 'test.txt', {type:'text/plain'})...
|
||||
succeeded
|
||||
f.name: test.txt
|
||||
f.size: 4
|
||||
f.type: text/plain
|
||||
f instanceof Blob: true
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] WebGL (weight: 2)
|
||||
GPU-accelerated graphics
|
||||
Result: all WebGL contexts returned null
|
||||
Diagnostics:
|
||||
getContext('webgl2'): null
|
||||
getContext('webgl'): null
|
||||
getContext('experimental-webgl'): null
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] WebGL2 (weight: 1)
|
||||
Advanced GPU graphics context
|
||||
Result: getContext('webgl2') returned null
|
||||
Diagnostics:
|
||||
getContext('webgl2'): null
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Service Worker (weight: 1)
|
||||
Background script registration
|
||||
Result: navigator.serviceWorker not present
|
||||
Diagnostics:
|
||||
'serviceWorker' in navigator: false
|
||||
typeof navigator.serviceWorker: undefined
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Web Share API (weight: 0)
|
||||
Native sharing interface
|
||||
Result: navigator.share is undefined
|
||||
Diagnostics:
|
||||
typeof navigator.share: undefined
|
||||
typeof navigator.canShare: undefined
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Gamepad API (weight: 1)
|
||||
Game controller input
|
||||
Result: navigator.getGamepads not present
|
||||
Diagnostics:
|
||||
'getGamepads' in navigator: false
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] WebRTC (weight: 2)
|
||||
Real-time peer communication
|
||||
Result: RTCPeerConnection is undefined
|
||||
Diagnostics:
|
||||
typeof RTCPeerConnection: undefined
|
||||
typeof webkitRTCPeerConnection: undefined
|
||||
|
||||
------------------------------------------------------------
|
||||
[AVAILABLE] FontFace API (weight: 1)
|
||||
Dynamic font loading
|
||||
Result: status: loaded
|
||||
Diagnostics:
|
||||
typeof FontFace: function
|
||||
new FontFace() succeeded
|
||||
ff.status: unloaded
|
||||
ff.family: test
|
||||
ff.status after load: loaded
|
||||
|
||||
------------------------------------------------------------
|
||||
[AVAILABLE] Remote Fonts (weight: 2)
|
||||
Loading fonts from network via data URI
|
||||
Result: API works, load rejected: A network error occurred.
|
||||
Diagnostics:
|
||||
FontFace created with data URI
|
||||
ff.status before load: unloaded
|
||||
caught: DOMException: A network error occurred.
|
||||
|
||||
------------------------------------------------------------
|
||||
[AVAILABLE] JIT Compilation (weight: 2)
|
||||
JavaScript JIT optimization heuristic
|
||||
Result: 99.0ms for 1M iterations (JIT likely)
|
||||
Diagnostics:
|
||||
running 1,000,000 iterations of Math.sqrt*Math.sin...
|
||||
elapsed: 99.00ms
|
||||
sum (to prevent dead-code elimination): -681.7597
|
||||
threshold: <150ms suggests JIT active
|
||||
verdict: likely JIT
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Notifications API (weight: 1)
|
||||
Push notification permission
|
||||
Result: Notification not in window
|
||||
Diagnostics:
|
||||
'Notification' in window: false
|
||||
typeof Notification: undefined
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] WebCodecs (weight: 1)
|
||||
Low-level VideoDecoder API
|
||||
Result: VideoDecoder is undefined
|
||||
Diagnostics:
|
||||
typeof VideoDecoder: undefined
|
||||
typeof VideoEncoder: undefined
|
||||
typeof AudioDecoder: function
|
||||
|
||||
------------------------------------------------------------
|
||||
[AVAILABLE] PDF Embed (weight: 2)
|
||||
Inline PDF rendering via embed/pdfViewerEnabled
|
||||
Result: pdfViewerEnabled is true
|
||||
Diagnostics:
|
||||
navigator.pdfViewerEnabled: true
|
||||
typeof navigator.pdfViewerEnabled: boolean
|
||||
created and appended <embed type=application/pdf>
|
||||
navigator.mimeTypes['application/pdf']: [object MimeType]
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] SharedArrayBuffer (weight: 1)
|
||||
Shared memory between workers
|
||||
Result: SharedArrayBuffer is undefined
|
||||
Diagnostics:
|
||||
typeof SharedArrayBuffer: undefined
|
||||
requires COOP/COEP headers to be present; may not indicate Lockdown Mode specifically
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Cache API (weight: 1)
|
||||
Programmatic HTTP cache (CacheStorage)
|
||||
Result: caches not in window
|
||||
Diagnostics:
|
||||
'caches' in window: false
|
||||
typeof caches: undefined
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] OPFS (weight: 2)
|
||||
Origin Private File System (navigator.storage.getDirectory)
|
||||
Result: navigator.storage.getDirectory is not a function
|
||||
Diagnostics:
|
||||
typeof navigator.storage: object
|
||||
typeof navigator.storage.getDirectory: undefined
|
||||
getDirectory method does not exist
|
||||
|
||||
============================================================
|
||||
NAVIGATOR INFO
|
||||
============================================================
|
||||
|
||||
userAgent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1
|
||||
platform: iPhone
|
||||
vendor: Apple Computer, Inc.
|
||||
language: en-US
|
||||
languages: en-US
|
||||
cookieEnabled: true
|
||||
doNotTrack: null
|
||||
maxTouchPoints: 5
|
||||
hardwareConcurrency: 4
|
||||
deviceMemory: N/A
|
||||
pdfViewerEnabled: true
|
||||
webdriver: false
|
||||
connection: unavailable
|
||||
mediaDevices: unavailable
|
||||
storage: available
|
||||
serviceWorker: unavailable
|
||||
credentials: available
|
||||
bluetooth: unavailable
|
||||
gpu (WebGPU): unavailable
|
||||
screenWidth: 393
|
||||
screenHeight: 852
|
||||
devicePixelRatio: 3
|
||||
colorDepth: 24
|
||||
|
||||
============================================================
|
||||
END OF REPORT
|
||||
============================================================
|
||||
+3
-1
@@ -2,4 +2,6 @@ VITE_SENTRY_DSN="https://********************************@*****************.exam
|
||||
VITE_PLAUSIBLE_DOMAIN="example.tld"
|
||||
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
|
||||
# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey")
|
||||
VITE_NOSTR_PUSH_PUBKEY=""
|
||||
VITE_NOSTR_PUSH_PUBKEY=""
|
||||
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
|
||||
# ALLOWED_HOSTS="*"
|
||||
+32
-2
@@ -145,8 +145,9 @@ build-apk:
|
||||
- npx vite build -l error
|
||||
- cp dist/index.html dist/404.html
|
||||
|
||||
# Sync web assets to Capacitor Android project
|
||||
# Sync web assets to Capacitor Android project and register local plugins
|
||||
- npx cap sync android
|
||||
- node scripts/patch-cap-config.mjs
|
||||
|
||||
# Build signed release APK
|
||||
- cd android && chmod +x gradlew && ./gradlew assembleRelease bundleRelease && cd ..
|
||||
@@ -218,7 +219,7 @@ publish-zapstore:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
SIGN_WITH: $ZAPSTORE_BUNKER_URL
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub"
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub,wss://relay.dreamith.to,wss://relay.primal.net"
|
||||
BLOSSOM_URL: "https://blossom.ditto.pub"
|
||||
script:
|
||||
- go install github.com/zapstore/zsp@latest
|
||||
@@ -234,3 +235,32 @@ publish-zapstore:
|
||||
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
|
||||
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
|
||||
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
|
||||
|
||||
publish-google-play:
|
||||
stage: publish
|
||||
image: ruby:3.3
|
||||
needs:
|
||||
- build-apk
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
- gem install fastlane --no-document
|
||||
|
||||
# Decode base64-encoded service account JSON to a temp file
|
||||
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json
|
||||
|
||||
# Upload the AAB to Google Play production track
|
||||
- >-
|
||||
fastlane supply
|
||||
--aab artifacts/Ditto.aab
|
||||
--package_name pub.ditto.app
|
||||
--track production
|
||||
--json_key /tmp/play-service-account.json
|
||||
--skip_upload_metadata
|
||||
--skip_upload_changelogs
|
||||
--skip_upload_images
|
||||
--skip_upload_screenshots
|
||||
--skip_upload_apk
|
||||
|
||||
# Clean up
|
||||
- rm -f /tmp/play-service-account.json
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
Thanks for contributing to Ditto! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!-- Link the GitLab issue. MRs without a linked issue will not be reviewed. -->
|
||||
|
||||
Closes #
|
||||
|
||||
## What Changed
|
||||
|
||||
<!-- 1-3 sentences: what you changed and why. -->
|
||||
|
||||
## Live Preview
|
||||
|
||||
<!-- REQUIRED for UI changes. Deploy your branch and paste the URL. -->
|
||||
<!-- Example: npx surge dist your-branch.surge.sh -->
|
||||
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- REQUIRED for UI changes. Show before and after. -->
|
||||
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
|
||||
|
||||
**Before:**
|
||||
|
||||
|
||||
**After:**
|
||||
|
||||
## Philosophy Alignment
|
||||
|
||||
<!-- Answer this question for your change: -->
|
||||
<!-- "Does this make Ditto more magnetic, more threatening to the status quo, -->
|
||||
<!-- and more peaceful to inhabit?" -->
|
||||
<!-- See: https://about.ditto.pub/philosophy -->
|
||||
<!-- For bug fixes: "Bug fix -- restores intended behavior" is acceptable. -->
|
||||
|
||||
## How to Test
|
||||
|
||||
<!-- Steps a reviewer can follow to verify this works. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
<!-- Complete ALL items. MRs with unchecked boxes will not be reviewed. -->
|
||||
<!-- Check a box: replace [ ] with [x] -->
|
||||
|
||||
### Process
|
||||
|
||||
- [ ] I read `AGENTS.md` before starting
|
||||
- [ ] I read the [Ditto Philosophy](https://about.ditto.pub/philosophy)
|
||||
- [ ] I used plan/research mode before writing code
|
||||
- [ ] I used Claude Opus 4.6 (or equivalent frontier model)
|
||||
|
||||
### Self-review
|
||||
|
||||
Copy-paste this into your AI tool and fix any findings before submitting:
|
||||
|
||||
> Review this diff against the self-review checklist in CONTRIBUTING.md step 8. Read that file first, then check every item. For each finding, state the file, line, and issue.
|
||||
|
||||
- [ ] I ran the self-review prompt above and addressed all findings
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] I ran `npm run test` locally and it passes
|
||||
- [ ] I tested the change manually in the browser
|
||||
@@ -283,16 +283,20 @@ When adding support for a new Nostr event kind to the application, the kind must
|
||||
3. **Detail page** (`src/pages/PostDetailPage.tsx`):
|
||||
- Add the same `isMyKind` detection flag and include it in the group/exclusion flags (mirrors NoteCard)
|
||||
- Add the content dispatch for the detail view
|
||||
- Add an entry in `shellTitleForKind()` for the loading state title
|
||||
- `shellTitleForKind()` falls through to the central `KIND_LABELS` registry, so adding a label there is sufficient for the loading state title. Only add a manual override in `shellTitleForKind()` if the kind belongs to a group (e.g. music kinds → "Track Details") or needs a composite label (e.g. "Badge Collection")
|
||||
- Import the new component
|
||||
|
||||
4. **Feed registration** (`src/lib/extraKinds.ts`):
|
||||
- Add the kind number to an existing feed definition's `extraFeedKinds` array, or create a new `ExtraKindDef` entry
|
||||
|
||||
5. **Kind label registries** -- these are separate maps that resolve kind numbers to human-readable strings. All must be updated:
|
||||
- `KIND_LABELS` and `KIND_ICONS` in `src/components/CommentContext.tsx` -- used for "Commenting on an nsite" text and inline icons
|
||||
- `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` -- used in addressable event preview headers
|
||||
- The icon fallback in `AddressableEventPreview` in the same file
|
||||
5. **Central kind label registry** (`src/lib/kindLabels.ts`):
|
||||
- Add an entry to the `KIND_LABELS` map with a short, user-facing label (capitalized noun phrase, no articles)
|
||||
- This registry is the single source of truth for kind→label mappings and is consumed by the nsite permission prompt, signer nudge toasts, detail page loading titles, and addressable event preview headers
|
||||
- Some UI contexts maintain **context-specific** label maps that cannot use the central registry directly (they need different grammar):
|
||||
- `KIND_LABELS` and `KIND_ICONS` in `src/components/CommentContext.tsx` -- uses articles ("a post", "an article") for "Commenting on {label}" text
|
||||
- `NOTIFICATION_KIND_NOUNS` in `src/pages/NotificationsPage.tsx` -- uses bare lowercase nouns for notification action text
|
||||
- `KIND_HEADER_MAP` in `src/components/NoteCard.tsx` -- uses action verbs + nouns for feed headers
|
||||
- These context-specific maps must also be updated when adding a new kind
|
||||
|
||||
6. **Embedded note cards** (`src/components/EmbeddedNote.tsx`, `src/components/EmbeddedNaddr.tsx`) -- these are the small preview cards shown inside quote posts, reply context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal card (author + title/content preview + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags rather than in the `content` field (e.g. kind 20 photos via `imeta` tags) may need attachment indicator logic added to `EmbeddedNoteCard`.
|
||||
|
||||
@@ -303,7 +307,7 @@ When adding support for a new Nostr event kind to the application, the kind must
|
||||
|
||||
#### Why so many places?
|
||||
|
||||
These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
|
||||
These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. The central `KIND_LABELS` in `src/lib/kindLabels.ts` handles the common case, but several contexts need grammar-specific maps (articles, verbs, lowercase nouns) that can't be derived mechanically. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
|
||||
|
||||
### NIP.md
|
||||
|
||||
@@ -409,6 +413,74 @@ Without filtering approvals by the moderator list, anyone could publish kind 455
|
||||
|
||||
Author filtering is not needed for public user-generated content where anyone should be able to post (kind 1 notes, reactions, discovery queries, public feeds, etc.).
|
||||
|
||||
#### Sanitizing URLs from Event Data
|
||||
|
||||
**CRITICAL**: Any URL extracted from Nostr event tags, content, or metadata fields is **untrusted user input**. Malicious URLs can cause harm in many ways beyond `javascript:` XSS — `data:` URIs for resource exhaustion, `http://` URLs leaking user IPs without TLS, relative paths triggering unintended requests to the app's own origin, and more. Reasoning about which rendering context is "safe enough" to skip sanitization is fragile and error-prone.
|
||||
|
||||
**Rule: sanitize every event-sourced URL unconditionally**, regardless of where it will be used (`href`, `img src`, `style`, etc.). Use `sanitizeUrl()` from `@/lib/sanitizeUrl`:
|
||||
|
||||
```typescript
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
// Single URL — returns the normalised href, or undefined if not valid https
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
if (url) {
|
||||
// safe to use in any context
|
||||
}
|
||||
|
||||
// Array of URLs — filter out invalid entries
|
||||
const links = getAllTags(event.tags, 'r')
|
||||
.map(([, v]) => sanitizeUrl(v))
|
||||
.filter((v): v is string => !!v);
|
||||
```
|
||||
|
||||
`sanitizeUrl` accepts `string | undefined | null` and returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, relative paths, etc.) return `undefined`.
|
||||
|
||||
**Best practice — sanitize at the parse layer.** When writing a parser function that extracts URLs from event tags (e.g. `parseThemeDefinition`, `parseBadgeDefinition`), apply `sanitizeUrl()` before returning the parsed data. This way every downstream consumer is automatically protected without needing to remember to sanitize at each usage site.
|
||||
|
||||
**When sanitization is NOT required:**
|
||||
- URLs extracted by regex that already constrains the protocol (e.g. `NoteContent` tokeniser matches only `https?://`)
|
||||
- Hardcoded or application-generated URLs (relay configs, internal routes, etc.)
|
||||
- URLs displayed as plain text without being placed into any HTML attribute or CSS value
|
||||
|
||||
#### Preventing CSS Injection from Event Data
|
||||
|
||||
**CRITICAL**: Any value from a Nostr event that is interpolated into a CSS string (inside a `<style>` element or inline `style` attribute) is a CSS injection vector. A malicious value containing `"`, `)`, `}`, or `;` can break out of the CSS context and inject arbitrary rules — for example, overlaying phishing content or hiding UI elements.
|
||||
|
||||
**Common CSS injection surfaces:**
|
||||
- `background-image: url("${url}")` — a URL with `"); body { display:none }` breaks out
|
||||
- `font-family: "${family}"` — a family name with `"; } body { visibility:hidden } .x {` breaks out
|
||||
- `@font-face { src: url("${url}") }` — same risk as background URLs
|
||||
|
||||
**Mitigation strategy — sanitize at the parse layer:**
|
||||
|
||||
1. **URLs in CSS `url()` values**: Pass through `sanitizeUrl()` at parse time. The `URL` constructor normalises the string, percent-encoding characters like `"`, `)`, and `\` that could escape the CSS context. Invalid or non-`https:` URLs are rejected entirely. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
|
||||
|
||||
2. **Strings in CSS declarations** (e.g. font family names): Use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens, underscores, apostrophes, and periods are permitted. Everything else is stripped.
|
||||
|
||||
```typescript
|
||||
// ❌ UNSAFE — raw event data interpolated into CSS
|
||||
const bgUrl = getTagValue(event.tags, 'bg');
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); }`;
|
||||
|
||||
const family = getTagValue(event.tags, 'f');
|
||||
style.textContent = `html { font-family: "${family}"; }`;
|
||||
|
||||
// ✅ SAFE — URLs validated, strings sanitised
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
const bgUrl = sanitizeUrl(getTagValue(event.tags, 'bg'));
|
||||
if (bgUrl) {
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); }`;
|
||||
}
|
||||
|
||||
// For non-URL strings, allowlist safe characters only
|
||||
const safeFamily = family.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
|
||||
style.textContent = `html { font-family: "${safeFamily}"; }`;
|
||||
```
|
||||
|
||||
**Rule of thumb**: Never interpolate untrusted strings into CSS without sanitisation. If it's a URL, use `sanitizeUrl()`. If it's any other string, strip characters that can break out of the CSS string context.
|
||||
|
||||
### The `useNostr` Hook
|
||||
|
||||
The `useNostr` hook returns an object containing a `nostr` property, with `.query()` and `.event()` methods for querying and publishing Nostr events respectively.
|
||||
@@ -1335,6 +1407,10 @@ Run available tools in this priority order:
|
||||
|
||||
The validation ensures code quality and catches errors before deployment, regardless of the development environment.
|
||||
|
||||
### Contributing Guide
|
||||
|
||||
When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing.
|
||||
|
||||
### Using Git
|
||||
|
||||
If git is available in your environment (through a `shell` tool, or other git-specific tools), you should utilize `git log` to understand project history. Use `git status` and `git diff` to check the status of your changes, and if you make a mistake use `git checkout` to restore files.
|
||||
@@ -1412,7 +1488,7 @@ The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
|
||||
2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only)
|
||||
3. **build** - Builds a signed release APK (`build-apk` job, tags only)
|
||||
4. **release** - Creates a GitLab Release with the APK artifact (tags only)
|
||||
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
|
||||
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only) and AAB to Google Play (`publish-google-play` job, tags only)
|
||||
|
||||
### Creating a Release
|
||||
|
||||
@@ -1422,7 +1498,7 @@ Releases are triggered by pushing a version tag. Use the npm script:
|
||||
npm run release
|
||||
```
|
||||
|
||||
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, and `publish-zapstore` stages.
|
||||
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` stages.
|
||||
|
||||
### Zapstore Publishing
|
||||
|
||||
@@ -1514,4 +1590,29 @@ The `--use-fallback-relays` and `--use-fallback-servers` flags also include nsyt
|
||||
To rotate the nsite credential:
|
||||
1. Revoke the old bunker connection in your signer app
|
||||
2. Run `nsyte ci` again to generate a new `nbunksec1...` string
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
|
||||
|
||||
### Google Play Publishing
|
||||
|
||||
The project automatically publishes Android AABs (App Bundles) to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). The `publish-google-play` CI job runs after a successful AAB build and uploads directly to the production track.
|
||||
|
||||
**GitLab CI/CD Variables** (Settings > CI/CD > Variables):
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Full JSON contents of the Google Play API service account key file | Yes | Yes | No |
|
||||
|
||||
#### Initial Setup (one-time)
|
||||
|
||||
1. Create or reuse a project in the [Google Cloud Console](https://console.cloud.google.com/projectcreate)
|
||||
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project
|
||||
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it
|
||||
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`
|
||||
5. Add the full JSON contents of the key file as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
|
||||
|
||||
#### Key Points
|
||||
|
||||
- The job uploads the signed AAB (not APK) since Google Play requires App Bundles
|
||||
- Uploads go directly to the **production** track -- Google's review process still applies before the update reaches users
|
||||
- Metadata, screenshots, and changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.)
|
||||
- The same signing keystore used for Zapstore is used here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`)
|
||||
@@ -1,5 +1,75 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.6] - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
|
||||
- Emoji shortcodes now render as color emoji instead of plain text glyphs
|
||||
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
|
||||
- Signing requests on Android are more reliable and no longer silently fail after switching apps
|
||||
|
||||
## [2.6.5] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
|
||||
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
|
||||
|
||||
### Fixed
|
||||
- External API requests on Android no longer fail due to hostname restrictions
|
||||
- iOS App Store compliance issues resolved
|
||||
|
||||
## [2.6.4] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
|
||||
|
||||
### Changed
|
||||
- Empty feeds show a friendlier state with a discover button to help you find people to follow
|
||||
- Signup flow simplified -- cleaner profile step with a single Continue button
|
||||
|
||||
### Fixed
|
||||
- Avatar fallback now shows the user's initial instead of a question mark
|
||||
- Android 16+ devices no longer have content hidden behind system bars
|
||||
- Signup dialog background clears properly when switching between light and dark themes
|
||||
- Sticky compose button stays anchored to the bottom even on empty feeds
|
||||
|
||||
## [2.6.3] - 2026-04-10
|
||||
|
||||
### Added
|
||||
- Lightning invoices embedded in posts now render as tappable payment cards
|
||||
- Blobbi companions in the feed reflect their current condition and projected health
|
||||
|
||||
### Changed
|
||||
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
|
||||
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
|
||||
- "Request to Vanish" renamed to "Delete Account" for clarity
|
||||
|
||||
### Fixed
|
||||
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
|
||||
- Security hardening for URLs and styles sourced from the network
|
||||
|
||||
## [2.6.2] - 2026-04-08
|
||||
|
||||
### Added
|
||||
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
|
||||
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
|
||||
- "Write a letter" option on profile menus for a more personal way to reach out
|
||||
- Push vs persistent notification delivery option on Android
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps always open fullscreen for a more immersive experience
|
||||
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
|
||||
- Profile fields now appear inline instead of in a separate right sidebar
|
||||
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
|
||||
|
||||
### Fixed
|
||||
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
|
||||
- File downloads now save directly to Documents on iOS and Android instead of silently failing
|
||||
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
|
||||
- iOS swipe-back navigation works correctly throughout the app
|
||||
- Blobbi companions appear reliably on profiles instead of sometimes going missing
|
||||
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
# Contributing to Ditto
|
||||
|
||||
We welcome contributions, but we have high standards. Ditto is a carefully designed product with a specific vision, and every merge request must meet that bar. This guide exists to help you succeed.
|
||||
|
||||
**Required reading before you start:**
|
||||
|
||||
- [Ditto Philosophy](https://about.ditto.pub/philosophy) -- the product vision. Your change must align with it.
|
||||
- [Contributing Guide](https://about.ditto.pub/contributing) -- the upstream contribution process.
|
||||
- `AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
|
||||
|
||||
## Understanding Ditto
|
||||
|
||||
Ditto is a carnival, not a platform. Before contributing, you need to understand what that means.
|
||||
|
||||
### The product decision filter
|
||||
|
||||
Every change to Ditto should pass this test:
|
||||
|
||||
> *Does this make Ditto more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
|
||||
|
||||
- **Magnetic** -- Ditto attracts through experience, not ideology. People don't need to understand Nostr to love it. They need to feel something they haven't felt online since the early web. Features should be odd, intriguing, and captivating -- not generic social media clones.
|
||||
- **Threatening to the status quo** -- Ditto threatens mainstream platforms when someone opens it and thinks: *"Why can't my platform do this?"* Theming, games, treasure hunts, interoperable micro-apps -- these are things walled gardens can't replicate.
|
||||
- **Peaceful to inhabit** -- Ditto displaces argument with creation, conformity with expression, and consumption with participation. No ads, no engagement-optimized algorithms, no outrage incentives.
|
||||
|
||||
If a change does all three, it belongs. If it only does one, think harder. If it does none, it doesn't belong here.
|
||||
|
||||
### What Ditto is NOT
|
||||
|
||||
- A Twitter/X clone with decentralization bolted on
|
||||
- A place to replicate features that mainstream platforms already do well
|
||||
- A showcase for generic UI components or boilerplate social features
|
||||
|
||||
### What Ditto IS
|
||||
|
||||
- A convergence point for interoperable Nostr experiences (games, treasure hunts, magic decks, themes, color moments, live streams, and things nobody has imagined yet)
|
||||
- A place where profiles feel like worlds, not business cards
|
||||
- The most fun you've had on the internet in years
|
||||
|
||||
Read the [full philosophy](https://about.ditto.pub/philosophy) for the complete vision.
|
||||
|
||||
## What we accept
|
||||
|
||||
### Bug fixes
|
||||
|
||||
One bug, one merge request. Fix exactly one thing. Don't bundle unrelated changes, don't sneak in refactors, don't "clean up while you're in there." Small, focused MRs get reviewed fast. Large ones sit.
|
||||
|
||||
### New features and significant changes
|
||||
|
||||
Every feature MR must link to an existing open issue and clearly align with the [Ditto Philosophy](https://about.ditto.pub/philosophy). The philosophy alignment section in the MR template is where you make the case for why your change belongs in Ditto. If you can't articulate that clearly, the change probably doesn't belong.
|
||||
|
||||
If you have an idea for a feature that doesn't have an issue yet:
|
||||
|
||||
1. Build it as a standalone Nostr app first (see [Contributing Guide](https://about.ditto.pub/contributing)).
|
||||
2. Prove it works and get user feedback.
|
||||
3. Open an issue to discuss integration.
|
||||
|
||||
**Feature MRs that don't link to an issue or don't align with the Ditto Philosophy will be closed.** Our open issues are our internal roadmap -- some require deep product context. If your implementation doesn't match the product vision, it will be closed regardless of code quality.
|
||||
|
||||
## Required tools
|
||||
|
||||
- **Claude Opus 4.6** (or the latest frontier model) -- not Sonnet, not GPT-4o, not local models. Quality depends on model quality.
|
||||
- **An AI coding agent with plan/research mode** -- [OpenCode](https://opencode.ai), [Shakespeare](https://shakespeare.diy), Cursor, or similar.
|
||||
- **Node.js 22+** and npm 10.9.4+.
|
||||
|
||||
## The contribution workflow
|
||||
|
||||
Follow these steps in order. Skipping steps is the most common reason MRs are rejected.
|
||||
|
||||
### 1. Ask: does anyone need this?
|
||||
|
||||
Before writing a single line of code, answer this honestly. For bug fixes this is straightforward -- someone hit the bug. For features, it requires more thought. Is there evidence of real user demand? Is the underlying technology mature enough? A beautifully written feature for a nonexistent user base is the wrong thing to build. If you can't point to a concrete user need, reconsider.
|
||||
|
||||
### 2. Understand the issue
|
||||
|
||||
Read the issue thoroughly. If anything is unclear, ask in the issue comments before writing code. Understand not just *what* to change, but *why* -- what problem does this solve for users?
|
||||
|
||||
### 3. Read the codebase conventions
|
||||
|
||||
Read `AGENTS.md` in the repo root. This is the single source of truth for how code should be written in this project. Your AI tool should load this file automatically. If it doesn't, paste it in or configure your tool to read it.
|
||||
|
||||
### 4. Read the philosophy
|
||||
|
||||
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy). Ditto is a carnival, not a platform. Your change should feel like it belongs in Ditto -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
|
||||
|
||||
### 5. Plan before you code
|
||||
|
||||
Start your AI tool in **plan mode** (or research/think mode). Spend the first few prompts:
|
||||
|
||||
- Exploring the existing codebase to understand how similar features are implemented
|
||||
- Reading the files you'll need to modify
|
||||
- Proposing an approach
|
||||
|
||||
Do not write code until you have a plan. The most expensive mistake is implementing the wrong approach.
|
||||
|
||||
### 6. Implement
|
||||
|
||||
Switch to code mode and implement your plan. Use Opus 4.6 or equivalent.
|
||||
|
||||
### 7. Run the test suite
|
||||
|
||||
```sh
|
||||
npm run test
|
||||
```
|
||||
|
||||
This runs type-checking, linting, unit tests, and a production build. All must pass. Do not submit an MR with a failing test suite.
|
||||
|
||||
### 8. Self-review
|
||||
|
||||
Run this prompt against your diff (copy the full `git diff` output and paste it to your AI tool along with this prompt):
|
||||
|
||||
```
|
||||
Review this diff as if you are a senior maintainer of this codebase who has to
|
||||
maintain it long-term. For each finding, state the file, line, and issue.
|
||||
|
||||
- [ ] Does the diff contain changes that weren't requested? Flag anything out of scope.
|
||||
- [ ] Is there dead code, commented-out blocks, or debug artifacts left in?
|
||||
- [ ] Are there placeholder comments like "// In a real app..." or "// TODO: implement"?
|
||||
- [ ] For every value displayed to a user, can you trace it from source to render without a gap?
|
||||
- [ ] Are error, loading, and empty states all handled -- and in the right order?
|
||||
- [ ] Does a mutation reflect in the UI without requiring a manual refresh?
|
||||
- [ ] Is there a new read/write path that assumes fresh data but could get a stale cache?
|
||||
- [ ] For replaceable/addressable Nostr events: is fetchFreshEvent used before mutation?
|
||||
- [ ] Does anything new block the critical render path or fire N+1 network requests?
|
||||
- [ ] Are Nostr queries efficient (combined kinds, relay-level filtering vs client-side)?
|
||||
- [ ] Are user inputs used in queries or rendered as content without sanitization?
|
||||
- [ ] Were existing patterns/conventions in AGENTS.md ignored in favor of something novel?
|
||||
- [ ] Are secrets, keys, or env-specific values hardcoded?
|
||||
- [ ] Does the code use the `any` type anywhere?
|
||||
- [ ] Is the code Capacitor-compatible (no `<a download>`, no `window.open()`)?
|
||||
- [ ] Are new Nostr event kinds documented in NIP.md with links to relevant specs?
|
||||
- [ ] Are there any new images >100KB or other large binary assets that should be hosted externally?
|
||||
- [ ] Is there any use of dangerouslySetInnerHTML, eval, innerHTML, or SVG string interpolation?
|
||||
- [ ] Is any data from a Nostr event (tags, content, pubkey, URLs) used in a security-sensitive context (href, src, query filter, trust decision) without validation?
|
||||
|
||||
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
|
||||
|
||||
Then answer: "If you were the people who have to maintain this codebase and deal
|
||||
with all long-term issues, what would be your biggest concerns about this
|
||||
implementation?"
|
||||
```
|
||||
|
||||
Address every finding before submitting.
|
||||
|
||||
### 9. Deploy a live preview
|
||||
|
||||
Deploy your branch so reviewers can test it without pulling your code:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npx surge dist your-branch-name.surge.sh
|
||||
```
|
||||
|
||||
Or use Netlify, Vercel, or any static hosting. Include the live preview URL in your MR description.
|
||||
|
||||
### 10. Take screenshots
|
||||
|
||||
Capture before and after screenshots of any UI changes. Include them directly in the MR description. If your change has no visual component, state that explicitly.
|
||||
|
||||
### 11. Submit
|
||||
|
||||
Fill out every field in the MR template. Incomplete MRs will not be reviewed.
|
||||
|
||||
## What gets your MR closed without review
|
||||
|
||||
- No linked issue
|
||||
- Feature MRs with no clear alignment with the [Ditto Philosophy](https://about.ditto.pub/philosophy)
|
||||
- Features that fail the product decision filter (not magnetic, not threatening to the status quo, not peaceful)
|
||||
- Incomplete MR template (missing checklist, screenshots, or preview URL)
|
||||
- Changes that go beyond what was asked for (scope creep)
|
||||
- Placeholder code, dead code, or debug artifacts
|
||||
- Evidence of low-quality AI generation ("In a real application..." comments, hallucinated APIs, generic template code)
|
||||
- Failing test suite
|
||||
- No evidence of planning (code-first, think-later approach produces recognizable patterns)
|
||||
- Undocumented Nostr event kinds (new kinds must be in NIP.md)
|
||||
- Large binary assets committed to git (images >100KB, fonts, videos)
|
||||
- Security issues (dangerouslySetInnerHTML, eval, innerHTML, unsanitized user input)
|
||||
|
||||
## MR review process
|
||||
|
||||
1. The CI pipeline validates your MR description automatically. If it fails, read the error message and fix your MR description.
|
||||
2. Maintainers will review your MR when all CI checks pass and the template is complete.
|
||||
3. If changes are requested, address them promptly. Stale MRs will be closed.
|
||||
|
||||
We appreciate your interest in contributing. These standards exist because reviewing a low-quality MR takes 3x longer than doing the work ourselves. Help us help you by following the process.
|
||||
@@ -138,6 +138,17 @@ src/
|
||||
public/ Static assets, icons, manifest
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions but have high standards. Please read the full [Contributing Guide](CONTRIBUTING.md) before submitting a merge request. The short version:
|
||||
|
||||
- **Bug fixes**: One bug, one MR. Keep it small and focused.
|
||||
- **New features**: Must link to an existing issue and align with the [Ditto Philosophy](https://about.ditto.pub/philosophy).
|
||||
- **Required**: Live preview URL, before/after screenshots, completed self-review checklist.
|
||||
- **Required tools**: Claude Opus 4.6 (or latest frontier model), an AI coding agent with plan mode.
|
||||
|
||||
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy) to understand what Ditto is and isn't.
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.6.1"
|
||||
versionName "2.6.6"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -11,9 +11,11 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-keyboard')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capgo-capacitor-autofill-save-password')
|
||||
implementation project(':capacitor-secure-storage-plugin')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
@@ -14,6 +17,10 @@ import org.json.JSONArray;
|
||||
/**
|
||||
* Capacitor plugin that allows the JS layer to configure the native
|
||||
* notification polling service with the user's pubkey and relay URLs.
|
||||
*
|
||||
* Supports two notification styles:
|
||||
* - "push" (default): no foreground service, relies on push notifications
|
||||
* - "persistent": starts NotificationRelayService as a foreground service
|
||||
*/
|
||||
@CapacitorPlugin(name = "DittoNotification")
|
||||
public class DittoNotificationPlugin extends Plugin {
|
||||
@@ -24,6 +31,7 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
@PluginMethod
|
||||
public void configure(PluginCall call) {
|
||||
String userPubkey = call.getString("userPubkey");
|
||||
String notificationStyle = call.getString("notificationStyle", "push");
|
||||
String relayUrlsRaw = null;
|
||||
String enabledKindsRaw = null;
|
||||
String authorsRaw = null;
|
||||
@@ -60,7 +68,8 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
if (userPubkey != null && relayUrlsRaw != null) {
|
||||
SharedPreferences.Editor editor = prefs.edit()
|
||||
.putString("userPubkey", userPubkey)
|
||||
.putString("relayUrls", relayUrlsRaw);
|
||||
.putString("relayUrls", relayUrlsRaw)
|
||||
.putString("notificationStyle", notificationStyle);
|
||||
if (enabledKindsRaw != null) {
|
||||
editor.putString("enabledKinds", enabledKindsRaw);
|
||||
}
|
||||
@@ -70,13 +79,46 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
editor.remove("authors");
|
||||
}
|
||||
editor.apply();
|
||||
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
|
||||
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., style=" + notificationStyle + ", relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
|
||||
} else {
|
||||
// Clear config (user logged out)
|
||||
prefs.edit().clear().apply();
|
||||
Log.d(TAG, "Config cleared (user logged out)");
|
||||
}
|
||||
|
||||
// Start or stop the foreground service based on style
|
||||
manageService(notificationStyle, userPubkey != null && relayUrlsRaw != null);
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the foreground service when style is "persistent" and config is valid.
|
||||
* Stop it otherwise.
|
||||
*/
|
||||
private void manageService(String style, boolean hasConfig) {
|
||||
Context ctx = getContext();
|
||||
Intent serviceIntent = new Intent(ctx, NotificationRelayService.class);
|
||||
|
||||
if ("persistent".equals(style) && hasConfig) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(serviceIntent);
|
||||
} else {
|
||||
ctx.startService(serviceIntent);
|
||||
}
|
||||
Log.d(TAG, "Started NotificationRelayService (persistent mode)");
|
||||
} catch (Exception e) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
&& e instanceof ForegroundServiceStartNotAllowedException) {
|
||||
Log.w(TAG, "Could not start foreground service: " + e.getMessage());
|
||||
} else {
|
||||
Log.w(TAG, "Failed to start service", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ctx.stopService(serviceIntent);
|
||||
Log.d(TAG, "Stopped NotificationRelayService (push mode or no config)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
@@ -11,32 +13,36 @@ import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
|
||||
private static final String PREFS_NAME = "ditto_notification_config";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
// Register the native notification config plugin before super.onCreate
|
||||
// Register native plugins before super.onCreate.
|
||||
registerPlugin(DittoNotificationPlugin.class);
|
||||
registerPlugin(SandboxPlugin.class);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Start the persistent relay connection service.
|
||||
// On Android 12+ (API 31+) the system may throw
|
||||
// ForegroundServiceStartNotAllowedException if the foreground service
|
||||
// time limit for this type has already been exhausted. We catch it so
|
||||
// the app continues to run normally; the alarm inside the service will
|
||||
// retry at the next scheduled interval.
|
||||
try {
|
||||
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent);
|
||||
} else {
|
||||
startService(serviceIntent);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
&& e instanceof ForegroundServiceStartNotAllowedException) {
|
||||
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
|
||||
} else {
|
||||
throw e;
|
||||
// Only start the foreground service if the user has opted into
|
||||
// "persistent" notification style. Default is "push" (no service).
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
String style = prefs.getString("notificationStyle", "push");
|
||||
|
||||
if ("persistent".equals(style)) {
|
||||
try {
|
||||
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent);
|
||||
} else {
|
||||
startService(serviceIntent);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
&& e instanceof ForegroundServiceStartNotAllowedException) {
|
||||
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebView;
|
||||
|
||||
import com.getcapacitor.Bridge;
|
||||
import com.getcapacitor.BridgeWebViewClient;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Capacitor plugin that intercepts requests from sandbox iframes in the
|
||||
* main Capacitor WebView.
|
||||
*
|
||||
* On Android, each sandbox iframe loads from
|
||||
* {@code https://<sandbox-id>.sandbox.native/path}. A custom
|
||||
* {@link BridgeWebViewClient} subclass intercepts these requests via
|
||||
* {@code shouldInterceptRequest}, forwards them to the JS layer as "fetch"
|
||||
* events, and blocks the WebView IO thread until JS responds with
|
||||
* {@code respondToFetch()}.
|
||||
*
|
||||
* Each unique hostname is a different web origin, so localStorage / IndexedDB
|
||||
* are fully isolated per sandbox — no separate WebView instances needed.
|
||||
*/
|
||||
@CapacitorPlugin(name = "SandboxPlugin")
|
||||
public class SandboxPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "SandboxPlugin";
|
||||
|
||||
/** Pending requests waiting for JS to respond. */
|
||||
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
// Replace the main WebView's client with our subclass that intercepts
|
||||
// sandbox iframe requests.
|
||||
Bridge bridge = getBridge();
|
||||
bridge.setWebViewClient(new SandboxBridgeWebViewClient(bridge, this));
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void respondToFetch(PluginCall call) {
|
||||
String requestId = call.getString("requestId");
|
||||
if (requestId == null) {
|
||||
call.reject("Missing required parameter: requestId");
|
||||
return;
|
||||
}
|
||||
JSObject response = call.getObject("response");
|
||||
if (response == null) {
|
||||
call.reject("Missing required parameter: response");
|
||||
return;
|
||||
}
|
||||
|
||||
int status = response.optInt("status", 200);
|
||||
String statusText = response.optString("statusText", "OK");
|
||||
String bodyBase64 = response.optString("body", null);
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
JSONObject headersObj = response.optJSONObject("headers");
|
||||
if (headersObj != null) {
|
||||
for (java.util.Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
|
||||
String key = it.next();
|
||||
headers.put(key, headersObj.optString(key));
|
||||
}
|
||||
}
|
||||
|
||||
PendingRequest pending = pendingRequests.remove(requestId);
|
||||
if (pending == null) {
|
||||
call.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] bodyBytes = null;
|
||||
if (bodyBase64 != null && !bodyBase64.equals("null")) {
|
||||
try {
|
||||
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
|
||||
}
|
||||
}
|
||||
|
||||
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
|
||||
String encoding = contentType.contains("text/") ? "UTF-8" : null;
|
||||
|
||||
InputStream body = bodyBytes != null
|
||||
? new ByteArrayInputStream(bodyBytes)
|
||||
: new ByteArrayInputStream(new byte[0]);
|
||||
|
||||
WebResourceResponse webResponse = new WebResourceResponse(
|
||||
contentType, encoding, status, statusText, headers, body
|
||||
);
|
||||
|
||||
pending.resolve(webResponse);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("requestId", requestId);
|
||||
data.put("request", request);
|
||||
notifyListeners("fetch", data);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Custom BridgeWebViewClient that intercepts sandbox iframe requests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extends Capacitor's BridgeWebViewClient to additionally intercept
|
||||
* requests from sandbox iframes (URLs matching *.sandbox.native).
|
||||
*/
|
||||
private static class SandboxBridgeWebViewClient extends BridgeWebViewClient {
|
||||
private final SandboxPlugin plugin;
|
||||
|
||||
SandboxBridgeWebViewClient(Bridge bridge, SandboxPlugin plugin) {
|
||||
super(bridge);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||
String host = request.getUrl().getHost();
|
||||
|
||||
// Intercept requests to *.sandbox.native (from sandbox iframes).
|
||||
if (host != null && host.endsWith(".sandbox.native")) {
|
||||
return handleSandboxRequest(request, host);
|
||||
}
|
||||
|
||||
// Everything else: delegate to Capacitor's default handling.
|
||||
return super.shouldInterceptRequest(view, request);
|
||||
}
|
||||
|
||||
private WebResourceResponse handleSandboxRequest(WebResourceRequest request, String host) {
|
||||
// Extract sandbox ID from the hostname (e.g. "abc123.sandbox.native" -> "abc123").
|
||||
String sandboxId = host.replace(".sandbox.native", "");
|
||||
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
PendingRequest pending = new PendingRequest();
|
||||
plugin.pendingRequests.put(requestId, pending);
|
||||
|
||||
// Serialise the request for the JS layer.
|
||||
String path = request.getUrl().getPath();
|
||||
if (path == null || path.isEmpty()) path = "/";
|
||||
String rewrittenURL = "https://" + sandboxId + ".sandbox.native" + path;
|
||||
|
||||
JSObject serialisedRequest = new JSObject();
|
||||
serialisedRequest.put("url", rewrittenURL);
|
||||
serialisedRequest.put("method", request.getMethod());
|
||||
|
||||
JSObject headers = new JSObject();
|
||||
for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
|
||||
headers.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
serialisedRequest.put("headers", headers);
|
||||
serialisedRequest.put("body", JSONObject.NULL);
|
||||
|
||||
// Emit to JS.
|
||||
plugin.emitFetchRequest(sandboxId, requestId, serialisedRequest);
|
||||
|
||||
// Block until JS responds. The WebView IO thread pool has ~6
|
||||
// threads; pre-fetching blobs in JS before setting the iframe src
|
||||
// ensures this blocking time is minimal (cache hits).
|
||||
WebResourceResponse response = pending.awaitResponse(60000);
|
||||
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Timeout — return error response.
|
||||
plugin.pendingRequests.remove(requestId);
|
||||
return new WebResourceResponse(
|
||||
"text/plain", "UTF-8", 504,
|
||||
"Gateway Timeout", new HashMap<>(),
|
||||
new ByteArrayInputStream("Request timed out".getBytes())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pending request helper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A pending request that blocks the WebView IO thread until JS responds.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private volatile WebResourceResponse response;
|
||||
private final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
void resolve(WebResourceResponse response) {
|
||||
this.response = response;
|
||||
latch.countDown();
|
||||
}
|
||||
|
||||
WebResourceResponse awaitResponse(long timeoutMs) {
|
||||
try {
|
||||
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,17 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-keyboard'
|
||||
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
include ':capgo-capacitor-autofill-save-password'
|
||||
project(':capgo-capacitor-autofill-save-password').projectDir = new File('../node_modules/@capgo/capacitor-autofill-save-password/android')
|
||||
|
||||
include ':capacitor-secure-storage-plugin'
|
||||
project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android')
|
||||
|
||||
+9
-4
@@ -5,8 +5,6 @@ const config: CapacitorConfig = {
|
||||
appName: 'Ditto',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
// Handle deep links from your domain
|
||||
hostname: 'ditto.pub',
|
||||
androidScheme: 'https',
|
||||
iosScheme: 'https'
|
||||
},
|
||||
@@ -17,9 +15,16 @@ const config: CapacitorConfig = {
|
||||
},
|
||||
ios: {
|
||||
backgroundColor: '#14161f',
|
||||
contentInset: 'automatic',
|
||||
contentInset: 'never',
|
||||
scheme: 'Ditto'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
SystemBars: {
|
||||
// Inject --safe-area-inset-* CSS variables on Android to work around
|
||||
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
|
||||
insetsHandling: 'css',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
|
||||
import customRules from "./eslint-rules/index.js";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "android"] },
|
||||
{ ignores: ["dist", "android", "ios"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<title>Ditto — Your content. Your vibe. Your rules.</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
|
||||
|
||||
<!-- Open Graph -->
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
|
||||
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -28,6 +31,10 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPlugin.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40004000100000002 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -63,11 +70,15 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
B1A2C3D40004000100000002 /* App.entitlements */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
@@ -145,6 +156,7 @@
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -156,6 +168,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -295,15 +309,17 @@
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GZLTTH5DLM;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.1;
|
||||
MARKETING_VERSION = 2.6.6;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -317,15 +333,17 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GZLTTH5DLM;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.1;
|
||||
MARKETING_VERSION = 2.6.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>webcredentials:ditto.pub</string>
|
||||
<string>webcredentials:ditto.pub?mode=developer</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -11,7 +11,7 @@
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<viewController id="BYZ-38-t0r" customClass="DittoBridgeViewController" customModule="App" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
import Capacitor
|
||||
|
||||
class DittoBridgeViewController: CAPBridgeViewController {
|
||||
|
||||
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
|
||||
let config = super.webViewConfiguration(for: instanceConfiguration)
|
||||
|
||||
// Register the sbx:// custom scheme handler BEFORE the WKWebView is
|
||||
// created. Each sandbox iframe loads from sbx://<sandbox-id>/path,
|
||||
// giving every sandbox a unique web origin with full storage isolation.
|
||||
let handler = SandboxRequestHandler()
|
||||
_sandboxHandler = handler
|
||||
config.setURLSchemeHandler(handler, forURLScheme: "sbx")
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
super.capacitorDidLoad()
|
||||
webView?.allowsBackForwardNavigationGestures = true
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,11 @@
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Ditto needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Ditto needs camera access to take photos and videos for your posts.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Ditto needs access to your microphone to record voice messages.</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import WebKit
|
||||
|
||||
// MARK: - Shared Handler Singleton
|
||||
|
||||
/// The sandbox request handler singleton.
|
||||
/// Created by `DittoBridgeViewController` at WKWebView configuration time,
|
||||
/// then connected to the `SandboxPlugin` when the plugin loads.
|
||||
var _sandboxHandler: SandboxRequestHandler?
|
||||
|
||||
// MARK: - Sandbox Scheme Handler
|
||||
|
||||
/// `WKURLSchemeHandler` for the `sbx://` custom scheme.
|
||||
///
|
||||
/// Each sandbox iframe loads from `sbx://<sandbox-id>/path`, giving every
|
||||
/// sandbox a unique web origin with full localStorage / IndexedDB / cookie
|
||||
/// isolation.
|
||||
///
|
||||
/// Intercepted requests are forwarded to the JS layer via the Capacitor
|
||||
/// plugin bridge. JS resolves the file and responds with `respondToFetch()`.
|
||||
class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
|
||||
private var pendingTasks: [String: WKURLSchemeTask] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
weak var plugin: SandboxPlugin?
|
||||
|
||||
/// Diagnostics: total number of start calls received.
|
||||
var startCallCount: Int = 0
|
||||
/// Diagnostics: last URL received by the handler.
|
||||
var lastURL: String = "(none)"
|
||||
|
||||
/// Number of pending tasks (for diagnostics).
|
||||
var pendingTaskCount: Int {
|
||||
lock.lock()
|
||||
let count = pendingTasks.count
|
||||
lock.unlock()
|
||||
return count
|
||||
}
|
||||
|
||||
// MARK: WKURLSchemeHandler
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
startCallCount += 1
|
||||
lastURL = urlSchemeTask.request.url?.absoluteString ?? "(nil)"
|
||||
|
||||
let request = urlSchemeTask.request
|
||||
guard let url = request.url else {
|
||||
urlSchemeTask.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
let requestId = UUID().uuidString
|
||||
|
||||
lock.lock()
|
||||
pendingTasks[requestId] = urlSchemeTask
|
||||
lock.unlock()
|
||||
|
||||
// Extract the sandbox ID from the hostname: sbx://<sandbox-id>/path
|
||||
let sandboxId = url.host ?? "unknown"
|
||||
|
||||
var headers: [String: String] = [:]
|
||||
if let allHeaders = request.allHTTPHeaderFields {
|
||||
headers = allHeaders
|
||||
}
|
||||
|
||||
var bodyBase64: String? = nil
|
||||
if let bodyData = request.httpBody {
|
||||
bodyBase64 = bodyData.base64EncodedString()
|
||||
}
|
||||
|
||||
let path = url.path.isEmpty ? "/" : url.path
|
||||
|
||||
// Rewrite URL so JS sees a consistent format matching Android.
|
||||
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
|
||||
|
||||
let serialisedRequest: [String: Any] = [
|
||||
"url": rewrittenURL,
|
||||
"method": request.httpMethod ?? "GET",
|
||||
"headers": headers,
|
||||
"body": bodyBase64 as Any,
|
||||
]
|
||||
|
||||
plugin?.emitFetchRequest(
|
||||
sandboxId: sandboxId,
|
||||
requestId: requestId,
|
||||
request: serialisedRequest
|
||||
)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
lock.lock()
|
||||
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
|
||||
if let key = removed?.key {
|
||||
pendingTasks.removeValue(forKey: key)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
// MARK: Response Resolution
|
||||
|
||||
func resolveRequest(
|
||||
requestId: String,
|
||||
status: Int,
|
||||
statusText: String,
|
||||
headers: [String: String],
|
||||
bodyBase64: String?
|
||||
) {
|
||||
lock.lock()
|
||||
guard let task = pendingTasks.removeValue(forKey: requestId) else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
var bodyData: Data? = nil
|
||||
if let b64 = bodyBase64 {
|
||||
bodyData = Data(base64Encoded: b64)
|
||||
}
|
||||
|
||||
let responseURL = task.request.url ?? URL(string: "sbx://unknown/")!
|
||||
let response = HTTPURLResponse(
|
||||
url: responseURL,
|
||||
statusCode: status,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: headers
|
||||
)!
|
||||
|
||||
DispatchQueue.main.async {
|
||||
task.didReceive(response)
|
||||
if let data = bodyData {
|
||||
task.didReceive(data)
|
||||
}
|
||||
task.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
lock.lock()
|
||||
let tasks = pendingTasks
|
||||
pendingTasks.removeAll()
|
||||
lock.unlock()
|
||||
|
||||
for (_, task) in tasks {
|
||||
task.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -999,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Sandbox destroyed"]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Plugin
|
||||
|
||||
/// Capacitor plugin that bridges sandbox fetch events between native and JS.
|
||||
///
|
||||
/// On iOS, sandbox iframes use the `sbx://` custom URL scheme, registered
|
||||
/// on the WKWebView configuration before the web view is created. Each
|
||||
/// sandbox loads from `sbx://<sandbox-id>/path`, providing full origin
|
||||
/// isolation (separate localStorage, cookies, etc.).
|
||||
///
|
||||
/// On Android, a custom `BridgeWebViewClient` subclass intercepts requests
|
||||
/// to `https://<sandbox-id>.sandbox.native/path`.
|
||||
@objc(SandboxPlugin)
|
||||
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "SandboxPlugin"
|
||||
public let jsName = "SandboxPlugin"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "diagnose", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
public override func load() {
|
||||
// Connect the shared handler to this plugin so it can emit events.
|
||||
_sandboxHandler?.plugin = self
|
||||
}
|
||||
|
||||
@objc func respondToFetch(_ call: CAPPluginCall) {
|
||||
guard let requestId = call.getString("requestId") else {
|
||||
call.reject("Missing required parameter: requestId")
|
||||
return
|
||||
}
|
||||
guard let response = call.getObject("response") else {
|
||||
call.reject("Missing required parameter: response")
|
||||
return
|
||||
}
|
||||
|
||||
guard let handler = _sandboxHandler else {
|
||||
call.reject("Sandbox handler not initialised")
|
||||
return
|
||||
}
|
||||
|
||||
handler.resolveRequest(
|
||||
requestId: requestId,
|
||||
status: response["status"] as? Int ?? 200,
|
||||
statusText: response["statusText"] as? String ?? "OK",
|
||||
headers: response["headers"] as? [String: String] ?? [:],
|
||||
bodyBase64: response["body"] as? String
|
||||
)
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/// Diagnostic method callable from JS to inspect native state.
|
||||
@objc func diagnose(_ call: CAPPluginCall) {
|
||||
let handler = _sandboxHandler
|
||||
call.resolve([
|
||||
"sandboxHandlerSet": handler != nil,
|
||||
"pluginConnected": handler?.plugin != nil,
|
||||
"bridgeHasWebView": bridge?.webView != nil,
|
||||
"hasListenersFetch": hasListeners("fetch"),
|
||||
"pendingTaskCount": handler?.pendingTaskCount ?? 0,
|
||||
"startCallCount": handler?.startCallCount ?? 0,
|
||||
"lastURL": handler?.lastURL ?? "(no handler)",
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Event Forwarding
|
||||
|
||||
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
|
||||
notifyListeners("fetch", data: [
|
||||
"id": sandboxId,
|
||||
"requestId": requestId,
|
||||
"request": request,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ let package = Package(
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
|
||||
.package(name: "CapgoCapacitorAutofillSavePassword", path: "../../../node_modules/@capgo/capacitor-autofill-save-password"),
|
||||
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -26,9 +28,11 @@ let package = Package(
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorShare", package: "CapacitorShare"),
|
||||
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
|
||||
.product(name: "CapgoCapacitorAutofillSavePassword", package: "CapgoCapacitorAutofillSavePassword"),
|
||||
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
Generated
+362
-190
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.6",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/keyboard": "^8.0.3",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-autofill-save-password": "^8.0.22",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -59,7 +60,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -91,10 +92,11 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.0.10",
|
||||
"@unhead/react": "^2.0.10",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -216,19 +218,66 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.27.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz",
|
||||
"integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==",
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.5",
|
||||
"@babel/types": "^7.27.3",
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@babel/parser": "^8.0.0-rc.3",
|
||||
"@babel/types": "^8.0.0-rc.3",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"@types/jsesc": "^2.5.0",
|
||||
"jsesc": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/helper-string-parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^8.0.0-rc.3"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/types": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0-rc.3",
|
||||
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
@@ -379,6 +428,15 @@
|
||||
"@capacitor/core": "^8.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/keyboard": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz",
|
||||
"integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/local-notifications": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.0.1.tgz",
|
||||
@@ -397,21 +455,21 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz",
|
||||
"integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/synapse": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@capgo/capacitor-autofill-save-password": {
|
||||
"version": "8.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@capgo/capacitor-autofill-save-password/-/capacitor-autofill-save-password-8.0.22.tgz",
|
||||
"integrity": "sha512-l6RvtTgdZWDx5fu74QcdV0NLioKmI4PwzCnscpl00ZjxHjecR/yVoB5ufsOYLAY2qyLP3jx9PUpFvEo2rPNHPA==",
|
||||
"license": "MPL-2.0",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
|
||||
@@ -1789,17 +1847,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.2.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
@@ -1811,15 +1875,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/set-array": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
||||
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -1827,9 +1882,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.25",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
||||
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -2389,20 +2444,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
@@ -2527,9 +2584,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.1.tgz",
|
||||
"integrity": "sha512-2JXxEl4e6FIFhbi96Dwv2knu5qAACYulo1a0oVell/aS8KCWsBTPd1+v0EUra0yqiUA3Q1nVLrk8mx7kQYH/yQ==",
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.1.tgz",
|
||||
"integrity": "sha512-gQUct8A7KLKvoLtv4bHpVDfmvzJlIHjZZI6DMui8vrSuzm8IqMRdAYADbR3ry1mlIQp8/c4EeR24piBpHK0WUw==",
|
||||
"dependencies": {
|
||||
"@nostrify/nostrify": "0.51.1",
|
||||
"@nostrify/types": "0.36.9"
|
||||
@@ -2556,9 +2613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.122.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
||||
"version": "0.123.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz",
|
||||
"integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -5391,9 +5448,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5408,9 +5465,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5425,9 +5482,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5442,9 +5499,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5459,9 +5516,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -5476,9 +5533,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5493,9 +5550,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5510,9 +5567,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -5527,9 +5584,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -5544,9 +5601,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5561,9 +5618,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5578,9 +5635,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5595,9 +5652,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -5605,16 +5662,18 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/wasm-runtime": "^1.1.1"
|
||||
"@emnapi/core": "1.9.1",
|
||||
"@emnapi/runtime": "1.9.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5629,9 +5688,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5653,9 +5712,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
|
||||
"integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0",
|
||||
@@ -6504,6 +6563,12 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsesc": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
|
||||
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -6895,30 +6960,33 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@unhead/addons": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/addons/-/addons-2.0.10.tgz",
|
||||
"integrity": "sha512-9+w/m+X5e7CDKXKGTym1N4MpBjrRC89cfl95RDgKwBcFJfQ3pZu50llIjx/j462VqtrNMXddBKcUnfWvQyapuw==",
|
||||
"version": "2.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/addons/-/addons-2.1.13.tgz",
|
||||
"integrity": "sha512-xiM5ERU68FEuiBCCiPZ1EDkja+kH4hKKot/7dNJufneACtGoAFWnKUcmj/iB9BKjVwgBBF3sFYO3qXjkNFXWxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.1.4",
|
||||
"@rollup/pluginutils": "^5.3.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17",
|
||||
"mlly": "^1.7.4",
|
||||
"ufo": "^1.6.1",
|
||||
"unplugin": "^2.3.4",
|
||||
"unplugin-ast": "^0.15.0"
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.0",
|
||||
"ufo": "^1.6.3",
|
||||
"unplugin": "^3.0.0",
|
||||
"unplugin-ast": "^0.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/harlan-zw"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"unhead": "^2.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@unhead/react": {
|
||||
"version": "2.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.12.tgz",
|
||||
"integrity": "sha512-1xXFrxyw29f+kScXfEb0GxjlgtnHxoYau0qpW9k8sgWhQUNnE5gNaH3u+rNhd5IqhyvbdDRJpQ25zoz0HIyGaw==",
|
||||
"version": "2.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.13.tgz",
|
||||
"integrity": "sha512-gC48tNJ0UtbithkiKCc2WUlxbVVk5o171EtruS2w2hQUblfYFHzCPu2hljjT1e0tUHXXqN8EMv7mpxHddMB2sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unhead": "2.1.12"
|
||||
"unhead": "2.1.13"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/harlan-zw"
|
||||
@@ -7192,9 +7260,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -7329,21 +7397,68 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.1.0.tgz",
|
||||
"integrity": "sha512-ROM2LlXbZBZVk97crfw8PGDOBzzsJvN2uJCmwswvPUNyfH14eg90mSN3xNqsri1JS1G9cz0VzeDUhxJkTrr4Ew==",
|
||||
"version": "3.0.0-beta.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz",
|
||||
"integrity": "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.3",
|
||||
"@babel/parser": "^8.0.0-beta.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/helper-string-parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^8.0.0-rc.3"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/types": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0-rc.3",
|
||||
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/astral-regex": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||
@@ -7645,6 +7760,15 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/capacitor-secure-storage-plugin": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.13.0.tgz",
|
||||
"integrity": "sha512-+rLC/9Z0LTaRRt6L6HjBwcDh5gqgI3NPmDSwo4hk41XQOy3EBrRo81VleIqFsowsMA3oMT+E59Bl8/HiWk0nhQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
@@ -9991,15 +10115,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string-ast": {
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-0.9.1.tgz",
|
||||
"integrity": "sha512-18dv2ZlSSgJ/jDWlZGKfnDJx56ilNlYq9F7NnwuWTErsmYmqJ2TWE4l1o2zlUHBYUGBy3tIhPCC1gxq8M5HkMA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
|
||||
"integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"magic-string": "^0.30.17"
|
||||
"magic-string": "^0.30.19"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
@@ -10982,15 +11106,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
|
||||
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.0",
|
||||
"pathe": "^2.0.1",
|
||||
"pkg-types": "^1.3.0",
|
||||
"ufo": "^1.5.4"
|
||||
"acorn": "^8.16.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^1.3.1",
|
||||
"ufo": "^1.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
@@ -12564,14 +12688,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.122.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.12"
|
||||
"@oxc-project/types": "=0.123.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.13"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -12580,27 +12704,27 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.13",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.13",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -13654,9 +13778,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
|
||||
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
@@ -13667,9 +13791,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unhead": {
|
||||
"version": "2.1.12",
|
||||
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz",
|
||||
"integrity": "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==",
|
||||
"version": "2.1.13",
|
||||
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.13.tgz",
|
||||
"integrity": "sha512-jO9M1sI6b2h/1KpIu4Jeu+ptumLmUKboRRLxys5pYHFeT+lqTzfNHbYUX9bxVDhC1FBszAGuWcUVlmvIPsah8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hookable": "^6.0.1"
|
||||
@@ -13790,37 +13914,85 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz",
|
||||
"integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
|
||||
"integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-ast/-/unplugin-ast-0.15.0.tgz",
|
||||
"integrity": "sha512-3ReKQUmmYEcNhjoyiwfFuaJU0jkZNcNk8+iLdLVWk73iojVjJLiF/QhnpAFf3O7CJd6bqhWBzNyQ68Udp2fi5Q==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-ast/-/unplugin-ast-0.16.0.tgz",
|
||||
"integrity": "sha512-1ow2FlRznoSKE7Fjk2bSxqDsvHyj/O876RqsNlipsM6A+I91t7Mi+jG7tCNNcl3vZx14z4pGXBLSl8KOPrMuFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.27.1",
|
||||
"ast-kit": "^2.0.0",
|
||||
"magic-string-ast": "^0.9.1",
|
||||
"unplugin": "^2.3.2"
|
||||
"@babel/generator": "^8.0.0-beta.4",
|
||||
"@babel/parser": "^8.0.0-beta.4",
|
||||
"@babel/types": "^8.0.0-beta.4",
|
||||
"ast-kit": "^3.0.0-beta.1",
|
||||
"magic-string-ast": "^1.0.3",
|
||||
"unplugin": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/helper-string-parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^8.0.0-rc.3"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/types": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0-rc.3",
|
||||
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
@@ -14012,16 +14184,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
|
||||
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
|
||||
"integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.12",
|
||||
"rolldown": "1.0.0-rc.13",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@@ -14039,7 +14211,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
@@ -14628,9 +14800,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node/node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15329,9 +15501,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+8
-5
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs",
|
||||
"keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload",
|
||||
"icons": "bash scripts/generate-icons.sh"
|
||||
},
|
||||
@@ -17,9 +18,10 @@
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/keyboard": "^8.0.3",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-autofill-save-password": "^8.0.22",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -65,7 +67,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -97,10 +99,11 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.0.10",
|
||||
"@unhead/react": "^2.0.10",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"GZLTTH5DLM.pub.ditto.app"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,75 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.6] - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
|
||||
- Emoji shortcodes now render as color emoji instead of plain text glyphs
|
||||
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
|
||||
- Signing requests on Android are more reliable and no longer silently fail after switching apps
|
||||
|
||||
## [2.6.5] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
|
||||
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
|
||||
|
||||
### Fixed
|
||||
- External API requests on Android no longer fail due to hostname restrictions
|
||||
- iOS App Store compliance issues resolved
|
||||
|
||||
## [2.6.4] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
|
||||
|
||||
### Changed
|
||||
- Empty feeds show a friendlier state with a discover button to help you find people to follow
|
||||
- Signup flow simplified -- cleaner profile step with a single Continue button
|
||||
|
||||
### Fixed
|
||||
- Avatar fallback now shows the user's initial instead of a question mark
|
||||
- Android 16+ devices no longer have content hidden behind system bars
|
||||
- Signup dialog background clears properly when switching between light and dark themes
|
||||
- Sticky compose button stays anchored to the bottom even on empty feeds
|
||||
|
||||
## [2.6.3] - 2026-04-10
|
||||
|
||||
### Added
|
||||
- Lightning invoices embedded in posts now render as tappable payment cards
|
||||
- Blobbi companions in the feed reflect their current condition and projected health
|
||||
|
||||
### Changed
|
||||
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
|
||||
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
|
||||
- "Request to Vanish" renamed to "Delete Account" for clarity
|
||||
|
||||
### Fixed
|
||||
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
|
||||
- Security hardening for URLs and styles sourced from the network
|
||||
|
||||
## [2.6.2] - 2026-04-08
|
||||
|
||||
### Added
|
||||
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
|
||||
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
|
||||
- "Write a letter" option on profile menus for a more personal way to reach out
|
||||
- Push vs persistent notification delivery option on Android
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps always open fullscreen for a more immersive experience
|
||||
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
|
||||
- Profile fields now appear inline instead of in a separate right sidebar
|
||||
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
|
||||
|
||||
### Fixed
|
||||
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
|
||||
- File downloads now save directly to Documents on iOS and Android instead of silently failing
|
||||
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
|
||||
- iOS swipe-back navigation works correctly throughout the app
|
||||
- Blobbi companions appear reliably on profiles instead of sometimes going missing
|
||||
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Patch capacitor.config.json to include local (non-SPM) plugin classes.
|
||||
*
|
||||
* `npx cap sync` regenerates the `packageClassList` array from SPM packages
|
||||
* only, so local plugins compiled directly into the app binary (like
|
||||
* SandboxPlugin) are not included. This script appends them after sync so
|
||||
* the Capacitor bridge eagerly registers them at startup.
|
||||
*
|
||||
* Usage: node scripts/patch-cap-config.mjs
|
||||
* Typically run after `npx cap sync`.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/** Local plugin class names to ensure are registered. */
|
||||
const LOCAL_PLUGINS = ['SandboxPlugin'];
|
||||
|
||||
const platforms = ['ios/App/App', 'android/app/src/main/assets'];
|
||||
|
||||
for (const platform of platforms) {
|
||||
const configPath = resolve(platform, 'capacitor.config.json');
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
} catch {
|
||||
// Platform may not exist or config not yet generated — skip.
|
||||
continue;
|
||||
}
|
||||
|
||||
const classList = new Set(config.packageClassList ?? []);
|
||||
let changed = false;
|
||||
|
||||
for (const plugin of LOCAL_PLUGINS) {
|
||||
if (!classList.has(plugin)) {
|
||||
classList.add(plugin);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
config.packageClassList = [...classList];
|
||||
writeFileSync(configPath, JSON.stringify(config, null, '\t') + '\n');
|
||||
console.log(`Patched ${configPath}: added ${LOCAL_PLUGINS.join(', ')}`);
|
||||
}
|
||||
}
|
||||
+11
-9
@@ -1,8 +1,7 @@
|
||||
// NOTE: This file should normally not be modified unless you are adding a new provider.
|
||||
// To add new routes, edit the AppRouter.tsx file.
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { StatusBar, Style } from "@capacitor/status-bar";
|
||||
import { Capacitor, SystemBars, SystemBarsStyle } from "@capacitor/core";
|
||||
import { NostrLoginProvider } from "@nostrify/react/login";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { InferSeoMetaPlugin } from "@unhead/addons";
|
||||
@@ -24,6 +23,7 @@ import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { PROTOCOL_MODE } from "@/lib/dmConstants";
|
||||
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
@@ -149,6 +149,8 @@ const hardcodedConfig: AppConfig = {
|
||||
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
|
||||
savedFeeds: [],
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -181,13 +183,13 @@ export function App() {
|
||||
useNsecPasteGuard();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize StatusBar for mobile apps
|
||||
// Initialize system bars for mobile apps.
|
||||
// On Android 16+ (API 36), edge-to-edge is enforced by the OS so
|
||||
// setOverlaysWebView / setBackgroundColor no longer work. The new
|
||||
// SystemBars API (bundled with @capacitor/core 8+) is the replacement.
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
StatusBar.setStyle({ style: Style.Dark }).catch(() => {
|
||||
// StatusBar may not be available on all platforms
|
||||
});
|
||||
StatusBar.setOverlaysWebView({ overlay: true }).catch(() => {
|
||||
// Ignore errors on unsupported platforms
|
||||
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
|
||||
// SystemBars may not be available on all platforms
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
@@ -198,7 +200,7 @@ export function App() {
|
||||
<SentryProvider>
|
||||
<PlausibleProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NostrLoginProvider storageKey="nostr:login">
|
||||
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<NativeNotifications />
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
@@ -33,8 +33,6 @@ interface BlobbiActionInventoryModalProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
action: InventoryAction;
|
||||
companion: BlobbiCompanion;
|
||||
/** Projected stats (with decay applied). Used for preview accuracy. */
|
||||
projectedStats?: BlobbiStats;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called when user taps Use on an item. Always uses once. */
|
||||
onUseItem: (itemId: string) => void;
|
||||
@@ -48,7 +46,6 @@ export function BlobbiActionInventoryModal({
|
||||
onOpenChange,
|
||||
action,
|
||||
companion,
|
||||
projectedStats,
|
||||
profile: _profile,
|
||||
onUseItem,
|
||||
onOpenShop: _onOpenShop,
|
||||
@@ -134,7 +131,6 @@ export function BlobbiActionInventoryModal({
|
||||
key={item.itemId}
|
||||
item={item}
|
||||
companion={companion}
|
||||
projectedStats={projectedStats}
|
||||
action={action}
|
||||
onUse={() => handleUseItem(item)}
|
||||
isUsing={isUsingItem && usingItemId === item.itemId}
|
||||
@@ -154,8 +150,6 @@ export function BlobbiActionInventoryModal({
|
||||
interface BlobbiInventoryUseRowProps {
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
/** Projected stats (with decay applied). Falls back to raw event stats. */
|
||||
projectedStats?: BlobbiStats;
|
||||
action: InventoryAction;
|
||||
onUse: () => void;
|
||||
isUsing: boolean;
|
||||
@@ -165,7 +159,6 @@ interface BlobbiInventoryUseRowProps {
|
||||
function BlobbiInventoryUseRow({
|
||||
item,
|
||||
companion,
|
||||
projectedStats,
|
||||
action,
|
||||
onUse,
|
||||
isUsing,
|
||||
@@ -175,32 +168,28 @@ function BlobbiInventoryUseRow({
|
||||
const isMedicine = action === 'medicine';
|
||||
const isClean = action === 'clean';
|
||||
|
||||
// Use projected stats for preview accuracy; fall back to raw event stats.
|
||||
// Eggs don't decay, so the fallback is always correct for them.
|
||||
const statsForPreview: Partial<BlobbiStats> = projectedStats ?? companion.stats;
|
||||
|
||||
// Preview stat changes - handle egg-specific preview for medicine and clean
|
||||
const { normalStatChanges, eggStatChanges } = useMemo(() => {
|
||||
if (isEgg && isMedicine) {
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewMedicineForEgg(statsForPreview.health, item.effect),
|
||||
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
|
||||
};
|
||||
}
|
||||
if (isEgg && isClean) {
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewCleanForEgg(
|
||||
{ hygiene: statsForPreview.hygiene, happiness: statsForPreview.happiness },
|
||||
{ hygiene: companion.stats.hygiene, happiness: companion.stats.happiness },
|
||||
item.effect
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
normalStatChanges: previewStatChanges(statsForPreview, item.effect),
|
||||
normalStatChanges: previewStatChanges(companion.stats, item.effect),
|
||||
eggStatChanges: [] as EggStatPreview[],
|
||||
};
|
||||
}, [statsForPreview, item.effect, isEgg, isMedicine, isClean]);
|
||||
}, [companion.stats, item.effect, isEgg, isMedicine, isClean]);
|
||||
|
||||
const hasChanges = normalStatChanges.length > 0 || eggStatChanges.length > 0;
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
* Fetches the current companion data from the user's Blobbonaut profile.
|
||||
* This is the data layer - it handles fetching and provides companion data.
|
||||
*
|
||||
* IMPORTANT: This hook shares the same query cache as BlobbiPage via
|
||||
* useBlobbisCollection. This ensures:
|
||||
* - Immediate reactivity when stats change (optimistic updates)
|
||||
* - Projected decay is applied for accurate visual reactions
|
||||
* - No duplicate queries or stale cache issues
|
||||
* Uses useBlobbisCollection with a targeted dList (single d-tag) for efficiency.
|
||||
* Optimistic updates from mutations propagate across all blobbi-collection
|
||||
* queries (including BlobbiPage's 'all' mode) via updateCompanionEvent.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
@@ -32,16 +30,14 @@ interface UseBlobbiCompanionDataResult {
|
||||
*
|
||||
* Flow:
|
||||
* 1. Use useBlobbonautProfile to get the profile (shared query, reactive)
|
||||
* 2. Build a dList containing just the currentCompanion
|
||||
* 3. Use useBlobbisCollection (shared with BlobbiPage) to get the companion
|
||||
* 2. Build a dList containing just the currentCompanion (targeted fetch)
|
||||
* 3. Use useBlobbisCollection with the dList to get the companion
|
||||
* 4. Apply projected decay for accurate UI reactions
|
||||
* 5. Return the companion data with projected stats
|
||||
*
|
||||
* Reactivity:
|
||||
* - Uses the same query cache as BlobbiPage (blobbi-collection)
|
||||
* - When Blobbi state is updated, optimistic updates flow through immediately
|
||||
* - Projected decay recalculates every 60 seconds
|
||||
* - No separate query or stale cache issues
|
||||
* - Optimistic updates propagate across all blobbi-collection queries
|
||||
* - Projected decay recalculates every 60 seconds while mounted
|
||||
*/
|
||||
export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
|
||||
// Use the shared profile hook - this ensures reactivity when profile changes
|
||||
|
||||
@@ -23,10 +23,8 @@ import {
|
||||
KIND_BLOBBI_STATE,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
type BlobbiCompanion,
|
||||
type BlobbiStats,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { calculateProjectedDecay } from '@/blobbi/core/hooks/useProjectedBlobbiState';
|
||||
import { checkItemCategoryNeed, type NeedCheckResult } from '../interaction/needDetection';
|
||||
import type { ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
import type { Position } from '../types/companion.types';
|
||||
@@ -88,13 +86,10 @@ export function useCompanionItemReaction({
|
||||
// Get current companion's d-tag from profile
|
||||
const currentCompanionD = profile?.currentCompanion;
|
||||
|
||||
// Fetch the parsed companion (raw event data).
|
||||
// We cache the BlobbiCompanion itself — NOT projected stats — because
|
||||
// projected values become stale within the staleTime window. Projection
|
||||
// is done at point-of-use below so it is always fresh.
|
||||
const companionQuery = useQuery({
|
||||
// Fetch companion stats
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ['companion-stats', user?.pubkey, currentCompanionD],
|
||||
queryFn: async ({ signal }): Promise<BlobbiCompanion | null> => {
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey || !currentCompanionD) return null;
|
||||
|
||||
const events = await nostr.query([{
|
||||
@@ -109,64 +104,38 @@ export function useCompanionItemReaction({
|
||||
|
||||
if (validEvents.length === 0) return null;
|
||||
|
||||
return parseBlobbiEvent(validEvents[0]) ?? null;
|
||||
const companion = parseBlobbiEvent(validEvents[0]);
|
||||
return companion?.stats ?? null;
|
||||
},
|
||||
enabled: isActive && !!user?.pubkey && !!currentCompanionD,
|
||||
staleTime: 30_000,
|
||||
gcTime: 60_000,
|
||||
staleTime: 30_000, // 30 seconds - stats don't change that fast
|
||||
gcTime: 60_000, // 1 minute
|
||||
});
|
||||
|
||||
const cachedCompanion = companionQuery.data ?? null;
|
||||
|
||||
// Keep a ref so callbacks always read the latest cached companion
|
||||
// without needing to be recreated on every query update.
|
||||
const companionRef = useRef<BlobbiCompanion | null>(null);
|
||||
companionRef.current = cachedCompanion;
|
||||
|
||||
/**
|
||||
* Project stats from the cached companion at call-time.
|
||||
* This ensures every invocation uses a fresh Date.now() for decay,
|
||||
* so need detection is accurate even when the underlying query data
|
||||
* hasn't been refetched.
|
||||
*/
|
||||
const getProjectedStats = useCallback((): BlobbiStats | null => {
|
||||
const c = companionRef.current;
|
||||
if (!c) return null;
|
||||
return calculateProjectedDecay(c).stats;
|
||||
}, []);
|
||||
|
||||
const hasStats = cachedCompanion !== null;
|
||||
|
||||
// Expose a snapshot for debugging/display (projected at render time)
|
||||
const stats: Partial<BlobbiStats> | null = hasStats ? getProjectedStats() : null;
|
||||
const stats = statsQuery.data ?? null;
|
||||
const hasStats = stats !== null;
|
||||
|
||||
/**
|
||||
* Check if Blobbi needs an item category based on current stats.
|
||||
* Projects stats at call-time for accuracy.
|
||||
*/
|
||||
const checkItemNeed = useCallback((category: ShopItemCategory): ItemReactionResult | null => {
|
||||
const projected = getProjectedStats();
|
||||
if (!projected) return null;
|
||||
if (!stats) return null;
|
||||
|
||||
const needResult = checkItemCategoryNeed(category, projected);
|
||||
const needResult = checkItemCategoryNeed(category, stats);
|
||||
return {
|
||||
needsItem: needResult.needsItem,
|
||||
needResult,
|
||||
};
|
||||
}, [getProjectedStats]);
|
||||
}, [stats]);
|
||||
|
||||
/**
|
||||
* React to an item landing on the ground.
|
||||
* Projects stats at call-time for accuracy.
|
||||
*
|
||||
* - If Blobbi needs the item: walk toward it (via onWalkTo)
|
||||
* - If Blobbi doesn't need the item: glance at it briefly (via onGlance)
|
||||
*/
|
||||
const reactToItemLanding = useCallback((category: ShopItemCategory, position: Position) => {
|
||||
if (!isActive) return;
|
||||
|
||||
const projected = getProjectedStats();
|
||||
if (!projected) return;
|
||||
if (!isActive || !stats) return;
|
||||
|
||||
// Rate limit reactions
|
||||
const now = Date.now();
|
||||
@@ -175,7 +144,7 @@ export function useCompanionItemReaction({
|
||||
}
|
||||
lastReactionTimeRef.current = now;
|
||||
|
||||
const needResult = checkItemCategoryNeed(category, projected);
|
||||
const needResult = checkItemCategoryNeed(category, stats);
|
||||
|
||||
// Delay reaction slightly for more natural feel
|
||||
setTimeout(() => {
|
||||
@@ -201,7 +170,7 @@ export function useCompanionItemReaction({
|
||||
onGlance?.(position);
|
||||
}
|
||||
}, REACTION_CONFIG.reactionDelay);
|
||||
}, [isActive, getProjectedStats, onGlance, onWalkTo]);
|
||||
}, [isActive, stats, onGlance, onWalkTo]);
|
||||
|
||||
return {
|
||||
checkItemNeed,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
BLOBBI_ECOSYSTEM_NAMESPACE,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
type BlobbiCompanion,
|
||||
@@ -26,62 +27,90 @@ function chunkArray<T>(array: T[], size: number): T[][] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
|
||||
* Hook to fetch Blobbi companions (Kind 31124) owned by the logged-in user.
|
||||
*
|
||||
* Two modes:
|
||||
* - **No dList** (default): Fetches ALL the user's blobbi events by author +
|
||||
* ecosystem namespace tag. This is the authoritative source of truth —
|
||||
* the user authored these events, so we don't need a secondary index.
|
||||
* - **With dList**: Fetches only the specified d-tags. Use this when you only
|
||||
* need a specific subset (e.g. the companion layer needs just one blobbi).
|
||||
*
|
||||
* Features:
|
||||
* - Fetches ALL pets by d-tag list (no limit: 1)
|
||||
* - Chunks large d-lists into multiple queries for relay compatibility
|
||||
* - Keeps only the newest event per d-tag
|
||||
* - Returns both a lookup record and array of companions
|
||||
* - Provides invalidation and optimistic update helpers
|
||||
*/
|
||||
export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
export function useBlobbisCollection(dList?: string[] | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Create a stable query key based on sorted d-tags
|
||||
// Determine the mode: 'all' fetches everything, 'dlist' fetches by specific d-tags
|
||||
const mode = dList === undefined ? 'all' : 'dlist';
|
||||
|
||||
// Create a stable query key based on sorted d-tags (for dlist mode)
|
||||
const sortedDList = useMemo(() => {
|
||||
if (!dList || dList.length === 0) return null;
|
||||
if (mode === 'all' || !dList || dList.length === 0) return null;
|
||||
return [...dList].sort();
|
||||
}, [dList]);
|
||||
}, [mode, dList]);
|
||||
|
||||
const queryKeyDTags = sortedDList?.join(',') ?? '';
|
||||
// Query key segment: 'all' for fetch-all mode, comma-joined d-tags for dlist mode
|
||||
const queryKeySegment = mode === 'all' ? 'all' : (sortedDList?.join(',') ?? '');
|
||||
|
||||
// Main query to fetch all companions from relays
|
||||
// Main query to fetch companions from relays
|
||||
const query = useQuery({
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeySegment],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
|
||||
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
|
||||
if (!user?.pubkey) {
|
||||
console.log('[useBlobbisCollection] No pubkey, returning empty');
|
||||
return { companionsByD: {}, companions: [] };
|
||||
}
|
||||
|
||||
// Log the dList we're about to query
|
||||
console.log('[Blobbi] dList:', sortedDList);
|
||||
let allEvents: NostrEvent[];
|
||||
|
||||
// Chunk the d-list for relay compatibility
|
||||
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
|
||||
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
|
||||
|
||||
// Query all chunks in parallel
|
||||
const allEvents: NostrEvent[] = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (mode === 'all') {
|
||||
// Fetch ALL the user's blobbi events — author is the source of truth
|
||||
const filter = {
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': chunk,
|
||||
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
|
||||
'#b': [BLOBBI_ECOSYSTEM_NAMESPACE],
|
||||
};
|
||||
|
||||
// Log the filter immediately before query
|
||||
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
|
||||
console.log('[Blobbi] 31124 query filter (all):', JSON.stringify(filter, null, 2));
|
||||
|
||||
const events = await nostr.query([filter], { signal });
|
||||
allEvents.push(...events);
|
||||
allEvents = await nostr.query([filter], { signal });
|
||||
|
||||
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
|
||||
console.log('[useBlobbisCollection] Fetch-all returned', allEvents.length, 'events');
|
||||
} else {
|
||||
// Fetch by specific d-tags (for companion layer etc.)
|
||||
if (!sortedDList || sortedDList.length === 0) {
|
||||
console.log('[useBlobbisCollection] Empty dList, returning empty');
|
||||
return { companionsByD: {}, companions: [] };
|
||||
}
|
||||
|
||||
console.log('[Blobbi] dList:', sortedDList);
|
||||
|
||||
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
|
||||
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
|
||||
|
||||
allEvents = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const filter = {
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': chunk,
|
||||
};
|
||||
|
||||
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
|
||||
|
||||
const events = await nostr.query([filter], { signal });
|
||||
allEvents.push(...events);
|
||||
|
||||
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
|
||||
@@ -123,7 +152,7 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
|
||||
return { companionsByD, companions };
|
||||
},
|
||||
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
|
||||
enabled: !!user?.pubkey && (mode === 'all' || (!!sortedDList && sortedDList.length > 0)),
|
||||
staleTime: 30_000, // 30 seconds
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -137,17 +166,17 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
// pattern (fetch fresh → mutate → optimistic update) keeps the cache correct.
|
||||
// Only call this when the set of d-tags itself changes (e.g. adoption, deletion).
|
||||
const invalidate = useCallback(() => {
|
||||
if (user?.pubkey && queryKeyDTags) {
|
||||
if (user?.pubkey) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
queryKey: ['blobbi-collection', user.pubkey, queryKeySegment],
|
||||
});
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
}, [queryClient, user?.pubkey, queryKeySegment]);
|
||||
|
||||
// Update a single companion event in the query cache (optimistic update).
|
||||
// CRITICAL: Updates ALL blobbi-collection queries for this user, not just the
|
||||
// one matching the current queryKeyDTags. This ensures the BlobbiPage cache
|
||||
// and companion layer cache stay in sync (they use different d-tag lists).
|
||||
// one matching the current queryKeySegment. This ensures the BlobbiPage cache
|
||||
// and companion layer cache stay in sync (they use different query modes).
|
||||
const updateCompanionEvent = useCallback((event: NostrEvent) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed || !user?.pubkey) return;
|
||||
@@ -169,14 +198,14 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
// If no existing queries matched (first load), set our own query key
|
||||
if (matchingQueries.length === 0) {
|
||||
queryClient.setQueryData<CollectionData>(
|
||||
['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
['blobbi-collection', user.pubkey, queryKeySegment],
|
||||
{
|
||||
companionsByD: { [parsed.d]: parsed },
|
||||
companions: [parsed],
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
}, [queryClient, user?.pubkey, queryKeySegment]);
|
||||
|
||||
// Memoize return values for stability
|
||||
const companionsByD = query.data?.companionsByD ?? {};
|
||||
|
||||
@@ -68,11 +68,13 @@ export function companionDataToBlobbi(companion: CompanionData): Blobbi {
|
||||
lifeStage: companion.stage,
|
||||
state: companion.state ?? 'active',
|
||||
isSleeping,
|
||||
// Use the full projected stats from CompanionData (populated by
|
||||
// useBlobbiCompanionData with decay applied). The old code hardcoded
|
||||
// every stat to 100 except energy, which was incorrect when the data
|
||||
// pipeline already provides projected values.
|
||||
stats: companion.stats,
|
||||
stats: {
|
||||
hunger: 100,
|
||||
happiness: 100,
|
||||
health: 100,
|
||||
hygiene: 100,
|
||||
energy: companion.energy,
|
||||
},
|
||||
baseColor: companion.visualTraits.baseColor,
|
||||
secondaryColor: companion.visualTraits.secondaryColor,
|
||||
eyeColor: companion.visualTraits.eyeColor,
|
||||
|
||||
@@ -297,11 +297,10 @@ export function AdvancedSettings() {
|
||||
<div className="px-3 pt-3 pb-4 space-y-4">
|
||||
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Request to Vanish</h3>
|
||||
<h3 className="text-sm font-medium">Delete Account</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Permanently request all relays to delete your data, including your profile,
|
||||
posts, reactions, and direct messages. This action is irreversible and legally
|
||||
binding in some jurisdictions (NIP-62).
|
||||
Permanently delete your data from the network, including your profile,
|
||||
posts, reactions, and direct messages. This action is irreversible.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -310,7 +309,7 @@ export function AdvancedSettings() {
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setVanishDialogOpen(true)}
|
||||
>
|
||||
Request to Vanish
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { NsitePreviewDialog } from '@/components/NsitePreviewDialog';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAddrEvent } from '@/hooks/useEvent';
|
||||
import { NostrURI } from '@/lib/NostrURI';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Get a tag value by name. */
|
||||
@@ -106,7 +107,7 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
const about = metadata.about;
|
||||
const picture = metadata.picture;
|
||||
const banner = metadata.banner;
|
||||
const websiteUrl = getWebsiteUrl(event.tags, metadata);
|
||||
const websiteUrl = sanitizeUrl(getWebsiteUrl(event.tags, metadata));
|
||||
const hashtags = getAllTags(event.tags, 't');
|
||||
|
||||
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
|
||||
|
||||
@@ -34,6 +34,7 @@ import { usePublishRSVP } from '@/hooks/usePublishRSVP';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// --- Helpers ---
|
||||
@@ -159,7 +160,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
const location = locationRaw ? parseLocation(locationRaw) : undefined;
|
||||
const summary = getTag(event.tags, 'summary');
|
||||
const hashtags = getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean);
|
||||
const links = getAllTags(event.tags, 'r').map(([, v]) => v).filter(Boolean);
|
||||
const links = getAllTags(event.tags, 'r').map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
|
||||
|
||||
const eventCoord = useMemo(() => getEventCoord(event), [event]);
|
||||
const dateStr = useMemo(() => formatDetailDate(event), [event]);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -92,7 +93,7 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
// Extract website URL from description if present
|
||||
const descriptionUrl = useMemo(() => {
|
||||
const urlMatch = description.match(/https?:\/\/[^\s]+/);
|
||||
return urlMatch?.[0];
|
||||
return sanitizeUrl(urlMatch?.[0]);
|
||||
}, [description]);
|
||||
|
||||
// Description text without trailing URL (if the URL is the last thing)
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useInsertText } from '@/hooks/useInsertText';
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import { resizeImage } from '@/lib/resizeImage';
|
||||
|
||||
@@ -1071,7 +1072,7 @@ export function ComposeBox({
|
||||
<Avatar shape={avatarShape} className="size-12 shrink-0 mt-0.5">
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{(metadata?.name?.[0] || '?').toUpperCase()}
|
||||
{(metadata?.display_name || metadata?.name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
|
||||
@@ -292,6 +292,9 @@ export function CreateBadgeDialog({ open, onOpenChange }: CreateBadgeDialogProps
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recommended aspect ratio is 1:1 (max 1024x1024 px).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badge name */}
|
||||
|
||||
@@ -3,6 +3,7 @@ import data from '@emoji-mart/data';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
|
||||
import { usePortalDropdown } from '@/hooks/usePortalDropdown';
|
||||
|
||||
interface EmojiData {
|
||||
id: string;
|
||||
@@ -186,6 +187,14 @@ export function EmojiShortcodeAutocomplete({
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const { computePosition, renderPortal } = usePortalDropdown({
|
||||
textareaRef,
|
||||
isOpen,
|
||||
onClose: handleClose,
|
||||
dropdownHeight: 280, // must match max-h-[280px] below
|
||||
});
|
||||
|
||||
const results = useMemo(() => searchEmojis(query, customEmojis), [query, customEmojis]);
|
||||
|
||||
// Detect :shortcode query at cursor
|
||||
@@ -237,14 +246,11 @@ export function EmojiShortcodeAutocomplete({
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(0);
|
||||
|
||||
// Position the dropdown below the : character
|
||||
// Position the dropdown using fixed viewport coordinates so it isn't
|
||||
// clipped by ancestor overflow containers (e.g. the compose modal).
|
||||
const coords = getCaretCoordinates(textarea, colonPos);
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
|
||||
setDropdownPos({
|
||||
top: coords.top + lineHeight + 4,
|
||||
left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)),
|
||||
});
|
||||
}, [textareaRef]);
|
||||
setDropdownPos(computePosition(coords));
|
||||
}, [textareaRef, computePosition]);
|
||||
|
||||
// Listen for input/cursor changes on the textarea element
|
||||
useEffect(() => {
|
||||
@@ -357,10 +363,10 @@ export function EmojiShortcodeAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-[100] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[280px] overflow-y-auto py-1">
|
||||
@@ -382,7 +388,7 @@ export function EmojiShortcodeAutocomplete({
|
||||
className="size-5 object-contain shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl leading-none shrink-0">{emoji.native}</span>
|
||||
<span className="text-xl leading-none shrink-0 font-emoji">{emoji.native}</span>
|
||||
)}
|
||||
<span className="text-sm truncate">
|
||||
:{emoji.id.replace('custom:', '')}:
|
||||
@@ -392,4 +398,8 @@ export function EmojiShortcodeAutocomplete({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal to document.body so the dropdown escapes any ancestor overflow
|
||||
// clipping and CSS transform containing blocks (e.g. Radix Dialog).
|
||||
return renderPortal(dropdown, document.body);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { genUserName } from '@/lib/genUserName';
|
||||
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
|
||||
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
|
||||
import { EXTRA_KINDS } from '@/lib/extraKinds';
|
||||
import { getKindLabel } from '@/lib/kindLabels';
|
||||
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -1080,16 +1081,7 @@ function hasVideo(tags: string[][]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
|
||||
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
|
||||
31990: 'App',
|
||||
32267: 'Zapstore App',
|
||||
30063: 'Zapstore Release',
|
||||
3063: 'Zapstore Asset',
|
||||
15128: 'Nsite',
|
||||
35128: 'Nsite',
|
||||
31124: 'Blobbi',
|
||||
};
|
||||
|
||||
|
||||
export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) {
|
||||
const { data: event, isLoading } = useAddrEvent(addr);
|
||||
@@ -1105,7 +1097,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey
|
||||
if (kindDef) return kindDef.label;
|
||||
const sub = EXTRA_KINDS.flatMap((d) => d.subKinds ?? []).find((s) => s.kind === addr.kind);
|
||||
if (sub) return sub.label;
|
||||
return WELL_KNOWN_KIND_LABELS[addr.kind] ?? `Kind ${addr.kind}`;
|
||||
return getKindLabel(addr.kind);
|
||||
}, [kindDef, addr.kind]);
|
||||
|
||||
const KindIcon = useMemo(() => {
|
||||
|
||||
+34
-40
@@ -14,7 +14,7 @@ import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useInfiniteHotFeed } from '@/hooks/useTrending';
|
||||
import { DITTO_RELAYS } from '@/lib/appRelays';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedTab } from '@/hooks/useFeedTab';
|
||||
import { useInterests } from '@/hooks/useInterests';
|
||||
@@ -22,13 +22,15 @@ import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { useCuratedDittoFeed } from '@/hooks/useCuratedDittoFeed';
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import { diversifyFeedPages } from '@/lib/feedDiversity';
|
||||
import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { DITTO_RELAYS } from '@/lib/appRelays';
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { SavedFeed } from '@/contexts/AppContext';
|
||||
@@ -36,23 +38,6 @@ import type { SavedFeed } from '@/contexts/AppContext';
|
||||
type CoreFeedTab = 'follows' | 'global' | 'communities' | 'ditto';
|
||||
type FeedTab = CoreFeedTab | string; // string = saved feed id
|
||||
|
||||
/** Curated kinds for the logged-out homepage: unique Ditto content types. */
|
||||
const LANDING_KINDS = [
|
||||
36767, // Themes
|
||||
37381, // Magic Decks
|
||||
3367, // Color Moments
|
||||
37516, // Treasures
|
||||
7516, // Treasures (Found Logs)
|
||||
30030, // Emoji Packs
|
||||
30009, // Badge Definitions
|
||||
10008, // Profile Badges
|
||||
30008, // Profile Badges (legacy)
|
||||
31124, // Blobbi
|
||||
];
|
||||
|
||||
/** Webxdc needs a MIME-type tag filter, so it gets its own filter object. */
|
||||
const LANDING_WEBXDC_FILTER = { kinds: [1063], '#m': ['application/x-webxdc'] };
|
||||
|
||||
interface FeedProps {
|
||||
/** Override the kinds list instead of using feed settings. */
|
||||
kinds?: number[];
|
||||
@@ -74,6 +59,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const { savedFeeds } = useSavedFeeds();
|
||||
const { hashtags } = useInterests();
|
||||
const { hashtags: geotags } = useInterests('g');
|
||||
const { data: curatorFollowList, isError: isCuratorError } = useCuratorFollowList();
|
||||
|
||||
// Tab settings from localStorage
|
||||
const showGlobalFeed = (() => {
|
||||
@@ -150,21 +136,17 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
(kinds || tagFilters) ? { kinds, tagFilters } : undefined,
|
||||
);
|
||||
|
||||
// "Hot" sorted feed query (used when logged out on the home page, or on the Ditto tab)
|
||||
// Shows curated "otherstuff" kinds instead of kind 1. Webxdc needs a
|
||||
// separate filter with a MIME-type tag constraint.
|
||||
const topQuery = useInfiniteHotFeed(
|
||||
LANDING_KINDS,
|
||||
// Curated Ditto feed: latest content from the curator's follow list.
|
||||
const topQuery = useCuratedDittoFeed(
|
||||
curatorFollowList,
|
||||
useTopFeedForLoggedOut || !!useDittoTab,
|
||||
undefined,
|
||||
[LANDING_WEBXDC_FILTER],
|
||||
);
|
||||
|
||||
// Unify the two query shapes behind a single interface
|
||||
const useDittoQuery = useTopFeedForLoggedOut || useDittoTab;
|
||||
const activeQuery = useDittoQuery ? topQuery : feedQuery;
|
||||
const queryKey = useMemo(
|
||||
() => useDittoQuery ? ['infinite-hot-feed', LANDING_KINDS.join(',')] : ['feed', activeTab],
|
||||
() => useDittoQuery ? ['ditto-curated-feed'] : ['feed', activeTab],
|
||||
[useDittoQuery, activeTab],
|
||||
);
|
||||
|
||||
@@ -204,16 +186,25 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const seen = new Set<string>();
|
||||
|
||||
if (useDittoQuery) {
|
||||
return (rawData.pages as unknown as import('@nostrify/nostrify').NostrEvent[][])
|
||||
.flat()
|
||||
.filter((event) => {
|
||||
if (seen.has(event.id)) return false;
|
||||
seen.add(event.id);
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
|
||||
// Deduplicate and filter each page independently, then diversify
|
||||
// page-by-page so earlier pages never change when new pages arrive.
|
||||
const dedupedPages = (rawData.pages as unknown as import('@nostrify/nostrify').NostrEvent[][])
|
||||
.map((page) =>
|
||||
page
|
||||
.filter((event) => {
|
||||
if (seen.has(event.id)) return false;
|
||||
seen.add(event.id);
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at })),
|
||||
);
|
||||
|
||||
// Reorder for content-type diversity: cap any single type at 20%
|
||||
// per page and enforce a minimum gap of 4 positions between same-type
|
||||
// items, with gap state carrying across page boundaries.
|
||||
return diversifyFeedPages(dedupedPages);
|
||||
}
|
||||
|
||||
return (rawData.pages as unknown as { items: FeedItem[] }[])
|
||||
@@ -228,7 +219,9 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
});
|
||||
}, [rawData?.pages, muteItems, useDittoQuery]);
|
||||
|
||||
const showSkeleton = isPending || (isLoading && !rawData);
|
||||
// Show skeletons while loading, but not if the curator list query errored
|
||||
// (that would leave logged-out users staring at infinite skeletons).
|
||||
const showSkeleton = (isPending || (isLoading && !rawData)) && !(useDittoQuery && isCuratorError);
|
||||
|
||||
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
|
||||
// Extra tabs (Ditto, Community, saved feeds, hashtags) are only for the home feed.
|
||||
@@ -236,7 +229,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const showSavedFeedTabs = user && !isKindSpecificPage && !tagFilters;
|
||||
|
||||
return (
|
||||
<main className="flex-1 min-w-0">
|
||||
<main className="flex-1 min-w-0 min-h-dvh">
|
||||
{/* CTA (logged out, main feed only) */}
|
||||
{!user && !kinds && (
|
||||
<LandingHero
|
||||
@@ -334,10 +327,11 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
message={
|
||||
emptyMessage ?? (
|
||||
activeTab === 'follows'
|
||||
? 'No posts yet. Follow some people to see their content here.'
|
||||
? 'Your feed is empty. Follow some people to see their posts here.'
|
||||
: 'No posts found. Check your relay connections or come back soon.'
|
||||
)
|
||||
}
|
||||
showDiscover={!emptyMessage && activeTab === 'follows'}
|
||||
onSwitchToGlobal={
|
||||
activeTab === 'follows' && showGlobalFeed
|
||||
? () => handleSetActiveTab('global')
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FeedEmptyStateProps {
|
||||
@@ -5,31 +8,45 @@ interface FeedEmptyStateProps {
|
||||
message: string;
|
||||
/** Called when the user clicks "Switch to Global". Omit to hide the button. */
|
||||
onSwitchToGlobal?: () => void;
|
||||
/** Show a "Discover people" link to /packs. */
|
||||
showDiscover?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent empty state for Follows/Global feed tabs across all feed pages.
|
||||
*
|
||||
* - Follows tab: pass `onSwitchToGlobal` to render a "Switch to Global" CTA.
|
||||
* - Global tab: omit `onSwitchToGlobal`; the message should guide the user
|
||||
* - Follows tab: pass `onSwitchToGlobal` and `showDiscover` to render CTAs.
|
||||
* - Global tab: omit both; the message should guide the user
|
||||
* to check their relay connections.
|
||||
*/
|
||||
export function FeedEmptyState({
|
||||
message,
|
||||
onSwitchToGlobal,
|
||||
showDiscover,
|
||||
className,
|
||||
}: FeedEmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('py-16 px-8 text-center space-y-3', className)}>
|
||||
<p className="text-muted-foreground break-all">{message}</p>
|
||||
{onSwitchToGlobal && (
|
||||
<button
|
||||
className="text-sm text-primary hover:underline"
|
||||
onClick={onSwitchToGlobal}
|
||||
>
|
||||
Switch to Global
|
||||
</button>
|
||||
<div className={cn('py-20 px-8 flex flex-col items-center text-center', className)}>
|
||||
<div className="size-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Users className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground max-w-xs">{message}</p>
|
||||
|
||||
{(showDiscover || onSwitchToGlobal) && (
|
||||
<div className="flex flex-col gap-2 mt-5 w-full max-w-xs">
|
||||
{showDiscover && (
|
||||
<Button asChild className="rounded-full">
|
||||
<Link to="/packs">Discover people to follow</Link>
|
||||
</Button>
|
||||
)}
|
||||
{onSwitchToGlobal && (
|
||||
<Button variant="ghost" className="rounded-full" onClick={onSwitchToGlobal}>
|
||||
Browse the Global feed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Extract the first value of a tag by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
@@ -75,7 +76,7 @@ interface FileMetadataContentProps {
|
||||
* rounded card below it (similar to YouTube's description box).
|
||||
*/
|
||||
export function FileMetadataContent({ event, compact }: FileMetadataContentProps) {
|
||||
const url = getTag(event.tags, 'url');
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
const mime = getTag(event.tags, 'm') ?? '';
|
||||
const alt = getTag(event.tags, 'alt');
|
||||
const webxdcId = getTag(event.tags, 'webxdc');
|
||||
|
||||
@@ -21,26 +21,17 @@ import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parsePackEvent } from '@/lib/packUtils';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
|
||||
/** Parse a follow pack / starter pack event into structured data. */
|
||||
function parsePackEvent(event: NostrEvent) {
|
||||
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
|
||||
const title = getTag('title') || getTag('name') || 'Untitled Pack';
|
||||
const description = getTag('description') || getTag('summary') || '';
|
||||
const image = getTag('image') || getTag('thumb') || getTag('banner');
|
||||
const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk);
|
||||
|
||||
return { title, description, image, pubkeys };
|
||||
}
|
||||
|
||||
type Tab = 'feed' | 'members';
|
||||
|
||||
// ─── Feed Tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
|
||||
export function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
const { posts, isLoading } = useStreamPosts('', {
|
||||
@@ -101,7 +92,7 @@ function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
|
||||
|
||||
// ─── Members Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function PackMembersTab({
|
||||
export function PackMembersTab({
|
||||
pubkeys,
|
||||
membersMap,
|
||||
membersLoading,
|
||||
@@ -186,34 +177,32 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
|
||||
setIsFollowingAll(true);
|
||||
try {
|
||||
const signal = AbortSignal.timeout(10_000);
|
||||
// 1. Fetch freshest kind 3 from relays (not cache)
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
|
||||
const followEvents = await nostr.query(
|
||||
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
|
||||
{ signal },
|
||||
);
|
||||
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
|
||||
const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
|
||||
const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
|
||||
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
|
||||
|
||||
const latestEvent = followEvents.length > 0
|
||||
? followEvents.reduce((latest, current) => current.created_at > latest.created_at ? current : latest)
|
||||
: null;
|
||||
|
||||
const existingFollows = latestEvent
|
||||
? latestEvent.tags.filter(([name]) => name === 'p').map(([, pk]) => pk)
|
||||
: [];
|
||||
|
||||
const allFollows = [...new Set([...existingFollows, ...pubkeys])];
|
||||
const added = pubkeys.filter((pk) => !existingFollows.includes(pk));
|
||||
// 3. Merge: add new pubkeys that aren't already followed
|
||||
const newPTags = pubkeys
|
||||
.filter((pk) => !existingPubkeys.has(pk))
|
||||
.map((pk) => ['p', pk]);
|
||||
const added = newPTags.length;
|
||||
|
||||
// 4. Publish with prev for published_at preservation
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content: latestEvent?.content ?? '',
|
||||
tags: allFollows.map((pk) => ['p', pk]),
|
||||
content: prev?.content ?? '',
|
||||
tags: [...nonPTags, ...existingPTags, ...newPTags],
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Following all!',
|
||||
description: added.length > 0
|
||||
? `Added ${added.length} new account${added.length !== 1 ? 's' : ''} to your follow list.`
|
||||
description: added > 0
|
||||
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
|
||||
: 'You were already following everyone in this pack.',
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -357,7 +346,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
}
|
||||
|
||||
/** Individual member card in the follow pack. */
|
||||
function MemberCard({
|
||||
export function MemberCard({
|
||||
pubkey,
|
||||
metadata,
|
||||
isFollowed,
|
||||
@@ -437,7 +426,7 @@ function MemberCard({
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCardSkeleton() {
|
||||
export function MemberCardSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
|
||||
@@ -9,125 +9,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
|
||||
|
||||
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
|
||||
const MIN_QR_CONTRAST = 3;
|
||||
|
||||
/** Saturation threshold (%) above which a color is considered "colorful". */
|
||||
const COLORFUL_SAT_MIN = 15;
|
||||
/** Lightness range within which a color appears visually colorful. */
|
||||
const COLORFUL_L_MIN = 20;
|
||||
const COLORFUL_L_MAX = 80;
|
||||
|
||||
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
|
||||
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
|
||||
if (!raw) return null;
|
||||
const { h, s, l } = parseHsl(raw);
|
||||
if ([h, s, l].some(isNaN)) return null;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function darkenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l > 0 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.max(0, l - 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function lightenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l < 100 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.min(100, l + 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the best module color from primary and foreground.
|
||||
*
|
||||
* Strongly prefers primary since it carries the theme's brand identity.
|
||||
* Only picks foreground if it is colorful (saturation > threshold) AND
|
||||
* has significantly better contrast (> 1.5x) against the QR background.
|
||||
*/
|
||||
function pickModuleColor(
|
||||
primary: { h: number; s: number; l: number },
|
||||
foreground: { h: number; s: number; l: number } | null,
|
||||
bgRgb: [number, number, number],
|
||||
): { h: number; s: number; l: number } {
|
||||
const fgIsColorful = foreground
|
||||
&& foreground.s >= COLORFUL_SAT_MIN
|
||||
&& foreground.l >= COLORFUL_L_MIN
|
||||
&& foreground.l <= COLORFUL_L_MAX;
|
||||
|
||||
if (!fgIsColorful) return primary;
|
||||
|
||||
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
|
||||
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
|
||||
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
|
||||
const fgContrast = getContrastRatio(fgRgb, bgRgb);
|
||||
|
||||
// Foreground must be significantly better to override primary
|
||||
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive QR module and background hex colors from the active theme.
|
||||
*
|
||||
* Light themes: white background, best themed color as modules (darkened if needed).
|
||||
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
|
||||
*
|
||||
* "Best themed color" is --primary by default. If --foreground is colorful
|
||||
* (saturation > 15%) and offers better contrast, it wins instead.
|
||||
*/
|
||||
function getThemedQRColors(): { dark: string; light: string } {
|
||||
const primary = readCssHsl('--primary');
|
||||
const foreground = readCssHsl('--foreground');
|
||||
const background = readCssHsl('--background');
|
||||
|
||||
if (!primary) return { dark: '#000000', light: '#ffffff' };
|
||||
|
||||
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
|
||||
|
||||
if (!isDark) {
|
||||
const white: [number, number, number] = [255, 255, 255];
|
||||
const module = pickModuleColor(primary, foreground, white);
|
||||
return { dark: darkenToContrast(module, white), light: '#ffffff' };
|
||||
}
|
||||
|
||||
if (!background) return { dark: '#ffffff', light: '#000000' };
|
||||
const bgRgb = hslToRgb(background.h, background.s, background.l);
|
||||
const module = pickModuleColor(primary, foreground, bgRgb);
|
||||
return {
|
||||
dark: lightenToContrast(module, bgRgb),
|
||||
light: rgbToHex(...bgRgb),
|
||||
};
|
||||
}
|
||||
import { getThemedQRColors } from '@/lib/qrColors';
|
||||
|
||||
interface FollowQRDialogProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BookMarked, Copy, Check, ExternalLink, Globe, Wand2 } from "lucide-reac
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openUrl } from "@/lib/downloadFile";
|
||||
import { sanitizeUrl } from "@/lib/sanitizeUrl";
|
||||
import { NostrURI } from "@/lib/NostrURI";
|
||||
|
||||
interface GitRepoCardProps {
|
||||
@@ -23,7 +24,7 @@ function getFaviconUrl(webUrl: string): string | undefined {
|
||||
export function GitRepoCard({ event }: GitRepoCardProps) {
|
||||
const name = event.tags.find(([n]) => n === "name")?.[1];
|
||||
const description = event.tags.find(([n]) => n === "description")?.[1];
|
||||
const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => v);
|
||||
const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
|
||||
const isPersonalFork = event.tags.some(
|
||||
([n, v]) => n === "t" && v === "personal-fork",
|
||||
);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { NostrEvent, NostrMetadata } from "@nostrify/nostrify";
|
||||
import { useNostr } from "@nostrify/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Heart,
|
||||
@@ -14,7 +12,8 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
|
||||
import { downloadTextFile } from "@/lib/downloadFile";
|
||||
import { saveNsec } from "@/lib/credentialManager";
|
||||
import { fetchFreshEvent } from "@/lib/fetchFreshEvent";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
@@ -45,6 +44,7 @@ import { toast } from "@/hooks/useToast";
|
||||
import { useUploadFile } from "@/hooks/useUploadFile";
|
||||
import { genUserName } from "@/lib/genUserName";
|
||||
import { getAvatarShape } from "@/lib/avatarShape";
|
||||
import { resolveTheme, resolveThemeConfig } from "@/themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -288,7 +288,8 @@ function SetupQuestionnaire({
|
||||
}
|
||||
}, [step, steps]);
|
||||
|
||||
// Keygen handler
|
||||
// Keygen handler — generates the key and advances to the save step.
|
||||
// The credential manager prompt is deferred until the user clicks "Continue".
|
||||
const handleGenerate = useCallback(() => {
|
||||
const sk = generateSecretKey();
|
||||
const encoded = nip19.nsecEncode(sk);
|
||||
@@ -296,31 +297,26 @@ function SetupQuestionnaire({
|
||||
next();
|
||||
}, [next]);
|
||||
|
||||
// Download + login handler
|
||||
const handleDownloadAndLogin = useCallback(async () => {
|
||||
// Continue handler for the download step — saves the key via the best
|
||||
// available method (native credential manager on iOS/Android, file download
|
||||
// on web), logs in, and advances to the next step.
|
||||
const handleDownloadContinue = useCallback(async () => {
|
||||
try {
|
||||
const decoded = nip19.decode(nsec);
|
||||
if (decoded.type !== "nsec") throw new Error("Invalid nsec");
|
||||
|
||||
const pubkey = getPublicKey(decoded.data);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const filename = `nostr-${location.hostname.replaceAll(/\./g, "-")}-${npub.slice(5, 9)}.nsec.txt`;
|
||||
|
||||
await downloadTextFile(filename, nsec);
|
||||
await saveNsec(npub, nsec);
|
||||
|
||||
// Let the user know where the file ended up on Android
|
||||
if (Capacitor.getPlatform() === "android") {
|
||||
toast({ title: "Key saved", description: `Saved to Download/${filename}` });
|
||||
}
|
||||
|
||||
// Log in with the new key
|
||||
login.nsec(nsec);
|
||||
next();
|
||||
} catch {
|
||||
toast({
|
||||
title: "Download failed",
|
||||
title: "Save failed",
|
||||
description:
|
||||
"Could not download the key file. Please copy it manually.",
|
||||
"Could not save the key. Please copy it manually.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
@@ -452,7 +448,7 @@ function SetupQuestionnaire({
|
||||
{step === "keygen" && <KeygenStep onGenerate={handleGenerate} />}
|
||||
|
||||
{step === "download" && (
|
||||
<DownloadStep nsec={nsec} onDownload={handleDownloadAndLogin} />
|
||||
<DownloadStep nsec={nsec} onContinue={handleDownloadContinue} />
|
||||
)}
|
||||
|
||||
{step === "profile" && (
|
||||
@@ -519,10 +515,10 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
|
||||
|
||||
function DownloadStep({
|
||||
nsec,
|
||||
onDownload,
|
||||
onContinue,
|
||||
}: {
|
||||
nsec: string;
|
||||
onDownload: () => void;
|
||||
onContinue: () => void;
|
||||
}) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
@@ -533,8 +529,7 @@ function DownloadStep({
|
||||
Save your secret key
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is your only way to access your account. Download it and keep it
|
||||
somewhere safe.
|
||||
This is your only way to access your account. Keep it somewhere safe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -566,17 +561,17 @@ function DownloadStep({
|
||||
</p>
|
||||
<p className="text-xs text-amber-900 dark:text-amber-300">
|
||||
This key is your only means of accessing your account. If you lose it,
|
||||
there is no way to recover it. Download it now to continue.
|
||||
there is no way to recover it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full gap-2 rounded-full h-12"
|
||||
onClick={onDownload}
|
||||
onClick={onContinue}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download and continue
|
||||
Continue
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -604,9 +599,6 @@ function ProfileStep({
|
||||
banner: "",
|
||||
website: "",
|
||||
});
|
||||
const [extraFields, setExtraFields] = useState<
|
||||
Array<{ label: string; value: string }>
|
||||
>([]);
|
||||
const [cropState, setCropState] = useState<{
|
||||
imageSrc: string;
|
||||
aspect: number;
|
||||
@@ -661,17 +653,10 @@ function ProfileStep({
|
||||
|
||||
const handlePublishProfile = useCallback(async () => {
|
||||
if (!user) return;
|
||||
const hasData =
|
||||
Object.values(profileData).some((v) => v) || extraFields.length > 0;
|
||||
const hasData = Object.values(profileData).some((v) => v);
|
||||
if (hasData) {
|
||||
try {
|
||||
const data: Record<string, unknown> = { ...profileData };
|
||||
const validFields = extraFields.filter(
|
||||
(f) => f.label.trim() && f.value.trim(),
|
||||
);
|
||||
if (validFields.length > 0)
|
||||
data.fields = validFields.map((f) => [f.label, f.value]);
|
||||
await publishEvent({ kind: 0, content: JSON.stringify(data), tags: [] });
|
||||
await publishEvent({ kind: 0, content: JSON.stringify(profileData), tags: [] });
|
||||
queryClient.invalidateQueries({ queryKey: ["logins"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["author", user.pubkey] });
|
||||
} catch {
|
||||
@@ -684,7 +669,7 @@ function ProfileStep({
|
||||
}
|
||||
}
|
||||
onNext();
|
||||
}, [user, profileData, extraFields, publishEvent, queryClient, onNext]);
|
||||
}, [user, profileData, publishEvent, queryClient, onNext]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400">
|
||||
@@ -730,8 +715,6 @@ function ProfileStep({
|
||||
}
|
||||
onPickImage={handlePickImage}
|
||||
showNip05={false}
|
||||
extraFields={extraFields}
|
||||
onExtraFieldsChange={setExtraFields}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -741,31 +724,21 @@ function ProfileStep({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onNext}
|
||||
className="flex-1 rounded-full h-11"
|
||||
disabled={isPublishing || isSaving}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePublishProfile}
|
||||
className="flex-1 rounded-full h-11 gap-1.5"
|
||||
disabled={isPublishing || isUploading || isSaving}
|
||||
>
|
||||
{isPublishing || isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue <ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePublishProfile}
|
||||
className="w-full rounded-full h-11 gap-1.5"
|
||||
disabled={isPublishing || isUploading || isSaving}
|
||||
>
|
||||
{isPublishing || isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue <ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -785,8 +758,10 @@ function ThemeStep({
|
||||
isFirst?: boolean;
|
||||
isSaving?: boolean;
|
||||
}) {
|
||||
const { customTheme } = useTheme();
|
||||
const bgUrl = customTheme?.background?.url;
|
||||
const { theme, customTheme, themes } = useTheme();
|
||||
const resolved = resolveTheme(theme);
|
||||
const activeConfig = resolved === 'custom' ? customTheme : resolveThemeConfig(resolved, themes);
|
||||
const bgUrl = activeConfig?.background?.url;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -942,32 +917,27 @@ function FollowsStep({
|
||||
.filter(([n]) => n === "p")
|
||||
.map(([, pk]) => pk);
|
||||
|
||||
// Fetch current follow list
|
||||
const followEvents: NostrEvent[] = await nostr
|
||||
.query([{ kinds: [3], authors: [user.pubkey], limit: 1 }], {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
.catch((): NostrEvent[] => []);
|
||||
// 1. Fetch freshest kind 3 from relays (not cache)
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [3],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
|
||||
const prev =
|
||||
followEvents.length > 0
|
||||
? followEvents.reduce((latest, current) =>
|
||||
current.created_at > latest.created_at ? current : latest,
|
||||
)
|
||||
: null;
|
||||
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
|
||||
const existingPTags = prev?.tags.filter(([n]) => n === "p") ?? [];
|
||||
const nonPTags = prev?.tags.filter(([n]) => n !== "p") ?? [];
|
||||
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
|
||||
|
||||
const existingFollows = prev
|
||||
? prev.tags
|
||||
.filter(([name]) => name === "p")
|
||||
.map(([, pk]) => pk)
|
||||
: [];
|
||||
|
||||
const allFollows = [...new Set([...existingFollows, ...packPubkeys])];
|
||||
// 3. Merge: add new pubkeys that aren't already followed
|
||||
const newPTags = packPubkeys
|
||||
.filter((pk) => !existingPubkeys.has(pk))
|
||||
.map((pk) => ["p", pk]);
|
||||
|
||||
// 4. Publish with prev for published_at preservation
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content: prev?.content ?? "",
|
||||
tags: allFollows.map((pk) => ["p", pk]),
|
||||
tags: [...nonPTags, ...existingPTags, ...newPTags],
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { DittoLogo } from '@/components/DittoLogo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTrendingTags } from '@/hooks/useTrending';
|
||||
import { themePresets, coreToTokens, type CoreThemeColors } from '@/themes';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -93,7 +92,6 @@ function ThemeSwatch({
|
||||
export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
|
||||
const { config } = useAppContext();
|
||||
const { theme, customTheme, applyCustomTheme, setTheme } = useTheme();
|
||||
const { data: trendingData } = useTrendingTags();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
@@ -116,8 +114,6 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
|
||||
return null;
|
||||
}, [theme, customTheme]);
|
||||
|
||||
const trendingTags = trendingData?.tags?.slice(0, 12) ?? [];
|
||||
|
||||
const updateScrollButtons = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -245,31 +241,6 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Trending Hashtags ── */}
|
||||
{trendingTags.length > 0 && (
|
||||
<div className="px-4 pb-4 landing-hero-fade" style={{ animationDelay: '320ms' }}>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2.5">
|
||||
Trending now
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{trendingTags.map(({ tag, accounts }) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${tag}`}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-secondary/60 hover:bg-secondary text-xs font-medium text-secondary-foreground transition-colors"
|
||||
>
|
||||
<span className="text-primary">#</span>{tag}
|
||||
{accounts > 1 && (
|
||||
<span className="text-muted-foreground text-[10px] ml-0.5">
|
||||
{accounts}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Divider into feed ── */}
|
||||
<div className="border-b border-border" />
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function LeftSidebar() {
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const getDisplayName = (account: Account) => account.metadata.name ?? genUserName(account.pubkey);
|
||||
const getDisplayName = (account: Account) => account.metadata.display_name || account.metadata.name || genUserName(account.pubkey);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setAccountPopoverOpen(false);
|
||||
@@ -151,7 +151,7 @@ export function LeftSidebar() {
|
||||
<Avatar shape={currentUserAvatarShape} className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{(metadata?.name?.[0] || '?').toUpperCase()}
|
||||
{(metadata?.display_name || metadata?.name || genUserName(user.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Zap, Copy, Check, ExternalLink } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { getThemedQRColors } from '@/lib/qrColors';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LightningInvoiceCardProps {
|
||||
invoice: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Parse the sats amount from a BOLT11 invoice's human-readable part. */
|
||||
function parseBolt11Amount(bolt11: string): number | null {
|
||||
const match = bolt11.toLowerCase().match(/^ln\w+?(\d+)([munp]?)1/);
|
||||
if (!match) return null;
|
||||
const value = parseInt(match[1], 10);
|
||||
if (isNaN(value)) return null;
|
||||
const multiplier = match[2];
|
||||
switch (multiplier) {
|
||||
case 'm': return value * 100_000; // milli-BTC → sats
|
||||
case 'u': return value * 100; // micro-BTC → sats
|
||||
case 'n': return value / 10; // nano-BTC → sats
|
||||
case 'p': return value / 10_000; // pico-BTC → sats
|
||||
default: return value * 100_000_000; // BTC → sats
|
||||
}
|
||||
}
|
||||
|
||||
/** Format sats with thousands separator. */
|
||||
function formatSats(sats: number): string {
|
||||
if (sats < 1) return '<1';
|
||||
const rounded = Math.round(sats);
|
||||
return rounded.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline card for rendering a BOLT11 lightning invoice found in note content.
|
||||
* Horizontal layout with theme-aware QR that expands on tap.
|
||||
* Amount text scales to fit via container query units.
|
||||
*/
|
||||
export function LightningInvoiceCard({ invoice, className }: LightningInvoiceCardProps) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [qrExpanded, setQrExpanded] = useState(false);
|
||||
|
||||
const amount = parseBolt11Amount(invoice);
|
||||
|
||||
// Generate theme-aware QR code
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const { dark, light } = getThemedQRColors();
|
||||
QRCode.toDataURL(invoice.toUpperCase(), {
|
||||
width: 400,
|
||||
margin: 2,
|
||||
color: { dark, light },
|
||||
errorCorrectionLevel: 'M',
|
||||
}).then((url) => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [invoice]);
|
||||
|
||||
const handleCopy = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoice);
|
||||
setCopied(true);
|
||||
toast({ title: 'Copied', description: 'Lightning invoice copied to clipboard' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to copy', variant: 'destructive' });
|
||||
}
|
||||
}, [invoice, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const t = setTimeout(() => setCopied(false), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [copied]);
|
||||
|
||||
const handleOpenWallet = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await openUrl(`lightning:${invoice}`);
|
||||
}, [invoice]);
|
||||
|
||||
const handlePayWebLN = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const webln = (globalThis as { webln?: { enable?: () => Promise<void>; sendPayment?: (invoice: string) => Promise<unknown> } }).webln;
|
||||
if (!webln?.sendPayment) return;
|
||||
try {
|
||||
setPaying(true);
|
||||
if (webln.enable) await webln.enable();
|
||||
await webln.sendPayment(invoice);
|
||||
toast({ title: 'Payment sent' });
|
||||
} catch {
|
||||
toast({ title: 'Payment failed', variant: 'destructive' });
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}, [invoice, toast]);
|
||||
|
||||
const toggleQr = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setQrExpanded((v) => !v);
|
||||
}, []);
|
||||
|
||||
const hasWebLN = typeof globalThis !== 'undefined' && !!(globalThis as { webln?: unknown }).webln;
|
||||
|
||||
const qrImage = qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Lightning Invoice QR"
|
||||
className="rounded-xl"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-square rounded-xl bg-muted animate-pulse" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'isolate my-2.5 relative rounded-2xl border border-border overflow-hidden @container',
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Subtle accent glow behind QR area */}
|
||||
<div className="absolute -z-10 top-0 left-0 w-44 h-44 bg-primary/[0.06] rounded-full blur-2xl" />
|
||||
|
||||
{/* Expanded QR -- square container that replaces the normal layout */}
|
||||
{qrExpanded ? (
|
||||
<button
|
||||
onClick={toggleQr}
|
||||
className="w-full aspect-square cursor-pointer p-5"
|
||||
>
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Lightning Invoice QR"
|
||||
className="w-full h-full rounded-xl"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full rounded-xl bg-muted animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
{/* QR code -- tappable thumbnail */}
|
||||
<button onClick={toggleQr} className="shrink-0 p-3 cursor-pointer">
|
||||
<div className="size-28 sm:size-40">{qrImage}</div>
|
||||
</button>
|
||||
|
||||
{/* Info column */}
|
||||
<div className="flex flex-col justify-between py-3.5 pr-3.5 min-w-0 flex-1 gap-2">
|
||||
{/* Label + amount */}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground font-medium whitespace-nowrap" style={{ fontSize: 'clamp(0.8rem, 3.5cqw, 1.05rem)' }}>
|
||||
<span className="flex items-center justify-center size-5 sm:size-6 rounded-full bg-primary/15 shrink-0">
|
||||
<Zap className="size-3 sm:size-3.5 text-primary fill-primary" />
|
||||
</span>
|
||||
Lightning Invoice
|
||||
</div>
|
||||
{amount !== null && (
|
||||
<div className="font-bold tracking-tight leading-none mt-1 whitespace-nowrap" style={{ fontSize: 'clamp(1.5rem, 8cqw, 2.5rem)' }}>
|
||||
{formatSats(amount)}
|
||||
<span className="font-normal text-muted-foreground ml-1" style={{ fontSize: 'clamp(0.75rem, 3.5cqw, 1.125rem)' }}>sats</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice string with copy */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 group max-w-full"
|
||||
>
|
||||
<span className="truncate text-xs font-mono text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{invoice}
|
||||
</span>
|
||||
{copied
|
||||
? <Check className="size-3.5 text-primary shrink-0" />
|
||||
: <Copy className="size-3.5 text-muted-foreground group-hover:text-foreground shrink-0 transition-colors" />}
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{hasWebLN && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePayWebLN}
|
||||
disabled={paying}
|
||||
className="gap-1.5 h-9 rounded-xl"
|
||||
>
|
||||
<Zap className="size-3.5" />
|
||||
{paying ? 'Paying...' : 'Pay'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={handleOpenWallet} className="gap-1.5 h-9 rounded-xl">
|
||||
<ExternalLink className="size-3.5" />
|
||||
Open in Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { LeftSidebar } from '@/components/LeftSidebar';
|
||||
import { RightSidebar } from '@/components/RightSidebar';
|
||||
import { MobileTopBar } from '@/components/MobileTopBar';
|
||||
import { MobileDrawer } from '@/components/MobileDrawer';
|
||||
import { MobileBottomNav } from '@/components/MobileBottomNav';
|
||||
@@ -9,6 +8,7 @@ import { FloatingComposeButton } from '@/components/FloatingComposeButton';
|
||||
import { CursorFireEffect } from '@/components/CursorFireEffect';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
|
||||
import { NsitePlayerContext, type NsitePlayerState } from '@/contexts/NsitePlayerContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -42,61 +42,8 @@ function PageSkeleton() {
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
{/* Right sidebar skeleton — mirrors RightSidebar's container + widget card styling */}
|
||||
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3">
|
||||
{/* Trends widget skeleton */}
|
||||
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-4 w-14" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-[28px] w-[50px] rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* Hot Posts widget skeleton */}
|
||||
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* New Accounts widget skeleton */}
|
||||
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<Skeleton className="h-6 w-28 mb-3" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
{/* Right sidebar placeholder — preserves layout width */}
|
||||
<div className="w-[300px] shrink-0 hidden xl:block" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -158,7 +105,8 @@ function MainLayoutInner() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rightSidebar !== null && (rightSidebar ?? <RightSidebar />)}
|
||||
{/* Right sidebar — render page-provided sidebar, or an empty placeholder to preserve layout width */}
|
||||
{rightSidebar ?? <div className="w-[300px] shrink-0 hidden xl:block" />}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -171,7 +119,7 @@ function MainLayoutInner() {
|
||||
{showFAB && (
|
||||
<div
|
||||
className="fixed bottom-fab right-6 z-30 pointer-events-none transition-transform duration-300 ease-in-out sidebar:hidden"
|
||||
style={navHidden ? { transform: `translateY(calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px)))` } : undefined}
|
||||
style={navHidden ? { transform: `translateY(calc(var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))` } : undefined}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} />
|
||||
@@ -191,10 +139,17 @@ function MainLayoutInner() {
|
||||
*/
|
||||
export function MainLayout() {
|
||||
const store = useMemo(() => new LayoutStore(), []);
|
||||
const [activeSubdomain, setActiveSubdomain] = useState<string | null>(null);
|
||||
const nsitePlayer = useMemo<NsitePlayerState>(
|
||||
() => ({ activeSubdomain, setActiveSubdomain }),
|
||||
[activeSubdomain],
|
||||
);
|
||||
|
||||
return (
|
||||
<LayoutStoreContext.Provider value={store}>
|
||||
<MainLayoutInner />
|
||||
<NsitePlayerContext.Provider value={nsitePlayer}>
|
||||
<MainLayoutInner />
|
||||
</NsitePlayerContext.Provider>
|
||||
</LayoutStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useNip05Verify } from '@/hooks/useNip05Verify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePortalDropdown } from '@/hooks/usePortalDropdown';
|
||||
|
||||
interface MentionAutocompleteProps {
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
@@ -89,6 +90,14 @@ export function MentionAutocomplete({
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const { computePosition, renderPortal } = usePortalDropdown({
|
||||
textareaRef,
|
||||
isOpen,
|
||||
onClose: handleClose,
|
||||
dropdownHeight: 240, // must match max-h-[240px] below
|
||||
});
|
||||
|
||||
const { data: profiles, followedPubkeys } = useSearchProfiles(
|
||||
isOpen ? mentionQuery : '',
|
||||
);
|
||||
@@ -140,15 +149,11 @@ export function MentionAutocomplete({
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(0);
|
||||
|
||||
// Position the dropdown below the @ character, relative to the textarea's
|
||||
// offsetParent (the `relative` wrapper div) so it stays inside the modal.
|
||||
// Position the dropdown using fixed viewport coordinates so it isn't
|
||||
// clipped by ancestor overflow containers (e.g. the compose modal).
|
||||
const coords = getCaretCoordinates(textarea, atPos);
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
|
||||
setDropdownPos({
|
||||
top: coords.top + lineHeight + 4,
|
||||
left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)),
|
||||
});
|
||||
}, [textareaRef]);
|
||||
setDropdownPos(computePosition(coords));
|
||||
}, [textareaRef, computePosition]);
|
||||
|
||||
// Listen for input/cursor changes on the textarea element.
|
||||
// Re-attaches whenever the underlying DOM element changes (e.g. after
|
||||
@@ -254,10 +259,10 @@ export function MentionAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-[100] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[240px] overflow-y-auto py-1">
|
||||
@@ -273,6 +278,10 @@ export function MentionAutocomplete({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal to document.body so the dropdown escapes any ancestor overflow
|
||||
// clipping and CSS transform containing blocks (e.g. Radix Dialog).
|
||||
return renderPortal(dropdown, document.body);
|
||||
}
|
||||
|
||||
function MentionItem({
|
||||
|
||||
@@ -40,8 +40,8 @@ export function MobileBottomNav() {
|
||||
setSearchOpen((v) => !v);
|
||||
}, []);
|
||||
|
||||
// Keep the nav visible while search is open regardless of scroll
|
||||
const isHidden = hidden && !searchOpen;
|
||||
// Hide the nav when search sheet is open so it doesn't compete for space
|
||||
const isHidden = hidden || searchOpen;
|
||||
|
||||
const displayName = metadata?.name || metadata?.display_name;
|
||||
const isOnProfile = user && location.pathname === profileUrl;
|
||||
@@ -137,8 +137,8 @@ export function MobileBottomNav() {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* Safe area spacer — fully opaque so any subpixel gap is invisible */}
|
||||
<div className="safe-area-bottom bg-background" />
|
||||
{/* Safe area fill — matches the arc's semi-transparent background */}
|
||||
<div className="safe-area-bottom bg-background/85" />
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -140,7 +140,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
<button
|
||||
onClick={() => setAccountExpanded((v) => !v)}
|
||||
className="flex items-center gap-3 px-3 hover:bg-secondary/60 transition-colors w-full text-left"
|
||||
style={{ minHeight: `calc(3rem + env(safe-area-inset-top, 0px))`, paddingTop: `env(safe-area-inset-top, 0px)` }}
|
||||
style={{ minHeight: `calc(3rem + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))`, paddingTop: `var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` }}
|
||||
>
|
||||
<Avatar shape={currentUserAvatarShape} className="size-7 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
@@ -336,7 +336,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
{/* Login prompt */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 border-b border-border"
|
||||
style={{ minHeight: `calc(3rem + env(safe-area-inset-top, 0px))`, paddingTop: `env(safe-area-inset-top, 0px)` }}
|
||||
style={{ minHeight: `calc(3rem + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))`, paddingTop: `var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` }}
|
||||
>
|
||||
<LoginArea className="w-full flex" />
|
||||
</div>
|
||||
|
||||
@@ -101,6 +101,28 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
const wikipediaIndex = hasWikipedia ? nextMobileIdx++ : -1;
|
||||
const archiveIndex = hasArchive ? nextMobileIdx++ : -1;
|
||||
|
||||
// Lock body scroll while the search sheet is open.
|
||||
// overflow:hidden alone is unreliable on mobile Safari, so we also
|
||||
// block touchmove on the document (except inside the results scroller).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const preventScroll = (e: TouchEvent) => {
|
||||
// Allow scrolling inside the results list
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.('[data-mobile-search-results]')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener('touchmove', preventScroll, { passive: false });
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
document.removeEventListener('touchmove', preventScroll);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -224,8 +246,8 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Bottom sheet — sits above the bottom nav bar */}
|
||||
<div className="fixed left-0 right-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 bottom-mobile-nav">
|
||||
{/* Bottom sheet — sits at the bottom of the screen with safe area clearance */}
|
||||
<div className="fixed left-0 right-0 bottom-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 pb-6">
|
||||
|
||||
{/* Results list — reversed so closest to input = most relevant */}
|
||||
{hasResults && (
|
||||
@@ -293,7 +315,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
)}
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="flex items-center px-6 py-3">
|
||||
<div className="flex items-center px-6 py-3 safe-area-bottom">
|
||||
<div className="flex items-center gap-2 flex-1 bg-secondary rounded-full px-4 py-2.5">
|
||||
{isFetching ? (
|
||||
<svg
|
||||
@@ -321,14 +343,12 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<X strokeWidth={4} className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<X strokeWidth={4} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,12 +25,12 @@ export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps)
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-20 sidebar:hidden safe-area-top transition-transform duration-300 ease-in-out"
|
||||
style={navHidden ? { transform: 'translateY(calc(-100% - 20px - env(safe-area-inset-top, 0px)))' } : undefined}
|
||||
style={navHidden ? { transform: 'translateY(calc(-100% - 20px - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))))' } : undefined}
|
||||
>
|
||||
{/* Safe-area fill — only covers the padding zone above the content with a single layer of bg. */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 bg-background/85"
|
||||
style={{ height: 'env(safe-area-inset-top, 0px)' }}
|
||||
style={{ height: 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))' }}
|
||||
/>
|
||||
{/* Relative wrapper so ArcBackground only covers the content area, not the safe-area padding above it. */}
|
||||
<div className="relative">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { NoteContent } from './NoteContent';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
describe('NoteContent', () => {
|
||||
it('linkifies URLs in kind 1 events', () => {
|
||||
it('linkifies URLs in kind 1 events', async () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -22,13 +22,13 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'https://example.com' });
|
||||
const link = await screen.findByRole('link', { name: 'https://example.com' });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('linkifies URLs in kind 1111 events (comments)', () => {
|
||||
it('linkifies URLs in kind 1111 events (comments)', async () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-comment-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -49,13 +49,13 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
|
||||
const link = await screen.findByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', 'https://nostrbook.dev/kinds/1111');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('handles text without URLs correctly', () => {
|
||||
it('handles text without URLs correctly', async () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -72,11 +72,11 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
expect(screen.getByText('This is just plain text without any links.')).toBeInTheDocument();
|
||||
expect(await screen.findByText('This is just plain text without any links.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hashtags as links', () => {
|
||||
it('renders hashtags as links', async () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -93,7 +93,7 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
const nostrHashtag = screen.getByRole('link', { name: '#nostr' });
|
||||
const nostrHashtag = await screen.findByRole('link', { name: '#nostr' });
|
||||
const bitcoinHashtag = screen.getByRole('link', { name: '#bitcoin' });
|
||||
|
||||
expect(nostrHashtag).toBeInTheDocument();
|
||||
@@ -102,7 +102,7 @@ describe('NoteContent', () => {
|
||||
expect(bitcoinHashtag).toHaveAttribute('href', '/t/bitcoin');
|
||||
});
|
||||
|
||||
it('generates deterministic names for users without metadata and styles them differently', () => {
|
||||
it('generates deterministic names for users without metadata and styles them differently', async () => {
|
||||
// Use a valid npub for testing
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
@@ -121,7 +121,7 @@ describe('NoteContent', () => {
|
||||
);
|
||||
|
||||
// The mention should be rendered with a deterministic name
|
||||
const mention = screen.getByRole('link');
|
||||
const mention = await screen.findByRole('link');
|
||||
expect(mention).toBeInTheDocument();
|
||||
|
||||
// Should have muted styling for generated names (muted-foreground instead of primary)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
|
||||
import { Lightbox, ImageGallery } from '@/components/ImageGallery';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { EmojifiedText, CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
@@ -176,7 +177,8 @@ type ContentToken =
|
||||
| { type: 'naddr-embed'; addr: AddrCoords; url?: string }
|
||||
| { type: 'nostr-link'; id: string; raw: string }
|
||||
| { type: 'hashtag'; tag: string; raw: string }
|
||||
| { type: 'relay-link'; url: string };
|
||||
| { type: 'relay-link'; url: string }
|
||||
| { type: 'lightning-invoice'; invoice: string };
|
||||
|
||||
/**
|
||||
* Regex segment matching a single visual emoji unit, including:
|
||||
@@ -234,9 +236,10 @@ export function NoteContent({
|
||||
}: NoteContentProps) {
|
||||
const tokens = useMemo(() => {
|
||||
const text = event.content;
|
||||
// Match: URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
|
||||
// Match: BOLT11 invoices | URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
|
||||
// BOLT11: optional "lightning:" prefix + lnbc/lntb/lnbcrt/lntbs + bech32 data (case-insensitive)
|
||||
// NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed)
|
||||
const regex = /((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/gu;
|
||||
const regex = /(?:lightning:)?(ln(?:bc|tb|bcrt|tbs)\d*[munp]?1[023456789acdefghjklmnpqrstuvwxyz]+)|((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/giu;
|
||||
|
||||
const result: ContentToken[] = [];
|
||||
let lastIndex = 0;
|
||||
@@ -244,9 +247,11 @@ export function NoteContent({
|
||||
let hadMatches = false;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
let [fullMatch, url] = match;
|
||||
const hashtag = match[6];
|
||||
const { 2: nostrPrefix, 3: nostrData, 4: barePrefix, 5: bareData } = match;
|
||||
let [fullMatch] = match;
|
||||
const bolt11 = match[1];
|
||||
let url = match[2];
|
||||
const hashtag = match[7];
|
||||
const { 3: nostrPrefix, 4: nostrData, 5: barePrefix, 6: bareData } = match;
|
||||
const index = match.index;
|
||||
hadMatches = true;
|
||||
|
||||
@@ -255,7 +260,9 @@ export function NoteContent({
|
||||
result.push({ type: 'text', value: text.substring(lastIndex, index) });
|
||||
}
|
||||
|
||||
if (url) {
|
||||
if (bolt11) {
|
||||
result.push({ type: 'lightning-invoice', invoice: bolt11.toLowerCase() });
|
||||
} else if (url) {
|
||||
// Strip common trailing punctuation that's likely not part of the URL
|
||||
// This handles cases like "(https://example.com)" or "Check this: https://example.com."
|
||||
const trailingPunctMatch = url.match(/^(.*?)([.,;:!?)\]]+)$/);
|
||||
@@ -409,7 +416,7 @@ export function NoteContent({
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const token = result[i];
|
||||
const isBlock = token.type === 'image-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|
||||
|| (token.type === 'naddr-embed' && !token.url);
|
||||
|| (token.type === 'naddr-embed' && !token.url) || token.type === 'lightning-invoice';
|
||||
|
||||
if (isBlock) {
|
||||
// Strip all trailing whitespace from the preceding text token.
|
||||
@@ -668,6 +675,11 @@ export function NoteContent({
|
||||
{token.url}
|
||||
</Link>
|
||||
);
|
||||
case 'lightning-invoice':
|
||||
if (disableEmbeds) {
|
||||
return <span key={i} className="text-primary break-all">{token.invoice}</span>;
|
||||
}
|
||||
return <LightningInvoiceCard key={i} invoice={token.invoice} />;
|
||||
}
|
||||
})}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
@@ -326,7 +327,13 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
|
||||
|
||||
const nip19Id = encodeEventNip19(event);
|
||||
const nostrUri = `nostr:${nip19Id}`;
|
||||
const isInSidebar = orderedItems.includes(nostrUri);
|
||||
|
||||
// Named nsite events (35128) use the nsite:// scheme in the sidebar for auto-play behavior.
|
||||
// Root sites (15128) can't be rendered as sidebar items (no naddr), so they use the normal nostr: URI.
|
||||
const isNamedNsite = event.kind === 35128;
|
||||
const nsiteUri = isNamedNsite ? `nsite://${getNsiteSubdomain(event)}` : undefined;
|
||||
const sidebarUri = nsiteUri ?? nostrUri;
|
||||
const isInSidebar = orderedItems.includes(sidebarUri);
|
||||
|
||||
const close = () => onOpenChange(false);
|
||||
|
||||
@@ -349,10 +356,10 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
if (isInSidebar) {
|
||||
removeFromSidebar(nostrUri);
|
||||
removeFromSidebar(sidebarUri);
|
||||
toast({ title: 'Removed from sidebar' });
|
||||
} else {
|
||||
addToSidebar(nostrUri);
|
||||
addToSidebar(sidebarUri);
|
||||
toast({ title: 'Added to sidebar' });
|
||||
}
|
||||
close();
|
||||
|
||||
@@ -1,42 +1,95 @@
|
||||
import type { NostrEvent } from "@nostrify/nostrify";
|
||||
import { ExternalLink, FileText, Globe, Play, Server } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ExternalLink, FileText, Globe, Pin, PinOff, Play, Server } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalFavicon } from "@/components/ExternalFavicon";
|
||||
import { NsitePreviewDialog } from "@/components/NsitePreviewDialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useNsitePlayer } from "@/contexts/NsitePlayerContext";
|
||||
import { useFeedSettings } from "@/hooks/useFeedSettings";
|
||||
import { useLinkPreview } from "@/hooks/useLinkPreview";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { getNsiteSubdomain } from "@/lib/nsiteSubdomain";
|
||||
import { sanitizeUrl } from "@/lib/sanitizeUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NsiteCardProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/** Build the nsite.lol gateway URL for an nsite event. */
|
||||
function getNsiteUrl(event: NostrEvent): string {
|
||||
return `https://${getNsiteSubdomain(event)}.nsite.lol`;
|
||||
/**
|
||||
* When set, automatically open the nsite preview. Change the value
|
||||
* (e.g. increment a counter) to re-trigger even if the component is
|
||||
* already mounted. `undefined` / `0` = don't auto-play.
|
||||
*/
|
||||
autoPlayKey?: number;
|
||||
}
|
||||
|
||||
/** Renders an nsite deployment card with a rich link preview. */
|
||||
export function NsiteCard({ event }: NsiteCardProps) {
|
||||
export function NsiteCard({ event, autoPlayKey }: NsiteCardProps) {
|
||||
const title = event.tags.find(([n]) => n === "title")?.[1];
|
||||
const description = event.tags.find(([n]) => n === "description")?.[1];
|
||||
const dTag = event.tags.find(([n]) => n === "d")?.[1];
|
||||
const sourceUrl = event.tags.find(([n]) => n === "source")?.[1];
|
||||
const sourceUrl = sanitizeUrl(event.tags.find(([n]) => n === "source")?.[1]);
|
||||
const pathTags = event.tags.filter(([n]) => n === "path");
|
||||
const serverTags = event.tags.filter(([n]) => n === "server");
|
||||
|
||||
const isNamed = event.kind === 35128 && !!dTag;
|
||||
const siteUrl = getNsiteUrl(event);
|
||||
const nsiteSubdomain = getNsiteSubdomain(event);
|
||||
const siteUrl = `https://${nsiteSubdomain}.nsite.lol`;
|
||||
const displayName = title || (isNamed ? dTag : "Root Site");
|
||||
|
||||
const { addToSidebar, removeFromSidebar, orderedItems } = useFeedSettings();
|
||||
const sidebarUri = isNamed ? `nsite://${nsiteSubdomain}` : undefined;
|
||||
const isPinned = sidebarUri ? orderedItems.includes(sidebarUri) : false;
|
||||
|
||||
const { data: preview, isLoading } = useLinkPreview(siteUrl);
|
||||
const image = preview?.thumbnail_url;
|
||||
const previewTitle = preview?.title;
|
||||
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const { activeSubdomain, setActiveSubdomain } = useNsitePlayer();
|
||||
const [previewOpen, setPreviewOpen] = useState(!!autoPlayKey);
|
||||
|
||||
// Ref tracks the latest activeSubdomain so the unmount cleanup can
|
||||
// guard against clearing a *different* nsite's active state.
|
||||
const activeRef = useRef(activeSubdomain);
|
||||
activeRef.current = activeSubdomain;
|
||||
|
||||
const handleTogglePin = useCallback(() => {
|
||||
if (!sidebarUri) return;
|
||||
if (isPinned) {
|
||||
removeFromSidebar(sidebarUri);
|
||||
toast({ title: 'Removed from sidebar' });
|
||||
} else {
|
||||
addToSidebar(sidebarUri);
|
||||
toast({ title: 'Added to sidebar' });
|
||||
}
|
||||
}, [sidebarUri, isPinned, addToSidebar, removeFromSidebar]);
|
||||
|
||||
// Sync open/close state with the global NsitePlayerContext.
|
||||
const handlePreviewOpenChange = useCallback((open: boolean) => {
|
||||
setPreviewOpen(open);
|
||||
setActiveSubdomain(open ? nsiteSubdomain : null);
|
||||
}, [nsiteSubdomain, setActiveSubdomain]);
|
||||
|
||||
// Open the player when autoPlayKey changes (e.g. sidebar clicked again).
|
||||
useEffect(() => {
|
||||
if (autoPlayKey) {
|
||||
handlePreviewOpenChange(true);
|
||||
}
|
||||
}, [autoPlayKey, handlePreviewOpenChange]);
|
||||
|
||||
// Register on mount if auto-playing, and clean up on unmount.
|
||||
useEffect(() => {
|
||||
if (previewOpen) {
|
||||
setActiveSubdomain(nsiteSubdomain);
|
||||
}
|
||||
return () => {
|
||||
// Only clear if we are still the active subdomain.
|
||||
if (activeRef.current === nsiteSubdomain) {
|
||||
setActiveSubdomain(null);
|
||||
}
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (isLoading) {
|
||||
return <NsiteCardSkeleton />;
|
||||
@@ -114,7 +167,7 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
|
||||
onClick={(e) => { e.stopPropagation(); handlePreviewOpenChange(true); }}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
Run
|
||||
@@ -144,6 +197,17 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{sidebarUri && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs ml-auto text-muted-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); handleTogglePin(); }}
|
||||
>
|
||||
{isPinned ? <PinOff className="size-3 mr-1" /> : <Pin className="size-3 mr-1" />}
|
||||
{isPinned ? 'Unpin' : 'Pin'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +216,7 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
appName={previewTitle || displayName || "nsite"}
|
||||
appPicture={undefined}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
onOpenChange={handlePreviewOpenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
import { Check, Shield, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
clearNsitePermissions,
|
||||
getNsiteAllowance,
|
||||
getPermissionLabel,
|
||||
removeNsitePermission,
|
||||
setNsitePermission,
|
||||
type NsiteAllowance,
|
||||
type NsitePermission,
|
||||
} from '@/lib/nsitePermissions';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subscribe to localStorage changes so the component re-renders when
|
||||
// permissions are modified (e.g. by the prompt granting a new permission).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STORAGE_KEY = 'nostr:nsite-permissions';
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
// Listen for changes from other tabs/windows.
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY) callback();
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
|
||||
// For same-tab mutations, we override the localStorage setter to also
|
||||
// dispatch a custom event. This is necessary because the `storage` event
|
||||
// only fires across tabs, not within the same tab.
|
||||
const onLocal = () => callback();
|
||||
window.addEventListener('nsite-permissions-changed', onLocal);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener('nsite-permissions-changed', onLocal);
|
||||
};
|
||||
}
|
||||
|
||||
let _snapshotCache: string | null = null;
|
||||
|
||||
function getSnapshot(): string | null {
|
||||
const current = localStorage.getItem(STORAGE_KEY);
|
||||
if (current !== _snapshotCache) {
|
||||
_snapshotCache = current;
|
||||
}
|
||||
return _snapshotCache;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NsitePermissionManagerProps {
|
||||
/** Canonical nsite subdomain identifier. */
|
||||
siteId: string;
|
||||
/** Human-readable site name. */
|
||||
siteName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover triggered from the nsite preview nav bar that shows and manages
|
||||
* stored permissions for the current site.
|
||||
*/
|
||||
export function NsitePermissionManager({ siteId, siteName }: NsitePermissionManagerProps) {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
// Subscribe to permission changes so the list stays in sync.
|
||||
useSyncExternalStore(subscribe, getSnapshot);
|
||||
const allowance: NsiteAllowance | undefined = user
|
||||
? getNsiteAllowance(siteId, user.pubkey)
|
||||
: undefined;
|
||||
const permissions = allowance?.permissions ?? [];
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(perm: NsitePermission) => {
|
||||
if (!user) return;
|
||||
setNsitePermission(
|
||||
siteId,
|
||||
user.pubkey,
|
||||
siteName,
|
||||
perm.type,
|
||||
perm.kind,
|
||||
!perm.allowed,
|
||||
);
|
||||
},
|
||||
[siteId, siteName, user],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(perm: NsitePermission) => {
|
||||
if (!user) return;
|
||||
removeNsitePermission(siteId, user.pubkey, perm.type, perm.kind);
|
||||
},
|
||||
[siteId, user],
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
if (!user) return;
|
||||
clearNsitePermissions(siteId, user.pubkey);
|
||||
}, [siteId, user]);
|
||||
|
||||
// Don't render the manager if no user is logged in.
|
||||
if (!user) return null;
|
||||
|
||||
const hasPermissions = permissions.length > 0;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
title="Site permissions"
|
||||
>
|
||||
<Shield className={`size-3.5 ${hasPermissions ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">Permissions</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{siteName}</p>
|
||||
</div>
|
||||
{hasPermissions && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-destructive hover:text-destructive gap-1"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Revoke all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Permission list */}
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{!hasPermissions ? (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<Shield className="size-8 text-muted-foreground/30 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No permissions granted
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
Permissions will appear here when the app requests them.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{permissions.map((perm) => (
|
||||
<div
|
||||
key={`${perm.type}-${perm.kind}`}
|
||||
className="flex items-center gap-3 px-4 py-2.5 group"
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className="shrink-0">
|
||||
{perm.allowed ? (
|
||||
<Check className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<X className="size-3.5 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-sm flex-1 min-w-0 truncate">
|
||||
{getPermissionLabel(perm.type, perm.kind)}
|
||||
</span>
|
||||
|
||||
{/* Toggle */}
|
||||
<Switch
|
||||
checked={perm.allowed}
|
||||
onCheckedChange={() => handleToggle(perm)}
|
||||
className="shrink-0 scale-75 origin-right"
|
||||
/>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemove(perm)}
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, KeyRound, Lock, Pen, ShieldAlert, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { getKindLabel } from '@/lib/nsitePermissions';
|
||||
import type { NsitePromptState, NsitePromptDecision } from '@/hooks/useNsiteSignerRpc';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NsitePermissionPromptProps {
|
||||
/** App icon URL, if available. */
|
||||
appPicture?: string;
|
||||
/** Human-readable app name. */
|
||||
appName: string;
|
||||
/** The nsite gateway URL, used to fetch the site favicon. */
|
||||
siteUrl?: string;
|
||||
/** The pending prompt state from useNsiteSignerRpc. */
|
||||
prompt: NsitePromptState;
|
||||
/** Callback to resolve the prompt. */
|
||||
onResolve: (decision: NsitePromptDecision) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getPromptIcon(type: NsitePromptState['type']) {
|
||||
switch (type) {
|
||||
case 'signEvent':
|
||||
return <Pen className="size-5 text-amber-500" />;
|
||||
case 'nip04.encrypt':
|
||||
case 'nip44.encrypt':
|
||||
return <Lock className="size-5 text-blue-500" />;
|
||||
case 'nip04.decrypt':
|
||||
case 'nip44.decrypt':
|
||||
return <KeyRound className="size-5 text-violet-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getPromptTitle(type: NsitePromptState['type'], kind: number | null): string {
|
||||
switch (type) {
|
||||
case 'signEvent':
|
||||
return kind !== null
|
||||
? `Sign: ${getKindLabel(kind)}`
|
||||
: 'Sign event';
|
||||
case 'nip04.encrypt':
|
||||
return 'Encrypt message (NIP-04)';
|
||||
case 'nip04.decrypt':
|
||||
return 'Decrypt message (NIP-04)';
|
||||
case 'nip44.encrypt':
|
||||
return 'Encrypt message (NIP-44)';
|
||||
case 'nip44.decrypt':
|
||||
return 'Decrypt message (NIP-44)';
|
||||
}
|
||||
}
|
||||
|
||||
function getPromptDescription(type: NsitePromptState['type']): string {
|
||||
switch (type) {
|
||||
case 'signEvent':
|
||||
return 'This app wants to sign a Nostr event on your behalf.';
|
||||
case 'nip04.encrypt':
|
||||
case 'nip44.encrypt':
|
||||
return 'This app wants to encrypt a message using your keys.';
|
||||
case 'nip04.decrypt':
|
||||
case 'nip44.decrypt':
|
||||
return 'This app wants to decrypt a message using your keys.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Truncate a string to a maximum character length. */
|
||||
function truncate(str: string, max: number): string {
|
||||
if (str.length <= max) return str;
|
||||
return str.slice(0, max) + '\u2026';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overlay prompt shown when an nsite requests a signer operation that requires
|
||||
* user approval. Renders on top of the nsite iframe within the preview panel.
|
||||
*/
|
||||
export function NsitePermissionPrompt({
|
||||
appPicture,
|
||||
appName,
|
||||
siteUrl,
|
||||
prompt,
|
||||
onResolve,
|
||||
}: NsitePermissionPromptProps) {
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
const handleAllow = () => onResolve({ allowed: true, remember });
|
||||
const handleDeny = () => onResolve({ allowed: false, remember });
|
||||
|
||||
const icon = getPromptIcon(prompt.type);
|
||||
const title = getPromptTitle(prompt.type, prompt.kind);
|
||||
const description = getPromptDescription(prompt.type);
|
||||
|
||||
// For signEvent, show a preview of the event content.
|
||||
const eventContent = prompt.event?.content as string | undefined;
|
||||
const eventJson = prompt.event ? JSON.stringify(prompt.event, null, 2) : null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-sm rounded-xl border bg-card shadow-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 pt-5 pb-3">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-muted">
|
||||
<ExternalFavicon
|
||||
url={siteUrl}
|
||||
size={22}
|
||||
fallback={<ShieldAlert className="size-5 text-muted-foreground" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{appName}</p>
|
||||
<p className="text-xs text-muted-foreground">Permission request</p>
|
||||
</div>
|
||||
{appPicture && (
|
||||
<img
|
||||
src={appPicture}
|
||||
alt={appName}
|
||||
className="size-8 rounded-md object-cover shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{/* Operation */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div className="shrink-0 mt-0.5">{icon}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event content preview (signEvent only) */}
|
||||
{prompt.type === 'signEvent' && eventContent && (
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">Content</p>
|
||||
<p className="text-sm break-words whitespace-pre-wrap">
|
||||
{truncate(eventContent, 280)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target pubkey (encrypt/decrypt) */}
|
||||
{prompt.targetPubkey && (
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">Target pubkey</p>
|
||||
<p className="text-xs font-mono break-all">
|
||||
{truncate(prompt.targetPubkey, 64)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw event details (collapsible) */}
|
||||
{eventJson && (
|
||||
<Collapsible open={showDetails} onOpenChange={setShowDetails}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showDetails ? 'Hide details' : 'Show details'}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-2 rounded-lg border bg-muted/30 p-3 text-xs font-mono max-h-40 overflow-auto whitespace-pre-wrap break-all">
|
||||
{eventJson}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Remember checkbox */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Checkbox
|
||||
id="nsite-remember"
|
||||
checked={remember}
|
||||
onCheckedChange={(checked) => setRemember(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="nsite-remember"
|
||||
className="text-xs text-muted-foreground cursor-pointer select-none"
|
||||
>
|
||||
Remember for this site
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 px-5 pb-5">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 gap-1.5"
|
||||
onClick={handleDeny}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-1.5"
|
||||
onClick={handleAllow}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
Allow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Package, X } from 'lucide-react';
|
||||
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { NsitePermissionManager } from '@/components/NsitePermissionManager';
|
||||
import { NsitePermissionPrompt } from '@/components/NsitePermissionPrompt';
|
||||
import { SandboxFrame } from '@/components/SandboxFrame';
|
||||
import { useCenterColumn } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNsiteSignerRpc } from '@/hooks/useNsiteSignerRpc';
|
||||
import { APP_BLOSSOM_SERVERS, getEffectiveBlossomServers } from '@/lib/appBlossom';
|
||||
import { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
|
||||
import { getNsiteNostrProviderScript } from '@/lib/nsiteNostrProvider';
|
||||
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
|
||||
import { getPreviewInjectedScript } from '@/lib/previewInjectedScript';
|
||||
import { getMimeType } from '@/lib/sandbox';
|
||||
import type { FileResponse, InjectedScript } from '@/lib/sandbox';
|
||||
|
||||
interface Rect { left: number; top: number; width: number; height: number }
|
||||
|
||||
@@ -35,38 +46,6 @@ function useElementRect(el: HTMLElement | null): Rect | null {
|
||||
return rect;
|
||||
}
|
||||
|
||||
/** The wildcard preview domain (iframe.diy service worker sandbox). */
|
||||
const PREVIEW_DOMAIN = 'iframe.diy';
|
||||
|
||||
interface JSONRPCFetchRequest {
|
||||
jsonrpc: '2.0';
|
||||
method: 'fetch';
|
||||
params: {
|
||||
request: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
};
|
||||
};
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface JSONRPCResponse {
|
||||
jsonrpc: '2.0';
|
||||
result?: {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
};
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the path→sha256 manifest from a nsite event's `path` tags.
|
||||
* Each path tag has the format: ["path", "/file/path", "<sha256>"]
|
||||
@@ -97,60 +76,106 @@ function resolveServers(event: NostrEvent, appServers: string[]): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a blob from the given sha256 by trying each Blossom server in order.
|
||||
* Returns a Response from the first server that responds successfully, or
|
||||
* throws if all servers fail.
|
||||
* Module-level preferred server. Once a Blossom server successfully serves
|
||||
* a blob, it is promoted here so subsequent requests try it first — avoiding
|
||||
* the round-trip penalty of 404s on servers that don't have the content.
|
||||
*/
|
||||
let preferredServer: string | null = null;
|
||||
|
||||
/**
|
||||
* Fetch a blob from the given sha256 by trying Blossom servers.
|
||||
*
|
||||
* If a server previously succeeded (the "preferred" server), it is tried
|
||||
* first. On success the preferred server is reinforced; on failure we fall
|
||||
* through to the remaining servers in order. Whichever server ultimately
|
||||
* succeeds is promoted to preferred for the next call.
|
||||
*/
|
||||
async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Response> {
|
||||
let lastError: unknown;
|
||||
for (const server of servers) {
|
||||
|
||||
/** Try a single server. Returns the Response on success, or null. */
|
||||
async function tryServer(server: string): Promise<Response | null> {
|
||||
const base = server.replace(/\/+$/, '');
|
||||
const url = `${base}/${sha256}`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return res;
|
||||
if (res.ok) {
|
||||
preferredServer = server;
|
||||
return res;
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try the preferred server first if it's in the list.
|
||||
if (preferredServer && servers.includes(preferredServer)) {
|
||||
const res = await tryServer(preferredServer);
|
||||
if (res) return res;
|
||||
}
|
||||
|
||||
// Fall through to the full list, skipping the preferred (already tried).
|
||||
for (const server of servers) {
|
||||
if (server === preferredServer) continue;
|
||||
const res = await tryServer(server);
|
||||
if (res) return res;
|
||||
}
|
||||
|
||||
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
|
||||
}
|
||||
|
||||
/** Max concurrent Blossom fetches during pre-fetch. */
|
||||
const PREFETCH_CONCURRENCY = 12;
|
||||
|
||||
/**
|
||||
* Guess a MIME type from a file path extension.
|
||||
* Falls back to 'application/octet-stream' for unknown extensions.
|
||||
* Pre-fetch all unique blobs from the manifest into an in-memory cache.
|
||||
*
|
||||
* **Android only.** Android's WebView uses `shouldInterceptRequest` which
|
||||
* blocks a pool of ~6 IO threads via `CountDownLatch` until JS responds.
|
||||
* If each response requires a network round-trip to Blossom, the 6-at-a-time
|
||||
* serialisation makes loading 200+ files extremely slow. By downloading
|
||||
* every blob *before* the WebView starts loading, each bridge round-trip
|
||||
* drops from seconds (network) to ~1-5ms (memory).
|
||||
*
|
||||
* iOS does NOT need this — `WKURLSchemeHandler` is fully async and can
|
||||
* handle many concurrent requests without any thread pool bottleneck.
|
||||
*
|
||||
* Uses bounded concurrency to saturate the network without overwhelming it.
|
||||
*/
|
||||
function guessMimeType(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase() ?? '';
|
||||
const map: Record<string, string> = {
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
css: 'text/css',
|
||||
js: 'application/javascript',
|
||||
mjs: 'application/javascript',
|
||||
json: 'application/json',
|
||||
svg: 'image/svg+xml',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
ico: 'image/x-icon',
|
||||
woff: 'font/woff',
|
||||
woff2: 'font/woff2',
|
||||
ttf: 'font/ttf',
|
||||
otf: 'font/otf',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mp3: 'audio/mpeg',
|
||||
ogg: 'audio/ogg',
|
||||
wav: 'audio/wav',
|
||||
wasm: 'application/wasm',
|
||||
xml: 'application/xml',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
};
|
||||
return map[ext] ?? 'application/octet-stream';
|
||||
async function prefetchAllBlobs(
|
||||
manifest: Map<string, string>,
|
||||
servers: string[],
|
||||
cache: Map<string, Uint8Array>,
|
||||
): Promise<void> {
|
||||
// Deduplicate — many paths may share the same hash (e.g. SPA fallbacks).
|
||||
const uniqueHashes = [...new Set(manifest.values())];
|
||||
// Skip hashes already in the cache (e.g. from a previous open).
|
||||
const toFetch = uniqueHashes.filter((h) => !cache.has(h));
|
||||
if (toFetch.length === 0) return;
|
||||
|
||||
let cursor = 0;
|
||||
const total = toFetch.length;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (cursor < total) {
|
||||
const idx = cursor++;
|
||||
const sha256 = toFetch[idx];
|
||||
try {
|
||||
const res = await fetchFromBlossom(sha256, servers);
|
||||
const buffer = await res.arrayBuffer();
|
||||
cache.set(sha256, new Uint8Array(buffer));
|
||||
} catch {
|
||||
// Non-fatal — resolveFile will fetch on demand for cache misses.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(PREFETCH_CONCURRENCY, total) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
interface NsitePreviewDialogProps {
|
||||
@@ -166,40 +191,45 @@ interface NsitePreviewDialogProps {
|
||||
|
||||
/**
|
||||
* An in-app preview panel that covers the center column and loads an nsite in
|
||||
* an iframe.diy sandbox.
|
||||
* a sandboxed iframe.
|
||||
*
|
||||
* Instead of proxying requests through an nsite gateway, this component serves
|
||||
* files directly from Blossom servers using the manifest data embedded in the
|
||||
* nsite event's `path` tags. Each path tag maps a file path to its sha256 hash,
|
||||
* which is used to construct a Blossom content-addressed URL.
|
||||
* Files are served directly from Blossom servers using the manifest data
|
||||
* embedded in the nsite event's `path` tags. Each path tag maps a file path
|
||||
* to its sha256 hash, which is used to construct a Blossom content-addressed URL.
|
||||
*
|
||||
* The panel is portaled into the center column DOM element (via CenterColumnContext)
|
||||
* and uses `position: fixed` to fill the viewport column area.
|
||||
*
|
||||
* iframe.diy provides a service-worker based sandbox. The handshake is:
|
||||
* 1. iframe.diy sends a `ready` JSON-RPC notification when its SW is installed
|
||||
* 2. Parent responds with `init` notification
|
||||
* 3. iframe.diy then forwards `fetch` JSON-RPC requests for all navigations
|
||||
* 4. Parent serves files from Blossom and injects a preview script into HTML
|
||||
*/
|
||||
export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenChange }: NsitePreviewDialogProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const centerColumn = useCenterColumn();
|
||||
const columnRect = useElementRect(open ? centerColumn : null);
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
// Use the NIP-5A canonical subdomain as the stable identifier, then derive
|
||||
// a private HMAC-SHA256 subdomain so the raw identifier is never exposed as
|
||||
// an iframe.diy origin (preventing cross-app localStorage/IndexedDB collisions).
|
||||
// a sandbox origin (preventing cross-app localStorage/IndexedDB collisions).
|
||||
const nsiteSubdomain = getNsiteSubdomain(event);
|
||||
const siteUrl = `https://${nsiteSubdomain}.nsite.lol`;
|
||||
const previewSubdomain = useMemo(() => deriveIframeSubdomain('nsite', nsiteSubdomain), [nsiteSubdomain]);
|
||||
const iframeOrigin = useMemo(() => `https://${previewSubdomain}.${PREVIEW_DOMAIN}`, [previewSubdomain]);
|
||||
const iframeSrc = `${iframeOrigin}/`;
|
||||
|
||||
// NIP-07 signer proxy — only active when a user is logged in.
|
||||
const signerRpc = useNsiteSignerRpc({
|
||||
siteId: nsiteSubdomain,
|
||||
siteName: appName,
|
||||
});
|
||||
|
||||
// Build the manifest and server list from the event (memoised per event identity)
|
||||
const manifest = useRef<Map<string, string>>(new Map());
|
||||
const servers = useRef<string[]>([]);
|
||||
|
||||
/**
|
||||
* In-memory blob cache: sha256 → raw bytes.
|
||||
* On Android, populated by a blocking pre-fetch in `onReady` so every
|
||||
* `resolveFile` call is an instant cache hit with no network wait.
|
||||
*/
|
||||
const blobCache = useRef<Map<string, Uint8Array>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
manifest.current = buildManifest(event);
|
||||
const appServers = getEffectiveBlossomServers(
|
||||
@@ -209,185 +239,82 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
servers.current = resolveServers(event, appServers.length > 0 ? appServers : APP_BLOSSOM_SERVERS.servers);
|
||||
}, [event, config.blossomServerMetadata, config.useAppBlossomServers]);
|
||||
|
||||
/** Send a JSON-RPC response back to the iframe. */
|
||||
const sendResponse = useCallback((message: JSONRPCResponse) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(message, iframeOrigin);
|
||||
}, [iframeOrigin]);
|
||||
/** Injected scripts: SPA path normalisation + NIP-07 provider (when logged in). */
|
||||
const injectedScripts = useMemo<InjectedScript[]>(() => {
|
||||
const scripts: InjectedScript[] = [{
|
||||
path: '__injected__/preview.js',
|
||||
content: getPreviewInjectedScript(),
|
||||
}];
|
||||
|
||||
/** Virtual path where the injected preview script is served. */
|
||||
const INJECTED_SCRIPT_PATH = '/__injected__/preview.js';
|
||||
|
||||
/** Inject a <script> tag into an HTML string so the preview script runs first. */
|
||||
const injectScript = useCallback((html: string): string => {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const tag = doc.createElement('script');
|
||||
tag.src = INJECTED_SCRIPT_PATH;
|
||||
doc.head.insertBefore(tag, doc.head.firstChild);
|
||||
return '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;
|
||||
}, []);
|
||||
|
||||
/** Encode a string as base64. */
|
||||
const encodeBase64 = (str: string): string => btoa(unescape(encodeURIComponent(str)));
|
||||
|
||||
/** Encode raw bytes as base64. */
|
||||
const encodeBytesBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
/** Handle a fetch request from the iframe by serving files directly from Blossom. */
|
||||
const handleFetch = useCallback(async (request: JSONRPCFetchRequest) => {
|
||||
const { params, id } = request;
|
||||
const { request: fetchRequest } = params;
|
||||
|
||||
try {
|
||||
const requestedUrl = new URL(fetchRequest.url);
|
||||
|
||||
// Only serve requests for our iframe origin
|
||||
if (requestedUrl.origin !== iframeOrigin) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32003, message: 'Origin mismatch' },
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedPath = requestedUrl.pathname;
|
||||
|
||||
// Serve the injected preview script at its virtual path
|
||||
if (requestedPath === INJECTED_SCRIPT_PATH) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
body: encodeBase64(getPreviewInjectedScript()),
|
||||
},
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the sha256 for this path in the manifest.
|
||||
// If not found, fall back to /index.html (SPA client-side routing).
|
||||
let sha256 = manifest.current.get(requestedPath);
|
||||
let servingPath = requestedPath;
|
||||
|
||||
if (!sha256) {
|
||||
sha256 = manifest.current.get('/index.html');
|
||||
servingPath = '/index.html';
|
||||
}
|
||||
|
||||
if (!sha256) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: btoa('Not Found'),
|
||||
},
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the blob from Blossom, trying each server in order
|
||||
const res = await fetchFromBlossom(sha256, servers.current);
|
||||
|
||||
// Read as ArrayBuffer → base64 so binary assets work correctly
|
||||
const buffer = await res.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
// Always determine content type from the file extension.
|
||||
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
|
||||
// files), which causes browsers to reject module scripts. The file path from
|
||||
// the manifest is authoritative for the correct MIME type.
|
||||
const contentType = guessMimeType(servingPath);
|
||||
|
||||
// Inject preview script into HTML responses for console/navigation support
|
||||
let bodyBase64: string;
|
||||
if (contentType === 'text/html') {
|
||||
const html = new TextDecoder().decode(bytes);
|
||||
bodyBase64 = encodeBase64(injectScript(html));
|
||||
} else {
|
||||
bodyBase64 = encodeBytesBase64(bytes);
|
||||
}
|
||||
|
||||
const responseHeaders: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: responseHeaders,
|
||||
body: bodyBase64,
|
||||
},
|
||||
id,
|
||||
});
|
||||
} catch (err) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32002, message: String(err) },
|
||||
id,
|
||||
// When a user is logged in, inject a NIP-07 provider so the nsite can
|
||||
// use window.nostr to interact with the user's signer.
|
||||
if (user) {
|
||||
scripts.push({
|
||||
path: '__injected__/nostr-provider.js',
|
||||
content: getNsiteNostrProviderScript(user.pubkey),
|
||||
});
|
||||
}
|
||||
}, [iframeOrigin, sendResponse, injectScript]);
|
||||
|
||||
/** Send a JSON-RPC notification to the iframe. */
|
||||
const sendNotification = useCallback((method: string, params?: Record<string, unknown>) => {
|
||||
iframeRef.current?.contentWindow?.postMessage({
|
||||
jsonrpc: '2.0' as const,
|
||||
method,
|
||||
params: params ?? {},
|
||||
}, iframeOrigin);
|
||||
}, [iframeOrigin]);
|
||||
return scripts;
|
||||
}, [user]);
|
||||
|
||||
/** Handle navigation state updates from the iframe (no-op). */
|
||||
const handleNavigationState = useCallback((_params: {
|
||||
currentUrl: string;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
}) => {
|
||||
// intentionally empty
|
||||
/**
|
||||
* Called by SandboxFrame before the native WebView is created.
|
||||
*
|
||||
* On Android: blocks until all blobs are pre-fetched. Android's WebView
|
||||
* uses `shouldInterceptRequest` which blocks ~6 IO threads — if each
|
||||
* response requires a network fetch the whole thing is painfully slow.
|
||||
* The native ProgressBar spinner (render thread) stays visible and
|
||||
* animating during the download. Once the WebView starts, every
|
||||
* resolveFile call is an instant cache hit.
|
||||
*
|
||||
* On iOS: no-op. WKURLSchemeHandler is async and handles concurrent
|
||||
* requests without a thread pool bottleneck.
|
||||
*
|
||||
* On web: no-op. iframe.diy's service worker handles fetches efficiently.
|
||||
*/
|
||||
const onReady = useCallback(async () => {
|
||||
if (Capacitor.getPlatform() !== 'android') return;
|
||||
await prefetchAllBlobs(manifest.current, servers.current, blobCache.current);
|
||||
}, []);
|
||||
|
||||
// Listen for messages from the iframe
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== iframeOrigin) return;
|
||||
const message = event.data;
|
||||
if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') return;
|
||||
/** Resolve a pathname to file content from the Blossom manifest. */
|
||||
const resolveFile = useCallback(async (pathname: string): Promise<FileResponse | null> => {
|
||||
// Look up the sha256 for this path in the manifest.
|
||||
// If not found, fall back to /index.html (SPA client-side routing).
|
||||
let sha256 = manifest.current.get(pathname);
|
||||
let servingPath = pathname;
|
||||
|
||||
// Handle iframe.diy handshake: respond to "ready" with "init"
|
||||
if (message.method === 'ready') {
|
||||
sendNotification('init', { version: 1 });
|
||||
return;
|
||||
}
|
||||
if (!sha256) {
|
||||
sha256 = manifest.current.get('/index.html');
|
||||
servingPath = '/index.html';
|
||||
}
|
||||
|
||||
if (message.method === 'fetch') {
|
||||
handleFetch(message as JSONRPCFetchRequest);
|
||||
} else if (message.method === 'updateNavigationState') {
|
||||
handleNavigationState(message.params);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [iframeOrigin, handleFetch, handleNavigationState, sendNotification]);
|
||||
if (!sha256) return null;
|
||||
|
||||
// Serve from cache if available (pre-fetched on Android).
|
||||
const cached = blobCache.current.get(sha256);
|
||||
if (cached) {
|
||||
const contentType = getMimeType(servingPath);
|
||||
return { status: 200, contentType, body: cached };
|
||||
}
|
||||
|
||||
// Cache miss — fetch from Blossom (normal path on iOS/web).
|
||||
const res = await fetchFromBlossom(sha256, servers.current);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const body = new Uint8Array(buffer);
|
||||
|
||||
// Store in cache for future requests (e.g. SPA navigations).
|
||||
blobCache.current.set(sha256, body);
|
||||
|
||||
// Always determine content type from the file extension.
|
||||
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
|
||||
// files), which causes browsers to reject module scripts. The file path from
|
||||
// the manifest is authoritative for the correct MIME type.
|
||||
const contentType = getMimeType(servingPath);
|
||||
|
||||
return { status: 200, contentType, body };
|
||||
}, []);
|
||||
|
||||
if (!open || !centerColumn || !columnRect) return null;
|
||||
|
||||
@@ -408,44 +335,67 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
}}
|
||||
>
|
||||
{/* Nav bar */}
|
||||
<div className="h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0">
|
||||
{/* App icon + name */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{appPicture ? (
|
||||
<img
|
||||
src={appPicture}
|
||||
alt={appName}
|
||||
className="size-6 rounded-md object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Package className="size-3.5 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{appName}</span>
|
||||
</div>
|
||||
<div className="min-h-11 border-b bg-muted/30 shrink-0 safe-area-top">
|
||||
<div className="px-3 py-2 flex items-center gap-2 w-full">
|
||||
{/* App icon + name */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{appPicture ? (
|
||||
<img
|
||||
src={appPicture}
|
||||
alt={appName}
|
||||
className="size-6 rounded-md object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<ExternalFavicon
|
||||
url={siteUrl}
|
||||
size={18}
|
||||
fallback={<Package className="size-3.5 text-primary/50" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{appName}</span>
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => onOpenChange(false)}
|
||||
title="Close"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
{/* Permissions + Close */}
|
||||
{user && (
|
||||
<NsitePermissionManager siteId={nsiteSubdomain} siteName={appName} />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => onOpenChange(false)}
|
||||
title="Close"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* iframe */}
|
||||
<div className="flex-1 min-h-0 bg-background">
|
||||
<iframe
|
||||
{/* Sandboxed iframe */}
|
||||
<div className="flex-1 min-h-0 bg-background relative">
|
||||
<SandboxFrame
|
||||
key={`${previewSubdomain}-${open}`}
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
id={previewSubdomain}
|
||||
resolveFile={resolveFile}
|
||||
onReady={onReady}
|
||||
onRpc={user ? signerRpc.onRpc : undefined}
|
||||
injectedScripts={injectedScripts}
|
||||
className="w-full h-full border-0"
|
||||
title={`${appName} preview`}
|
||||
/>
|
||||
|
||||
{/* Permission prompt overlay */}
|
||||
{signerRpc.pendingPrompt && (
|
||||
<NsitePermissionPrompt
|
||||
appPicture={appPicture}
|
||||
appName={appName}
|
||||
siteUrl={siteUrl}
|
||||
prompt={signerRpc.pendingPrompt}
|
||||
onResolve={signerRpc.resolvePrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { GripVertical, Rocket, X } from 'lucide-react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { nsiteUriToSubdomain } from '@/lib/sidebarItems';
|
||||
import { parseNsiteSubdomain } from '@/lib/nsiteSubdomain';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { useNsitePlayer } from '@/contexts/NsitePlayerContext';
|
||||
import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
import { useNostrEventSidebar } from '@/hooks/useNostrEventSidebar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface NsiteSidebarItemProps {
|
||||
/** The full nsite:// URI, e.g. "nsite://3cbg51pm00nms2dp8rm..." */
|
||||
id: string;
|
||||
/** Ignored -- active state is derived from NsitePlayerContext instead. Kept for caller consistency with other sidebar item types. */
|
||||
active?: boolean;
|
||||
editing: boolean;
|
||||
onRemove: (id: string, index?: number) => void;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
/** Extra classes on the link. */
|
||||
linkClassName?: string;
|
||||
}
|
||||
|
||||
// ── Label sub-component ───────────────────────────────────────────────────────
|
||||
|
||||
function NsiteSidebarLabel({ subdomain, parsed }: { subdomain: string; parsed: ReturnType<typeof parseNsiteSubdomain> }) {
|
||||
const siteUrl = `https://${subdomain}.nsite.lol`;
|
||||
const { data: preview } = useLinkPreview(siteUrl);
|
||||
|
||||
const addr = parsed && parsed.kind === 35128
|
||||
? { kind: parsed.kind, pubkey: parsed.pubkey, identifier: parsed.identifier }
|
||||
: undefined;
|
||||
|
||||
const { data: eventData, isLoading } = useNostrEventSidebar({ addr });
|
||||
|
||||
if (isLoading && !eventData && !preview) {
|
||||
return <Skeleton className="h-4 w-20" />;
|
||||
}
|
||||
|
||||
// Prefer the link preview title (the live site <title>), then the event tag label
|
||||
const label = preview?.title || eventData?.label || 'Nsite';
|
||||
|
||||
return (
|
||||
<span className="truncate">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function NsiteSidebarItem({
|
||||
id, editing, onRemove, onClick, linkClassName,
|
||||
}: NsiteSidebarItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
const navigate = useNavigate();
|
||||
|
||||
const subdomain = nsiteUriToSubdomain(id);
|
||||
const parsed = useMemo(() => parseNsiteSubdomain(subdomain), [subdomain]);
|
||||
|
||||
// Highlight when the nsite player is open for this subdomain.
|
||||
const { activeSubdomain } = useNsitePlayer();
|
||||
const active = activeSubdomain === subdomain;
|
||||
|
||||
// Build the naddr path for navigation. For named sites (35128), encode as naddr.
|
||||
// For root sites (15128), we'd need a nevent which requires the event ID — fall back to null.
|
||||
const naddrPath = useMemo(() => {
|
||||
if (!parsed) return null;
|
||||
if (parsed.kind === 35128) {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: parsed.kind,
|
||||
pubkey: parsed.pubkey,
|
||||
identifier: parsed.identifier,
|
||||
});
|
||||
return `/${naddr}`;
|
||||
}
|
||||
// Root site (15128) — we can't construct an naddr without a d-tag,
|
||||
// and nevent requires event ID. For now, root site nsite:// URIs are not supported.
|
||||
return null;
|
||||
}, [parsed]);
|
||||
|
||||
// Navigate with a fresh timestamp on every click so the detail page
|
||||
// can detect repeated clicks and re-open the player.
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
onClick?.(e);
|
||||
if (e.defaultPrevented || !naddrPath) return;
|
||||
e.preventDefault();
|
||||
navigate(naddrPath, { state: { nsiteAutoPlay: true, nsiteAutoPlayTs: Date.now() } });
|
||||
}, [naddrPath, navigate, onClick]);
|
||||
|
||||
if (!parsed || !naddrPath) {
|
||||
// Invalid or unsupported nsite URI — render nothing
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
|
||||
>
|
||||
{editing && (
|
||||
<button
|
||||
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={naddrPath}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
|
||||
editing ? 'px-2' : 'px-3',
|
||||
active ? 'font-bold text-primary' : 'font-normal text-foreground',
|
||||
linkClassName ?? 'text-lg',
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">
|
||||
<ExternalFavicon
|
||||
url={`https://${subdomain}.nsite.lol`}
|
||||
size={20}
|
||||
fallback={<Rocket className="size-5" />}
|
||||
className="size-6 flex items-center justify-center"
|
||||
/>
|
||||
</span>
|
||||
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
|
||||
<NsiteSidebarLabel subdomain={subdomain} parsed={parsed} />
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{editing && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
|
||||
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -206,9 +206,11 @@ export function ProfileCard({
|
||||
<Pencil className="size-3.5" /> {metadata.banner ? 'Change banner' : 'Add banner'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
{metadata.banner && (
|
||||
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -240,9 +242,11 @@ export function ProfileCard({
|
||||
>
|
||||
<Pencil className="size-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow" />
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
{metadata.picture && (
|
||||
<div className="absolute bottom-0 right-0 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={6}>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
import { parseDimToAspectRatio } from '@/lib/mediaUtils';
|
||||
import { isWeatherFieldLabel } from '@/lib/weatherStation';
|
||||
import { WeatherStationCard } from '@/components/WeatherStationCard';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Media-native kinds shown in the sidebar (excludes kind 1 text notes and kind 1111 comments). */
|
||||
const SIDEBAR_MEDIA_KINDS = [20, 21, 22, 34236, 36787, 34139, 30054, 30055];
|
||||
@@ -400,24 +401,24 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
|
||||
}
|
||||
|
||||
// Media fields: render inline players/previews based on file extension
|
||||
const isUrl = field.value.startsWith('http://') || field.value.startsWith('https://');
|
||||
const safeUrl = sanitizeUrl(field.value);
|
||||
|
||||
if (isUrl && isAudioUrl(field.value)) {
|
||||
if (safeUrl && isAudioUrl(safeUrl)) {
|
||||
return (
|
||||
<div>
|
||||
<div className="font-semibold text-sm mb-1.5">{field.label}</div>
|
||||
<MiniAudioPlayer src={field.value} />
|
||||
<MiniAudioPlayer src={safeUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUrl && isImageUrl(field.value)) {
|
||||
if (safeUrl && isImageUrl(safeUrl)) {
|
||||
return (
|
||||
<div>
|
||||
{field.label && <div className="font-semibold text-sm mb-1.5">{field.label}</div>}
|
||||
<a href={field.value} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<a href={safeUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={field.value}
|
||||
src={safeUrl}
|
||||
alt={field.label || 'Profile image'}
|
||||
className="w-full rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
@@ -427,12 +428,12 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isUrl && isVideoUrl(field.value)) {
|
||||
if (safeUrl && isVideoUrl(safeUrl)) {
|
||||
return (
|
||||
<div>
|
||||
{field.label && <div className="font-semibold text-sm mb-1.5">{field.label}</div>}
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<VideoPlayer src={field.value} />
|
||||
<VideoPlayer src={safeUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -442,15 +443,15 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="font-semibold text-sm">{field.label}</div>
|
||||
{isUrl ? (
|
||||
{safeUrl ? (
|
||||
<a
|
||||
href={field.value}
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm text-primary hover:underline truncate mt-0.5"
|
||||
>
|
||||
<ExternalFavicon url={field.value} size={16} className="shrink-0" />
|
||||
<span className="truncate">{field.value.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalFavicon url={safeUrl} size={16} className="shrink-0" />
|
||||
<span className="truncate">{safeUrl.replace(/^https?:\/\//, '')}</span>
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground truncate">{field.value}</p>
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Globe, Radio, Loader2, X, ArrowRight, ArrowLeft, Flame } from 'lucide-react';
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useRequestToVanish } from '@/hooks/useRequestToVanish';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
@@ -22,30 +17,38 @@ interface RequestToVanishDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type VanishMode = 'global' | 'targeted';
|
||||
type Step = 0 | 1 | 2;
|
||||
const DELETION_ITEMS = [
|
||||
{ id: 'profile', label: 'Your profile and metadata' },
|
||||
{ id: 'posts', label: 'All posts, replies, and reactions' },
|
||||
{ id: 'messages', label: 'Direct messages' },
|
||||
{ id: 'settings', label: 'Follow lists and settings' },
|
||||
{ id: 'other', label: 'All other events submitted to the network' },
|
||||
] as const;
|
||||
|
||||
const STEPS = ['Scope', 'Details', 'Confirm'] as const;
|
||||
const CONFIRMATION_PHRASE = 'VANISH';
|
||||
type ItemId = (typeof DELETION_ITEMS)[number]['id'];
|
||||
|
||||
export function RequestToVanishDialog({ open, onOpenChange }: RequestToVanishDialogProps) {
|
||||
const { config } = useAppContext();
|
||||
const { mutateAsync: requestVanish, isPending } = useRequestToVanish();
|
||||
const { logout } = useLoginActions();
|
||||
|
||||
const [step, setStep] = useState<Step>(0);
|
||||
const [mode, setMode] = useState<VanishMode>('global');
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [checked, setChecked] = useState<Set<ItemId>>(new Set());
|
||||
|
||||
const userRelays = config.relayMetadata.relays.map((r) => r.url);
|
||||
const isConfirmed = confirmText === CONFIRMATION_PHRASE;
|
||||
const allChecked = DELETION_ITEMS.every((item) => checked.has(item.id));
|
||||
|
||||
const toggle = (id: ItemId) => {
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setStep(0);
|
||||
setMode('global');
|
||||
setReason('');
|
||||
setConfirmText('');
|
||||
setChecked(new Set());
|
||||
}, []);
|
||||
|
||||
// Reset when dialog closes.
|
||||
@@ -54,411 +57,90 @@ export function RequestToVanishDialog({ open, onOpenChange }: RequestToVanishDia
|
||||
}, [open, resetState]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isConfirmed) return;
|
||||
if (!allChecked) return;
|
||||
|
||||
try {
|
||||
const relayUrls = mode === 'global' ? ['ALL_RELAYS'] : userRelays;
|
||||
|
||||
await requestVanish({ relayUrls, content: reason.trim() });
|
||||
await requestVanish({ relayUrls: ['ALL_RELAYS'], content: '' });
|
||||
|
||||
toast({
|
||||
title: 'Request to vanish sent',
|
||||
description: mode === 'global'
|
||||
? 'Your request has been broadcast. Compliant relays will delete your data.'
|
||||
: `Your request was sent to ${userRelays.length} relay(s).`,
|
||||
title: 'Account deleted',
|
||||
description: 'Your deletion request has been broadcast. You have been logged out.',
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
await logout();
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Failed to send request',
|
||||
description: 'Some relays may not have received the request. You can try again.',
|
||||
title: 'Failed to delete account',
|
||||
description: 'Something went wrong. You can try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[440px] rounded-2xl p-0 gap-0 border-border overflow-hidden max-h-[90dvh] [&>button]:hidden">
|
||||
{/* ── Header ── */}
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Gradient backdrop */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-destructive/10 via-destructive/5 to-transparent" />
|
||||
|
||||
<div className="relative px-5 pt-5 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-destructive/15 ring-1 ring-destructive/20 shrink-0">
|
||||
<Flame className="size-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-base font-bold">Request to Vanish</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
||||
Permanently erase your data from relays
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 -mr-1 -mt-0.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center gap-1.5 mt-4">
|
||||
{STEPS.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-1.5 flex-1">
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full h-1 rounded-full overflow-hidden bg-muted/60">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500 ease-out',
|
||||
i <= step ? 'bg-destructive w-full' : 'w-0',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[10px] font-medium transition-colors',
|
||||
i <= step ? 'text-destructive' : 'text-muted-foreground/50',
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="max-w-[400px] rounded-2xl p-6 gap-0 border-destructive/40">
|
||||
{/* Title */}
|
||||
<div className="mb-4">
|
||||
<AlertDialogTitle className="text-base font-bold flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0" />
|
||||
Delete Account
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-sm text-muted-foreground mt-1">
|
||||
This will <span className="font-semibold text-destructive">permanently delete your data</span>. Check each box to confirm you understand what will be removed:
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Step Content ── */}
|
||||
<div className="overflow-y-auto min-h-0 flex-1">
|
||||
{step === 0 && <StepScope mode={mode} setMode={setMode} userRelays={userRelays} />}
|
||||
{step === 1 && <StepDetails reason={reason} setReason={setReason} mode={mode} userRelays={userRelays} />}
|
||||
{step === 2 && (
|
||||
<StepConfirm
|
||||
confirmText={confirmText}
|
||||
setConfirmText={setConfirmText}
|
||||
mode={mode}
|
||||
relayCount={userRelays.length}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5">
|
||||
{step > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setStep((s) => (s - 1) as Step)}
|
||||
disabled={isPending}
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
{/* Checkbox list */}
|
||||
<div className="space-y-3 mb-5">
|
||||
{DELETION_ITEMS.map((item) => (
|
||||
<label
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 cursor-pointer select-none"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step < 2 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setStep((s) => (s + 1) as Step)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!isConfirmed || isPending}
|
||||
className="gap-1.5 bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-40"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Flame className="size-3.5" />
|
||||
Vanish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Step 0: Scope ───────────────────────── */
|
||||
|
||||
function StepScope({
|
||||
mode,
|
||||
setMode,
|
||||
userRelays,
|
||||
}: {
|
||||
mode: VanishMode;
|
||||
setMode: (m: VanishMode) => void;
|
||||
userRelays: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Choose scope</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Select which relays should delete your data. This determines the reach of your vanish request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ScopeCard
|
||||
selected={mode === 'global'}
|
||||
onClick={() => setMode('global')}
|
||||
icon={<Globe className="size-5" />}
|
||||
title="All relays"
|
||||
description="Request every relay on the network to delete your data. The event is broadcast as widely as possible."
|
||||
badge="Recommended"
|
||||
/>
|
||||
<ScopeCard
|
||||
selected={mode === 'targeted'}
|
||||
onClick={() => setMode('targeted')}
|
||||
icon={<Radio className="size-5" />}
|
||||
title={`My relays only (${userRelays.length})`}
|
||||
description="Request only your currently configured relays to delete your data."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Relay list preview for targeted mode */}
|
||||
{mode === 'targeted' && userRelays.length > 0 && (
|
||||
<div className="rounded-lg bg-muted/40 border border-border/50 px-3 py-2.5 space-y-1.5 animate-in fade-in-0 slide-in-from-top-1 duration-200">
|
||||
<p className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Target relays</p>
|
||||
<ul className="space-y-0.5">
|
||||
{userRelays.map((url) => (
|
||||
<li key={url} className="text-xs font-mono text-muted-foreground truncate">{url}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeCard({
|
||||
selected,
|
||||
onClick,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full text-left rounded-xl border-2 p-3.5 transition-all duration-200',
|
||||
'hover:bg-secondary/30',
|
||||
selected
|
||||
? 'border-destructive/60 bg-destructive/[0.03] shadow-sm shadow-destructive/5'
|
||||
: 'border-border/60 bg-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'flex size-9 items-center justify-center rounded-lg shrink-0 transition-colors',
|
||||
selected ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground',
|
||||
)}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
{badge && (
|
||||
<span className="text-[10px] font-medium bg-destructive/10 text-destructive rounded-full px-2 py-0.5">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
{/* Selection indicator */}
|
||||
<div className={cn(
|
||||
'size-4 rounded-full border-2 shrink-0 mt-0.5 transition-all duration-200 flex items-center justify-center',
|
||||
selected ? 'border-destructive bg-destructive' : 'border-muted-foreground/30',
|
||||
)}>
|
||||
{selected && <div className="size-1.5 rounded-full bg-white" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Step 1: Details ───────────────────────── */
|
||||
|
||||
function StepDetails({
|
||||
reason,
|
||||
setReason,
|
||||
mode,
|
||||
userRelays,
|
||||
}: {
|
||||
reason: string;
|
||||
setReason: (r: string) => void;
|
||||
mode: VanishMode;
|
||||
userRelays: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-5">
|
||||
{/* Summary of what will happen */}
|
||||
<div className="rounded-xl bg-destructive/[0.04] border border-destructive/15 p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-destructive flex items-center gap-2">
|
||||
<Flame className="size-4" />
|
||||
What will be deleted
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
'Your profile (kind 0) and metadata',
|
||||
'All posts, replies, and reactions',
|
||||
'Direct messages and gift wraps',
|
||||
'Contact lists, relay lists, and settings',
|
||||
'All other events published by your key',
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="text-destructive/60 mt-0.5 shrink-0">—</span>
|
||||
{item}
|
||||
</li>
|
||||
<Checkbox
|
||||
checked={checked.has(item.id)}
|
||||
onCheckedChange={() => toggle(item.id)}
|
||||
className="border-destructive/60 data-[state=checked]:bg-destructive data-[state=checked]:border-destructive"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-[11px] text-destructive/70 pt-1 border-t border-destructive/10">
|
||||
{mode === 'global'
|
||||
? 'This request will be sent to all relays on the network.'
|
||||
: `This request will be sent to ${userRelays.length} relay(s).`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vanish-reason" className="text-sm font-medium">
|
||||
Reason or legal notice
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Optionally include a message for the relay operator. This is included in the event's content field.
|
||||
</p>
|
||||
<Textarea
|
||||
id="vanish-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. GDPR Article 17 — Right to erasure"
|
||||
className="resize-none text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Step 2: Confirm ───────────────────────── */
|
||||
|
||||
function StepConfirm({
|
||||
confirmText,
|
||||
setConfirmText,
|
||||
mode,
|
||||
relayCount,
|
||||
}: {
|
||||
confirmText: string;
|
||||
setConfirmText: (t: string) => void;
|
||||
mode: VanishMode;
|
||||
relayCount: number;
|
||||
}) {
|
||||
const isMatch = confirmText === CONFIRMATION_PHRASE;
|
||||
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-5">
|
||||
{/* Final warning */}
|
||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-4 text-center space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<div className="size-12 rounded-full bg-destructive/15 flex items-center justify-center">
|
||||
<Flame className="size-6 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-destructive">This action is irreversible</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed max-w-[280px] mx-auto">
|
||||
Once sent, compliant relays will permanently delete your events.
|
||||
Deletion requests (kind 5) against this event have no effect.
|
||||
You will be logged out immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scope summary */}
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/40 px-3.5 py-2.5">
|
||||
{mode === 'global' ? (
|
||||
<Globe className="size-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Radio className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{mode === 'global'
|
||||
? 'Targeting all relays on the network'
|
||||
: `Targeting ${relayCount} configured relay(s)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Confirmation input */}
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="vanish-confirm" className="text-sm font-medium">
|
||||
Type{' '}
|
||||
<span className="font-mono bg-destructive/10 text-destructive px-1.5 py-0.5 rounded text-xs">
|
||||
{CONFIRMATION_PHRASE}
|
||||
</span>{' '}
|
||||
to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="vanish-confirm"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value.toUpperCase())}
|
||||
placeholder={CONFIRMATION_PHRASE}
|
||||
className={cn(
|
||||
'font-mono text-center text-lg tracking-widest transition-colors',
|
||||
isMatch && 'border-destructive/50 ring-1 ring-destructive/20',
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className={cn(
|
||||
'text-center text-xs transition-opacity duration-300',
|
||||
isMatch ? 'text-destructive opacity-100' : 'text-muted-foreground/40 opacity-0',
|
||||
)}>
|
||||
Confirmation accepted
|
||||
{/* Warning */}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-5">
|
||||
This action is <span className="font-semibold text-destructive">irreversible</span>.
|
||||
Your account cannot be recovered after deletion. You will be logged out immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-1.5 bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-40"
|
||||
onClick={handleSubmit}
|
||||
disabled={!allChecked || isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Delete Account'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
type IframeHTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import {
|
||||
bytesToBase64,
|
||||
utf8ToBase64,
|
||||
injectScriptTags,
|
||||
} from '@/lib/sandbox';
|
||||
import type {
|
||||
FileResponse,
|
||||
InjectedScript,
|
||||
JsonRpcResponse,
|
||||
SerialisedRequest,
|
||||
} from '@/lib/sandbox';
|
||||
import {
|
||||
SandboxPlugin,
|
||||
type SandboxFetchEvent,
|
||||
} from '@/lib/sandboxPlugin';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SandboxFrameProps
|
||||
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, 'src' | 'id'> {
|
||||
/** HMAC-derived subdomain identifier. */
|
||||
id: string;
|
||||
/**
|
||||
* Resolve a pathname to file content.
|
||||
* Return a `FileResponse` to serve the file, or `null` for a 404.
|
||||
*/
|
||||
resolveFile: (pathname: string) => Promise<FileResponse | null>;
|
||||
/**
|
||||
* Handle non-fetch, non-lifecycle JSON-RPC methods (e.g. `webxdc.*`).
|
||||
* Receives the method name, params, and a `post` function for sending
|
||||
* arbitrary messages back into the sandbox (e.g. push notifications).
|
||||
* Return the result value to send as the JSON-RPC response.
|
||||
*/
|
||||
onRpc?: (
|
||||
method: string,
|
||||
params: unknown,
|
||||
post: (msg: Record<string, unknown>) => void,
|
||||
) => Promise<unknown>;
|
||||
/**
|
||||
* Virtual scripts to inject into HTML responses.
|
||||
* Each entry is served at its `path` and a `<script src="...">` tag is
|
||||
* prepended into `<head>` of every HTML response.
|
||||
*/
|
||||
injectedScripts?: InjectedScript[];
|
||||
/** Optional Content-Security-Policy header added to every response. */
|
||||
csp?: string;
|
||||
/**
|
||||
* Called when the sandbox sends `ready`, **before** `init` is sent back.
|
||||
* If the returned promise is pending, `init` is deferred until it resolves,
|
||||
* which prevents fetch requests from arriving before the consumer is ready
|
||||
* to serve files (e.g. while an archive is still being downloaded).
|
||||
*/
|
||||
onReady?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** Imperative handle exposed via ref. */
|
||||
export interface SandboxFrameHandle {
|
||||
/** Send a postMessage to the sandbox iframe. */
|
||||
postMessage: (msg: Record<string, unknown>, transfer?: Transferable[]) => void;
|
||||
/** Focus the iframe element. */
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fetch/RPC handler logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a serialised HTTP response and call `respond` with it.
|
||||
* Shared between the web (postMessage) and native (respondToFetch) paths.
|
||||
*/
|
||||
async function handleFetchRequest(
|
||||
pathname: string,
|
||||
resolveFile: (pathname: string) => Promise<FileResponse | null>,
|
||||
scripts: InjectedScript[],
|
||||
activeCsp: string | undefined,
|
||||
respond: (result: Record<string, unknown>) => void,
|
||||
respondError: (code: number, message: string) => void,
|
||||
): Promise<void> {
|
||||
// Check if the request is for a virtual injected script.
|
||||
const virtualScript = scripts.find(
|
||||
(s) => pathname === `/${s.path}` || pathname === s.path,
|
||||
);
|
||||
if (virtualScript) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
|
||||
respond({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers,
|
||||
body: utf8ToBase64(virtualScript.content),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to the consumer's file resolver.
|
||||
try {
|
||||
const file = await resolveFile(pathname);
|
||||
|
||||
if (!file) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/plain' };
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
|
||||
respond({
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers,
|
||||
body: utf8ToBase64('Not Found'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For HTML responses, inject script tags.
|
||||
let bodyBase64: string;
|
||||
if (file.contentType === 'text/html' && scripts.length > 0) {
|
||||
const html = new TextDecoder().decode(file.body);
|
||||
const injected = injectScriptTags(
|
||||
html,
|
||||
scripts.map((s) => `/${s.path}`),
|
||||
);
|
||||
bodyBase64 = utf8ToBase64(injected);
|
||||
} else {
|
||||
bodyBase64 = bytesToBase64(file.body);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': file.contentType,
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
// Include Content-Length for non-HTML (binary) responses.
|
||||
if (file.contentType !== 'text/html') {
|
||||
headers['Content-Length'] = String(file.body.byteLength);
|
||||
}
|
||||
|
||||
respond({
|
||||
status: file.status,
|
||||
statusText: 'OK',
|
||||
headers,
|
||||
body: bodyBase64,
|
||||
});
|
||||
} catch (err) {
|
||||
respondError(-32002, String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Web (iframe.diy) implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
function SandboxFrameWeb(
|
||||
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, ...iframeProps },
|
||||
ref,
|
||||
) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const { config } = useAppContext();
|
||||
|
||||
const origin = useMemo(
|
||||
() => `https://${id}.${config.sandboxDomain}`,
|
||||
[id, config.sandboxDomain],
|
||||
);
|
||||
|
||||
// Keep latest callbacks in refs so the message handler always sees
|
||||
// current values without re-registering the listener.
|
||||
const resolveFileRef = useRef(resolveFile);
|
||||
const onRpcRef = useRef(onRpc);
|
||||
const injectedScriptsRef = useRef(injectedScripts);
|
||||
const cspRef = useRef(csp);
|
||||
const onReadyRef = useRef(onReady);
|
||||
|
||||
useEffect(() => { resolveFileRef.current = resolveFile; }, [resolveFile]);
|
||||
useEffect(() => { onRpcRef.current = onRpc; }, [onRpc]);
|
||||
useEffect(() => { injectedScriptsRef.current = injectedScripts; }, [injectedScripts]);
|
||||
useEffect(() => { cspRef.current = csp; }, [csp]);
|
||||
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Post a message to the iframe
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
const post = useCallback(
|
||||
(msg: Record<string, unknown>, transfer?: Transferable[]) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(msg, origin, transfer ?? []);
|
||||
},
|
||||
[origin],
|
||||
);
|
||||
|
||||
// Expose imperative handle.
|
||||
useImperativeHandle(ref, () => ({
|
||||
postMessage: (msg: Record<string, unknown>, transfer?: Transferable[]) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(msg, origin, transfer ?? []);
|
||||
},
|
||||
focus: () => {
|
||||
iframeRef.current?.focus();
|
||||
},
|
||||
}), [origin]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Message handler
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
if (event.origin !== origin) return;
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
|
||||
const msg = event.data;
|
||||
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
|
||||
|
||||
// Notification: ready -> await onReady, then respond with init
|
||||
if (msg.method === 'ready' && msg.id === undefined) {
|
||||
handleReady();
|
||||
return;
|
||||
}
|
||||
|
||||
// Requests (have an `id`)
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
if (msg.method === 'fetch') {
|
||||
handleFetch(msg.id, msg.params);
|
||||
} else if (onRpcRef.current) {
|
||||
handleRpc(msg.id, msg.method, msg.params ?? {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Ready handler: run consumer setup, then send init
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleReady() {
|
||||
try {
|
||||
await onReadyRef.current?.();
|
||||
} catch (err) {
|
||||
console.error('[SandboxFrame] onReady failed:', err);
|
||||
}
|
||||
post({ jsonrpc: '2.0', method: 'init', params: { version: 1 } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Fetch handler
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleFetch(
|
||||
id: string | number,
|
||||
params: { request?: SerialisedRequest },
|
||||
) {
|
||||
const reqUrl = params?.request?.url;
|
||||
if (!reqUrl) {
|
||||
post({ jsonrpc: '2.0', id, error: { code: -32001, message: 'Invalid request' } });
|
||||
return;
|
||||
}
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
const url = new URL(reqUrl);
|
||||
// Only serve requests for our sandbox origin.
|
||||
if (url.origin !== origin) {
|
||||
post({ jsonrpc: '2.0', id, error: { code: -32003, message: 'Origin mismatch' } });
|
||||
return;
|
||||
}
|
||||
pathname = url.pathname;
|
||||
} catch {
|
||||
post({ jsonrpc: '2.0', id, error: { code: -32003, message: 'Invalid URL' } });
|
||||
return;
|
||||
}
|
||||
|
||||
await handleFetchRequest(
|
||||
pathname,
|
||||
resolveFileRef.current,
|
||||
injectedScriptsRef.current ?? [],
|
||||
cspRef.current,
|
||||
(result) => post({ jsonrpc: '2.0', id, result }),
|
||||
(code, message) => post({ jsonrpc: '2.0', id, error: { code, message } }),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Custom RPC handler
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleRpc(
|
||||
id: string | number,
|
||||
method: string,
|
||||
params: unknown,
|
||||
) {
|
||||
try {
|
||||
const result = await onRpcRef.current!(method, params, post);
|
||||
post({ jsonrpc: '2.0', id, result: result ?? null } satisfies JsonRpcResponse);
|
||||
} catch (err) {
|
||||
post({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: -1, message: String(err) },
|
||||
} satisfies JsonRpcResponse);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [origin, post]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${origin}/`}
|
||||
allow="clipboard-write"
|
||||
{...iframeProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native (Capacitor) implementation — uses a real <iframe> served by native
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the iframe origin for native platforms.
|
||||
*
|
||||
* - iOS: `sbx://<sandbox-id>` — intercepted by the `SandboxRequestHandler`
|
||||
* registered on the WKWebView configuration as a `WKURLSchemeHandler` for
|
||||
* the `sbx://` custom scheme. Each sandbox ID is a unique origin, giving
|
||||
* full localStorage / IndexedDB / cookie isolation.
|
||||
* - Android: `https://<sandbox-id>.sandbox.native` — intercepted by the
|
||||
* custom BridgeWebViewClient subclass.
|
||||
*/
|
||||
function getNativeOrigin(id: string): string {
|
||||
if (Capacitor.getPlatform() === 'ios') {
|
||||
return `sbx://${id}`;
|
||||
}
|
||||
// Android
|
||||
return `https://${id}.sandbox.native`;
|
||||
}
|
||||
|
||||
const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
function SandboxFrameNative(
|
||||
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, className, style, ...iframeProps },
|
||||
ref,
|
||||
) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const readyRef = useRef(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const origin = useMemo(() => getNativeOrigin(id), [id]);
|
||||
|
||||
// Keep latest callbacks in refs.
|
||||
const resolveFileRef = useRef(resolveFile);
|
||||
const onRpcRef = useRef(onRpc);
|
||||
const injectedScriptsRef = useRef(injectedScripts);
|
||||
const cspRef = useRef(csp);
|
||||
const onReadyRef = useRef(onReady);
|
||||
|
||||
useEffect(() => { resolveFileRef.current = resolveFile; }, [resolveFile]);
|
||||
useEffect(() => { onRpcRef.current = onRpc; }, [onRpc]);
|
||||
useEffect(() => { injectedScriptsRef.current = injectedScripts; }, [injectedScripts]);
|
||||
useEffect(() => { cspRef.current = csp; }, [csp]);
|
||||
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Post a message to the iframe via postMessage
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
const post = useCallback(
|
||||
(msg: Record<string, unknown>) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(msg, origin);
|
||||
},
|
||||
[origin],
|
||||
);
|
||||
|
||||
// Expose imperative handle.
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
postMessage: (msg: Record<string, unknown>) => {
|
||||
post(msg);
|
||||
},
|
||||
focus: () => {
|
||||
iframeRef.current?.focus();
|
||||
},
|
||||
}),
|
||||
[post],
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Handle fetch events from the native scheme handler
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const listeners: Array<{ remove: () => void }> = [];
|
||||
|
||||
async function setup() {
|
||||
// Register the fetch listener BEFORE doing anything else.
|
||||
// On Android, shouldInterceptRequest fires on a background thread
|
||||
// as soon as the iframe src is set — the listener must be ready.
|
||||
const fetchListener = await SandboxPlugin.addListener(
|
||||
'fetch',
|
||||
(event: SandboxFetchEvent) => {
|
||||
if (event.id !== id) return;
|
||||
handleNativeFetch(event);
|
||||
},
|
||||
);
|
||||
listeners.push(fetchListener);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Run onReady (e.g. Android pre-fetches all blobs here).
|
||||
try {
|
||||
await onReadyRef.current?.();
|
||||
} catch (err) {
|
||||
console.error('[SandboxFrame] onReady failed:', err);
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Diagnose native state before loading.
|
||||
try {
|
||||
const diag = await SandboxPlugin.diagnose();
|
||||
console.log('[SandboxFrame] diagnose BEFORE src:', JSON.stringify(diag));
|
||||
} catch (err) {
|
||||
console.warn('[SandboxFrame] diagnose failed:', err);
|
||||
}
|
||||
|
||||
// Set the iframe src to start loading content.
|
||||
// This triggers native fetch interception via the scheme handler.
|
||||
readyRef.current = true;
|
||||
const src = `${origin}/index.html`;
|
||||
console.log(`[SandboxFrame] setting iframe.src=${src}`);
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = src;
|
||||
}
|
||||
|
||||
// Diagnose again after a delay to see if handler was called.
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
if (!cancelled) {
|
||||
try {
|
||||
const diag = await SandboxPlugin.diagnose();
|
||||
console.log('[SandboxFrame] diagnose AFTER src (2s):', JSON.stringify(diag));
|
||||
} catch (err) {
|
||||
console.warn('[SandboxFrame] diagnose after failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNativeFetch(event: SandboxFetchEvent) {
|
||||
const reqUrl = event.request.url;
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(reqUrl).pathname;
|
||||
} catch {
|
||||
const pathMatch = reqUrl.match(/\/\/[^/]+(\/.*)/);
|
||||
pathname = pathMatch?.[1] ?? '/';
|
||||
}
|
||||
|
||||
await handleFetchRequest(
|
||||
pathname,
|
||||
resolveFileRef.current,
|
||||
injectedScriptsRef.current ?? [],
|
||||
cspRef.current,
|
||||
(result) => {
|
||||
SandboxPlugin.respondToFetch({
|
||||
requestId: event.requestId,
|
||||
response: result as {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[SandboxFrame] respondToFetch failed:', err);
|
||||
});
|
||||
},
|
||||
(_code, message) => {
|
||||
SandboxPlugin.respondToFetch({
|
||||
requestId: event.requestId,
|
||||
response: {
|
||||
status: 500,
|
||||
statusText: 'Internal Error',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: btoa(message),
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[SandboxFrame] respondToFetch error failed:', err);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setup().catch((err) => {
|
||||
console.error('[SandboxFrame] native setup failed:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const listener of listeners) {
|
||||
listener.remove();
|
||||
}
|
||||
};
|
||||
}, [id, origin]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Listen for postMessage from the iframe (RPC from injected scripts)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
// On iOS the origin is "sbx://<id>",
|
||||
// on Android "https://<id>.sandbox.native".
|
||||
if (event.origin !== origin) return;
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
|
||||
const msg = event.data;
|
||||
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
|
||||
|
||||
// Handle RPC requests from injected scripts.
|
||||
if (msg.id !== undefined && msg.method && onRpcRef.current) {
|
||||
handleRpc(msg.id, msg.method, msg.params ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRpc(
|
||||
rpcId: string | number,
|
||||
method: string,
|
||||
params: unknown,
|
||||
) {
|
||||
try {
|
||||
const result = await onRpcRef.current!(method, params, post);
|
||||
post({ jsonrpc: '2.0', id: rpcId, result: result ?? null });
|
||||
} catch (err) {
|
||||
post({
|
||||
jsonrpc: '2.0',
|
||||
id: rpcId,
|
||||
error: { code: -1, message: String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [origin, post]);
|
||||
|
||||
// Hide the spinner once the iframe fires its load event (initial HTML parsed).
|
||||
const handleLoad = useCallback(() => setLoading(false), []);
|
||||
|
||||
// Don't set src initially — it's set after onReady completes in setup().
|
||||
return (
|
||||
<div className={className} style={{ ...style, position: 'relative' }}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
onLoad={handleLoad}
|
||||
allow="clipboard-write"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
{...iframeProps}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 className="size-10 animate-spin text-primary/70" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public component — delegates to web or native implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renders a sandboxed content frame.
|
||||
*
|
||||
* On web, this creates an iframe on a unique subdomain (`<id>.<sandboxDomain>`)
|
||||
* and implements the iframe.diy handshake + fetch proxy protocol.
|
||||
*
|
||||
* On native platforms (iOS/Android via Capacitor), this creates a regular
|
||||
* `<iframe>` element whose requests are intercepted by native code:
|
||||
* - iOS: `WKURLSchemeHandler` for the `sbx://` custom scheme, registered
|
||||
* on the WKWebView configuration. Each sandbox loads from
|
||||
* `sbx://<sandbox-id>/path` with full origin isolation.
|
||||
* - Android: Custom BridgeWebViewClient intercepting `*.sandbox.native`
|
||||
*
|
||||
* Each sandbox gets a unique origin (via hostname or scheme+host), so
|
||||
* localStorage/IndexedDB are isolated per sandbox. Since the sandbox is a
|
||||
* regular DOM element, web UI (permission dialogs, popovers) naturally
|
||||
* layers on top.
|
||||
*
|
||||
* All file serving is delegated to the `resolveFile` callback.
|
||||
* Custom RPC methods are delegated to the optional `onRpc` callback.
|
||||
* Consumers (Webxdc, NsitePreviewDialog) are platform-agnostic.
|
||||
*/
|
||||
export const SandboxFrame = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
function SandboxFrame(props, ref) {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
return <SandboxFrameNative ref={ref} {...props} />;
|
||||
}
|
||||
return <SandboxFrameWeb ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
export default SandboxFrame;
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { sidebarItemIcon, itemPath } from '@/lib/sidebarItems';
|
||||
import type { HiddenSidebarItem } from '@/hooks/useFeedSettings';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { parseNsiteSubdomain } from '@/lib/nsiteSubdomain';
|
||||
|
||||
interface SidebarMoreMenuProps {
|
||||
editing: boolean;
|
||||
@@ -152,6 +153,21 @@ export function SidebarMoreMenu({
|
||||
return;
|
||||
}
|
||||
|
||||
// Nsite URI: nsite://<subdomain>
|
||||
if (raw.startsWith('nsite://')) {
|
||||
const subdomain = raw.slice('nsite://'.length);
|
||||
const parsed = parseNsiteSubdomain(subdomain);
|
||||
if (!parsed || parsed.kind !== 35128) {
|
||||
setLinkError('Invalid nsite identifier (only named sites are supported)');
|
||||
return;
|
||||
}
|
||||
onAdd(raw);
|
||||
setLinkInput(false);
|
||||
setLinkValue('');
|
||||
setLinkError('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Nostr: strip "nostr:" prefix if present for validation
|
||||
const bech32 = raw.startsWith('nostr:') ? raw.slice(6) : raw;
|
||||
|
||||
@@ -224,7 +240,7 @@ export function SidebarMoreMenu({
|
||||
setLinkError('');
|
||||
}
|
||||
}}
|
||||
placeholder="URL, npub1..., iso3166:US, ..."
|
||||
placeholder="URL, npub1..., nsite://..., ..."
|
||||
className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isNostrUri, isExternalUri } from '@/lib/sidebarItems';
|
||||
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isNostrUri, isExternalUri, isNsiteUri } from '@/lib/sidebarItems';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCallback } from 'react';
|
||||
import { NostrEventSidebarItem } from '@/components/NostrEventSidebarItem';
|
||||
import { NsiteSidebarItem } from '@/components/NsiteSidebarItem';
|
||||
import { ExternalContentSidebarItem } from '@/components/ExternalContentSidebarItem';
|
||||
|
||||
// ── Sortable item ─────────────────────────────────────────────────────────────
|
||||
@@ -181,6 +182,19 @@ export function SidebarNavList({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isNsiteUri(id)) {
|
||||
return (
|
||||
<NsiteSidebarItem
|
||||
key={id}
|
||||
id={id}
|
||||
active={isActive(id)}
|
||||
editing={editing}
|
||||
onRemove={(removeId) => onRemove(removeId, i)}
|
||||
onClick={getOnClick?.(id)}
|
||||
linkClassName={linkClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isNostrUri(id)) {
|
||||
return (
|
||||
<NostrEventSidebarItem
|
||||
|
||||
@@ -85,7 +85,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
|
||||
// Measure safe-area-inset-top once by reading it via a throw-away element.
|
||||
const probe = document.createElement('div');
|
||||
probe.style.cssText = 'position:fixed;top:env(safe-area-inset-top,0px);left:0;width:0;height:0;visibility:hidden;pointer-events:none';
|
||||
probe.style.cssText = 'position:fixed;top:var(--safe-area-inset-top,env(safe-area-inset-top,0px));left:0;width:0;height:0;visibility:hidden;pointer-events:none';
|
||||
document.body.appendChild(probe);
|
||||
const safeAreaTop = probe.getBoundingClientRect().top;
|
||||
document.body.removeChild(probe);
|
||||
@@ -122,7 +122,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
{showSafeAreaPadding && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 bg-background/85 sidebar:hidden"
|
||||
style={{ height: 'env(safe-area-inset-top, 0px)' }}
|
||||
style={{ height: 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Inner wrapper so ArcBackground covers only the tab area, not the safe-area padding above.
|
||||
@@ -167,9 +167,9 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
type="button"
|
||||
aria-label="Scroll tabs left"
|
||||
onClick={() => scrollBy('left')}
|
||||
className="hidden sidebar:flex absolute left-0 top-0 bottom-0 z-10 items-center pl-0.5 pr-1 bg-gradient-to-r from-background/90 to-transparent cursor-pointer"
|
||||
className="hidden sidebar:flex absolute left-0 top-0 bottom-0 z-10 items-center pl-0.5 pr-1 bg-gradient-to-r from-background via-background to-transparent cursor-pointer"
|
||||
>
|
||||
<ChevronLeft className="size-4 text-muted-foreground" />
|
||||
<ChevronLeft className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
@@ -184,9 +184,9 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
type="button"
|
||||
aria-label="Scroll tabs right"
|
||||
onClick={() => scrollBy('right')}
|
||||
className="hidden sidebar:flex absolute right-0 top-0 bottom-0 z-10 items-center pr-0.5 pl-1 bg-gradient-to-l from-background/90 to-transparent cursor-pointer"
|
||||
className="hidden sidebar:flex absolute right-0 top-0 bottom-0 z-10 items-center pr-0.5 pl-1 bg-gradient-to-l from-background via-background to-transparent cursor-pointer"
|
||||
>
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
<ChevronRight className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,32 +30,52 @@ export function useActiveTabIndicator(active: boolean, elRef: React.RefObject<HT
|
||||
const reportSlice = useCallback(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return null;
|
||||
const scrollOffset = scrollContainerRef.current?.scrollLeft ?? 0;
|
||||
return { left: el.offsetLeft - scrollOffset, width: el.offsetWidth };
|
||||
const container = scrollContainerRef.current;
|
||||
const scrollOffset = container?.scrollLeft ?? 0;
|
||||
// Account for the scroll container's own offset within its parent
|
||||
// (e.g. when innerClassName adds mx-auto centering).
|
||||
const containerOffset = container?.offsetLeft ?? 0;
|
||||
return { left: el.offsetLeft - scrollOffset + containerOffset, width: el.offsetWidth };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Report active slice to SubHeaderBar so the arc indicator renders.
|
||||
// Schedule a second report after paint so that layout-dependent values
|
||||
// (e.g. offsetLeft from mx-auto centering) are fully resolved.
|
||||
useLayoutEffect(() => {
|
||||
if (!active) return;
|
||||
const s = reportSlice();
|
||||
if (s) onActive(s);
|
||||
return () => onActive(null);
|
||||
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const updated = reportSlice();
|
||||
if (updated) onActive(updated);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
onActive(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
|
||||
// Re-report position when the scroll container scrolls,
|
||||
// Re-report position when the scroll container scrolls or resizes,
|
||||
// so the SVG clip-path stays aligned with the visually shifted tab.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const handleScroll = () => {
|
||||
const update = () => {
|
||||
const s = reportSlice();
|
||||
if (s) onActive(s);
|
||||
};
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
container.addEventListener('scroll', update, { passive: true });
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(container);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', update);
|
||||
ro.disconnect();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
@@ -87,34 +88,32 @@ export function TeamSoapboxCard({ className }: { className?: string }) {
|
||||
|
||||
setIsFollowingAll(true);
|
||||
try {
|
||||
const signal = AbortSignal.timeout(10_000);
|
||||
// 1. Fetch freshest kind 3 from relays (not cache)
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
|
||||
const followEvents = await nostr.query(
|
||||
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
|
||||
{ signal },
|
||||
);
|
||||
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
|
||||
const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
|
||||
const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
|
||||
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
|
||||
|
||||
const latestEvent = followEvents.length > 0
|
||||
? followEvents.reduce((latest, current) => current.created_at > latest.created_at ? current : latest)
|
||||
: null;
|
||||
|
||||
const existingFollows = latestEvent
|
||||
? latestEvent.tags.filter(([name]) => name === 'p').map(([, pk]) => pk)
|
||||
: [];
|
||||
|
||||
const allFollows = [...new Set([...existingFollows, ...pubkeys])];
|
||||
const added = pubkeys.filter((pk) => !existingFollows.includes(pk));
|
||||
// 3. Merge: add new pubkeys that aren't already followed
|
||||
const newPTags = pubkeys
|
||||
.filter((pk) => !existingPubkeys.has(pk))
|
||||
.map((pk) => ['p', pk]);
|
||||
const added = newPTags.length;
|
||||
|
||||
// 4. Publish with prev for published_at preservation
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content: latestEvent?.content ?? '',
|
||||
tags: allFollows.map((pk) => ['p', pk]),
|
||||
content: prev?.content ?? '',
|
||||
tags: [...nonPTags, ...existingPTags, ...newPTags],
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Following Team Soapbox!',
|
||||
description: added.length > 0
|
||||
? `Added ${added.length} new account${added.length !== 1 ? 's' : ''} to your follow list.`
|
||||
description: added > 0
|
||||
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
|
||||
: 'You were already following everyone on the team.',
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
+201
-383
@@ -2,21 +2,25 @@ import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
type IframeHTMLAttributes,
|
||||
} from "react";
|
||||
import { unzipSync } from "fflate";
|
||||
} from 'react';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from "@webxdc/types/webxdc";
|
||||
import type { Webxdc as WebxdcAPI, ReceivedStatusUpdate } from '@webxdc/types/webxdc';
|
||||
|
||||
import { SandboxFrame, type SandboxFrameHandle } from '@/components/SandboxFrame';
|
||||
import { getMimeType, bytesToBase64, injectScriptTags } from '@/lib/sandbox';
|
||||
import type { FileResponse } from '@/lib/sandbox';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WebxdcProps
|
||||
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, "src" | "id"> {
|
||||
/** Unique session identifier — used as the subdomain: `<id>.iframe.diy`. */
|
||||
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, 'src' | 'id'> {
|
||||
/** Unique session identifier — used as the sandbox subdomain. */
|
||||
id: string;
|
||||
/** The `.xdc` archive: raw bytes or a URL to fetch them from. */
|
||||
xdc: Uint8Array | string;
|
||||
@@ -32,28 +36,6 @@ export interface WebxdcHandle {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MIME type lookup (covers common web-relevant file types)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html", ".htm": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript", ".mjs": "application/javascript",
|
||||
".json": "application/json",
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
|
||||
".ico": "image/x-icon", ".bmp": "image/bmp", ".avif": "image/avif",
|
||||
".woff": "font/woff", ".woff2": "font/woff2",
|
||||
".ttf": "font/ttf", ".otf": "font/otf",
|
||||
".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav",
|
||||
".opus": "audio/opus", ".weba": "audio/webm",
|
||||
".mp4": "video/mp4", ".webm": "video/webm",
|
||||
".xml": "application/xml", ".txt": "text/plain",
|
||||
".wasm": "application/wasm", ".pdf": "application/pdf",
|
||||
".toml": "application/toml",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSP applied to every response served from the archive.
|
||||
//
|
||||
@@ -67,14 +49,7 @@ const WEBXDC_CSP = [
|
||||
"default-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' data: blob:",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; ");
|
||||
|
||||
function getMimeType(path: string): string {
|
||||
const dot = path.lastIndexOf(".");
|
||||
if (dot === -1) return "application/octet-stream";
|
||||
const ext = path.slice(dot).toLowerCase();
|
||||
return MIME_TYPES[ext] ?? "application/octet-stream";
|
||||
}
|
||||
].join('; ');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -82,7 +57,7 @@ function getMimeType(path: string): string {
|
||||
|
||||
/** Resolve `xdc` prop to a Uint8Array. */
|
||||
async function resolveXdc(xdc: Uint8Array | string): Promise<Uint8Array> {
|
||||
if (typeof xdc === "string") {
|
||||
if (typeof xdc === 'string') {
|
||||
const res = await fetch(xdc);
|
||||
if (!res.ok) throw new Error(`Failed to fetch xdc: ${res.status}`);
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
@@ -95,32 +70,17 @@ function unzipXdc(bytes: Uint8Array): Map<string, Uint8Array> {
|
||||
const unzipped = unzipSync(bytes);
|
||||
const fileMap = new Map<string, Uint8Array>();
|
||||
for (const [path, content] of Object.entries(unzipped)) {
|
||||
const normalised = path.replace(/^\/+/, "").replace(/\\/g, "/");
|
||||
if (normalised.endsWith("/")) continue; // skip directories
|
||||
const normalised = path.replace(/^\/+/, '').replace(/\\/g, '/');
|
||||
if (normalised.endsWith('/')) continue; // skip directories
|
||||
fileMap.set(normalised, content);
|
||||
}
|
||||
return fileMap;
|
||||
}
|
||||
|
||||
/** Encode a Uint8Array to base64. */
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Encode a UTF-8 string to base64. */
|
||||
function utf8ToBase64(str: string): string {
|
||||
const bytes = new TextEncoder().encode(str);
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the webxdc bridge script that will be injected into HTML responses.
|
||||
* This script implements window.webxdc by sending JSON-RPC requests to the
|
||||
* parent (Ditto) through iframe.diy's relay.
|
||||
* parent through the sandbox frame's relay.
|
||||
*/
|
||||
function generateWebxdcBridge(api: WebxdcAPI<unknown>): string {
|
||||
return `(function(){
|
||||
@@ -253,357 +213,51 @@ function generateWebxdcBridge(api: WebxdcAPI<unknown>): string {
|
||||
})();`;
|
||||
}
|
||||
|
||||
/** Virtual path used to serve the webxdc bridge script. */
|
||||
const BRIDGE_SCRIPT_PATH = "webxdc.js";
|
||||
|
||||
/**
|
||||
* Inject a `<script src="/webxdc.js">` tag into an HTML document string.
|
||||
* Uses DOMParser so we don't rely on fragile regex against HTML.
|
||||
* The tag is prepended inside `<head>` so it runs before any app scripts.
|
||||
*/
|
||||
function injectScriptTag(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const script = doc.createElement("script");
|
||||
script.src = `/${BRIDGE_SCRIPT_PATH}`;
|
||||
// Prepend as first child of <head> so it loads before the app's own scripts.
|
||||
doc.head.prepend(script);
|
||||
// Serialise back to an HTML string. doctype is lost by DOMParser, so
|
||||
// we re-add it when the original document had one.
|
||||
const hasDoctype = /^<!doctype\s/i.test(html.trimStart());
|
||||
const serialised = doc.documentElement.outerHTML;
|
||||
return hasDoctype ? "<!DOCTYPE html>\n" + serialised : serialised;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renders a webxdc app inside an iframe hosted on `<id>.iframe.diy`.
|
||||
* Renders a webxdc app inside a sandboxed iframe.
|
||||
*
|
||||
* The component handles the full lifecycle:
|
||||
* 1. Waits for `ready` from the iframe.diy frame.
|
||||
* 2. Fetches and unzips the `.xdc` archive on the parent side.
|
||||
* 3. Sends `init` to signal the frame to start.
|
||||
* 4. Responds to `fetch` RPC requests by serving files from the archive,
|
||||
* injecting the webxdc bridge script into HTML responses.
|
||||
* 5. Handles `webxdc.*` RPC requests from the bridge script (relayed by
|
||||
* iframe.diy) and proxies them to the provided `WebxdcAPI` instance.
|
||||
* 1. Fetches and unzips the `.xdc` archive on the parent side.
|
||||
* 2. Serves files from the archive via the sandbox frame's fetch proxy.
|
||||
* 3. Injects the webxdc bridge script into HTML responses.
|
||||
* 4. Handles `webxdc.*` RPC requests from the bridge script and proxies
|
||||
* them to the provided `WebxdcAPI` instance.
|
||||
*/
|
||||
export const Webxdc = forwardRef<WebxdcHandle, WebxdcProps>(function Webxdc(
|
||||
{ id, xdc, webxdc, ...iframeProps },
|
||||
ref,
|
||||
) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const sandboxRef = useRef<SandboxFrameHandle>(null);
|
||||
|
||||
// Keep latest props in refs so the message handler always sees current values
|
||||
// without needing to re-register the listener.
|
||||
// Keep latest props in refs so callbacks always see current values.
|
||||
const webxdcRef = useRef(webxdc);
|
||||
const xdcRef = useRef(xdc);
|
||||
useEffect(() => {
|
||||
webxdcRef.current = webxdc;
|
||||
}, [webxdc]);
|
||||
useEffect(() => {
|
||||
xdcRef.current = xdc;
|
||||
}, [xdc]);
|
||||
useEffect(() => { webxdcRef.current = webxdc; }, [webxdc]);
|
||||
useEffect(() => { xdcRef.current = xdc; }, [xdc]);
|
||||
|
||||
// The unzipped file map, populated on first `ready` message.
|
||||
// The unzipped file map, populated on first `onReady`.
|
||||
const fileMapRef = useRef<Map<string, Uint8Array> | null>(null);
|
||||
// The generated bridge script, cached per webxdc instance.
|
||||
const bridgeScriptRef = useRef<string>("");
|
||||
const bridgeScriptRef = useRef<string>('');
|
||||
|
||||
const origin = `https://${id}.iframe.diy`;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Post a JSON-RPC message to the iframe
|
||||
// ------------------------------------------------------------------
|
||||
const post = useCallback(
|
||||
(msg: Record<string, unknown>, transfer?: Transferable[]) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
msg,
|
||||
origin,
|
||||
transfer ?? [],
|
||||
);
|
||||
},
|
||||
[origin],
|
||||
);
|
||||
// Realtime channel handles, keyed by channelId.
|
||||
const realtimeChannels = useRef<
|
||||
Map<string, ReturnType<WebxdcAPI<unknown>['joinRealtimeChannel']>>
|
||||
>(new Map());
|
||||
|
||||
// Expose imperative handle so parent components can post messages and focus.
|
||||
useImperativeHandle(ref, () => ({
|
||||
postMessage: (msg: Record<string, unknown>, transfer?: Transferable[]) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
msg,
|
||||
origin,
|
||||
transfer ?? [],
|
||||
);
|
||||
sandboxRef.current?.postMessage(msg, transfer);
|
||||
},
|
||||
focus: () => {
|
||||
iframeRef.current?.focus();
|
||||
sandboxRef.current?.focus();
|
||||
},
|
||||
}), [origin]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Handle messages coming from the iframe
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
// Only accept messages from our iframe's origin.
|
||||
if (event.origin !== origin) return;
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const msg = event.data as any;
|
||||
if (!msg || msg.jsonrpc !== "2.0") return;
|
||||
|
||||
// --- Notification: ready → fetch xdc, unzip, send init -----------
|
||||
if (msg.method === "ready" && msg.id === undefined) {
|
||||
handleReady();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Requests (have an `id`) ------------------------------------
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
if (msg.method === "fetch") {
|
||||
handleFetch(msg.id, msg.params);
|
||||
} else {
|
||||
// webxdc.* RPC methods relayed from the bridge script
|
||||
handleWebxdcRequest(msg.id, msg.method, msg.params ?? {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReady() {
|
||||
try {
|
||||
// Fetch and unzip the .xdc archive on the parent side.
|
||||
const bytes = await resolveXdc(xdcRef.current);
|
||||
fileMapRef.current = unzipXdc(bytes);
|
||||
|
||||
// Generate the bridge script with current webxdc API values.
|
||||
bridgeScriptRef.current = generateWebxdcBridge(webxdcRef.current);
|
||||
|
||||
// Send init notification (iframe.diy protocol).
|
||||
post({
|
||||
jsonrpc: "2.0",
|
||||
method: "init",
|
||||
params: { version: 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Webxdc] Failed to initialise:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function handleFetch(id: string | number, params: any) {
|
||||
const reqUrl: string | undefined = params?.request?.url;
|
||||
if (!reqUrl) {
|
||||
post({ jsonrpc: "2.0", id, error: { code: -32001, message: "Invalid request" } });
|
||||
return;
|
||||
}
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(reqUrl).pathname;
|
||||
} catch {
|
||||
post({ jsonrpc: "2.0", id, error: { code: -32003, message: "Invalid URL" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const fileMap = fileMapRef.current;
|
||||
if (!fileMap) {
|
||||
post({
|
||||
jsonrpc: "2.0", id,
|
||||
result: {
|
||||
status: 503, statusText: "Not Ready",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: utf8ToBase64("Archive not loaded"),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalise: "/" and "/index.html" both resolve to "index.html".
|
||||
const filePath =
|
||||
pathname === "/" ? "index.html" : decodeURIComponent(pathname.slice(1));
|
||||
|
||||
// Serve the virtual webxdc bridge script.
|
||||
if (filePath === BRIDGE_SCRIPT_PATH) {
|
||||
post({
|
||||
jsonrpc: "2.0", id,
|
||||
result: {
|
||||
status: 200, statusText: "OK",
|
||||
headers: {
|
||||
"Content-Type": "application/javascript",
|
||||
"Content-Security-Policy": WEBXDC_CSP,
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
body: utf8ToBase64(bridgeScriptRef.current),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileBytes = fileMap.get(filePath);
|
||||
if (!fileBytes) {
|
||||
post({
|
||||
jsonrpc: "2.0", id,
|
||||
result: {
|
||||
status: 404, statusText: "Not Found",
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"Content-Security-Policy": WEBXDC_CSP,
|
||||
},
|
||||
body: utf8ToBase64("Not Found: " + pathname),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = getMimeType(filePath);
|
||||
|
||||
// Inject a <script src="/webxdc.js"> tag into HTML responses.
|
||||
if (contentType.includes("text/html")) {
|
||||
const html = new TextDecoder().decode(fileBytes);
|
||||
const injected = injectScriptTag(html);
|
||||
post({
|
||||
jsonrpc: "2.0", id,
|
||||
result: {
|
||||
status: 200, statusText: "OK",
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Security-Policy": WEBXDC_CSP,
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
body: utf8ToBase64(injected),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
post({
|
||||
jsonrpc: "2.0", id,
|
||||
result: {
|
||||
status: 200, statusText: "OK",
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Security-Policy": WEBXDC_CSP,
|
||||
"Content-Length": String(fileBytes.byteLength),
|
||||
},
|
||||
body: bytesToBase64(fileBytes),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function handleWebxdcRequest(id: string | number, method: string, params: any) {
|
||||
const api = webxdcRef.current;
|
||||
|
||||
const respond = (result: unknown) =>
|
||||
post({ jsonrpc: "2.0", id, result });
|
||||
const respondError = (code: number, message: string) =>
|
||||
post({ jsonrpc: "2.0", id, error: { code, message } });
|
||||
|
||||
try {
|
||||
switch (method) {
|
||||
case "webxdc.sendUpdate": {
|
||||
api.sendUpdate(params.update, "");
|
||||
respond(null);
|
||||
break;
|
||||
}
|
||||
|
||||
case "webxdc.setUpdateListener": {
|
||||
const serial: number = params.serial ?? 0;
|
||||
// Forward every update into the frame as a notification.
|
||||
await api.setUpdateListener(
|
||||
(update: ReceivedStatusUpdate<unknown>) => {
|
||||
post({
|
||||
jsonrpc: "2.0",
|
||||
method: "webxdc.update",
|
||||
params: { update },
|
||||
});
|
||||
},
|
||||
serial,
|
||||
);
|
||||
respond(null);
|
||||
break;
|
||||
}
|
||||
|
||||
case "webxdc.getAllUpdates": {
|
||||
const updates = await api.getAllUpdates();
|
||||
respond(updates);
|
||||
break;
|
||||
}
|
||||
|
||||
case "webxdc.sendToChat": {
|
||||
await api.sendToChat(params.message);
|
||||
respond(null);
|
||||
break;
|
||||
}
|
||||
|
||||
case "webxdc.importFiles": {
|
||||
const files = await api.importFiles(params.filter ?? {});
|
||||
// File objects can't be serialised — convert to transferable form.
|
||||
const result = await Promise.all(
|
||||
files.map(async (f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
data: bytesToBase64(new Uint8Array(await f.arrayBuffer())),
|
||||
})),
|
||||
);
|
||||
respond(result);
|
||||
break;
|
||||
}
|
||||
|
||||
case "webxdc.joinRealtimeChannel": {
|
||||
const rt = api.joinRealtimeChannel();
|
||||
// Generate a channel id to track this listener.
|
||||
const channelId = crypto.randomUUID();
|
||||
|
||||
rt.setListener((data: Uint8Array) => {
|
||||
post({
|
||||
jsonrpc: "2.0",
|
||||
method: "webxdc.realtimeChannel.data",
|
||||
params: { channelId, data: Array.from(data) },
|
||||
});
|
||||
});
|
||||
|
||||
// Store on ref so subsequent calls can find it.
|
||||
realtimeChannels.current.set(channelId, rt);
|
||||
respond({ channelId });
|
||||
break;
|
||||
}
|
||||
|
||||
case "webxdc.realtimeChannel.send": {
|
||||
const ch = realtimeChannels.current.get(params.channelId);
|
||||
if (ch) ch.send(new Uint8Array(params.data));
|
||||
respond(null);
|
||||
break;
|
||||
}
|
||||
|
||||
case "webxdc.realtimeChannel.leave": {
|
||||
const ch = realtimeChannels.current.get(params.channelId);
|
||||
if (ch) {
|
||||
ch.leave();
|
||||
realtimeChannels.current.delete(params.channelId);
|
||||
}
|
||||
respond(null);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
respondError(-32601, `Method not found: ${method}`);
|
||||
}
|
||||
} catch (err) {
|
||||
respondError(-1, String(err));
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [origin, post]);
|
||||
|
||||
// Realtime channel handles, keyed by channelId.
|
||||
const realtimeChannels = useRef<
|
||||
Map<string, ReturnType<WebxdcAPI<unknown>["joinRealtimeChannel"]>>
|
||||
>(new Map());
|
||||
}), []);
|
||||
|
||||
// Clean up realtime channels on unmount.
|
||||
useEffect(() => {
|
||||
@@ -614,10 +268,174 @@ export const Webxdc = forwardRef<WebxdcHandle, WebxdcProps>(function Webxdc(
|
||||
};
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// onReady: fetch and unzip the archive when the sandbox is ready
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
const onReady = useCallback(async () => {
|
||||
try {
|
||||
const bytes = await resolveXdc(xdcRef.current);
|
||||
fileMapRef.current = unzipXdc(bytes);
|
||||
bridgeScriptRef.current = generateWebxdcBridge(webxdcRef.current);
|
||||
} catch (err) {
|
||||
console.error('[Webxdc] Failed to initialise:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// File resolver: serve files from the unzipped archive
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
const resolveFile = useCallback(async (pathname: string): Promise<FileResponse | null> => {
|
||||
const fileMap = fileMapRef.current;
|
||||
if (!fileMap) {
|
||||
// Archive not loaded yet — return a 503.
|
||||
return {
|
||||
status: 503,
|
||||
contentType: 'text/plain',
|
||||
body: new TextEncoder().encode('Archive not loaded'),
|
||||
};
|
||||
}
|
||||
|
||||
// Normalise: "/" and "/index.html" both resolve to "index.html".
|
||||
const filePath =
|
||||
pathname === '/' ? 'index.html' : decodeURIComponent(pathname.slice(1));
|
||||
|
||||
const fileBytes = fileMap.get(filePath);
|
||||
if (!fileBytes) return null;
|
||||
|
||||
const contentType = getMimeType(filePath);
|
||||
return { status: 200, contentType, body: fileBytes };
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// File resolver with bridge script injection
|
||||
//
|
||||
// The webxdc bridge is generated dynamically in onReady (it embeds
|
||||
// runtime values like selfAddr), so we can't use SandboxFrame's
|
||||
// static injectedScripts prop. Instead we:
|
||||
// 1. Serve /webxdc.js ourselves from bridgeScriptRef
|
||||
// 2. Inject <script src="/webxdc.js"> into HTML responses here
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
const resolveFileWithBridge = useCallback(async (pathname: string): Promise<FileResponse | null> => {
|
||||
// Serve the virtual webxdc bridge script.
|
||||
if (pathname === '/webxdc.js') {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/javascript',
|
||||
body: new TextEncoder().encode(bridgeScriptRef.current),
|
||||
};
|
||||
}
|
||||
|
||||
const file = await resolveFile(pathname);
|
||||
if (!file) return null;
|
||||
|
||||
// Inject <script src="/webxdc.js"> into HTML responses.
|
||||
if (file.contentType.includes('text/html')) {
|
||||
const html = new TextDecoder().decode(file.body);
|
||||
const injected = injectScriptTags(html, ['/webxdc.js']);
|
||||
return { ...file, body: new TextEncoder().encode(injected) };
|
||||
}
|
||||
|
||||
return file;
|
||||
}, [resolveFile]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Custom RPC handler: webxdc.* methods
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const onRpc = useCallback(async (method: string, params: any, post: (msg: Record<string, unknown>) => void): Promise<unknown> => {
|
||||
const api = webxdcRef.current;
|
||||
|
||||
switch (method) {
|
||||
case 'webxdc.sendUpdate': {
|
||||
api.sendUpdate(params.update, '');
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'webxdc.setUpdateListener': {
|
||||
const serial: number = params.serial ?? 0;
|
||||
// Forward every update into the frame as a notification.
|
||||
await api.setUpdateListener(
|
||||
(update: ReceivedStatusUpdate<unknown>) => {
|
||||
post({
|
||||
jsonrpc: '2.0',
|
||||
method: 'webxdc.update',
|
||||
params: { update },
|
||||
});
|
||||
},
|
||||
serial,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'webxdc.getAllUpdates': {
|
||||
return await api.getAllUpdates();
|
||||
}
|
||||
|
||||
case 'webxdc.sendToChat': {
|
||||
await api.sendToChat(params.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'webxdc.importFiles': {
|
||||
const files = await api.importFiles(params.filter ?? {});
|
||||
// File objects can't be serialised — convert to transferable form.
|
||||
return await Promise.all(
|
||||
files.map(async (f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
data: bytesToBase64(new Uint8Array(await f.arrayBuffer())),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
case 'webxdc.joinRealtimeChannel': {
|
||||
const rt = api.joinRealtimeChannel();
|
||||
const channelId = crypto.randomUUID();
|
||||
|
||||
rt.setListener((data: Uint8Array) => {
|
||||
post({
|
||||
jsonrpc: '2.0',
|
||||
method: 'webxdc.realtimeChannel.data',
|
||||
params: { channelId, data: Array.from(data) },
|
||||
});
|
||||
});
|
||||
|
||||
realtimeChannels.current.set(channelId, rt);
|
||||
return { channelId };
|
||||
}
|
||||
|
||||
case 'webxdc.realtimeChannel.send': {
|
||||
const ch = realtimeChannels.current.get(params.channelId);
|
||||
if (ch) ch.send(new Uint8Array(params.data));
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'webxdc.realtimeChannel.leave': {
|
||||
const ch = realtimeChannels.current.get(params.channelId);
|
||||
if (ch) {
|
||||
ch.leave();
|
||||
realtimeChannels.current.delete(params.channelId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Method not found: ${method}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${origin}/`}
|
||||
<SandboxFrame
|
||||
ref={sandboxRef}
|
||||
id={id}
|
||||
resolveFile={resolveFileWithBridge}
|
||||
onRpc={onRpc}
|
||||
csp={WEBXDC_CSP}
|
||||
onReady={onReady}
|
||||
{...iframeProps}
|
||||
/>
|
||||
);
|
||||
|
||||
+94
-133
@@ -1,9 +1,10 @@
|
||||
import { useState, useRef, useCallback, forwardRef } from 'react';
|
||||
import { Blocks, Play, Maximize2, Minimize2, RotateCcw, X, Gamepad2 } from 'lucide-react';
|
||||
import { useState, useRef, useCallback, useEffect, forwardRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Blocks, Play, X, Gamepad2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Webxdc, type WebxdcHandle } from '@/components/Webxdc';
|
||||
import { GameControls } from '@/components/GameControls';
|
||||
import { useCenterColumn } from '@/contexts/LayoutContext';
|
||||
import { useWebxdc } from '@/hooks/useWebxdc';
|
||||
import { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -20,38 +21,52 @@ export interface WebxdcEmbedProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Rect { left: number; top: number; width: number; height: number }
|
||||
|
||||
/** Track the viewport-relative bounding rect of an element, updating on resize. */
|
||||
function useElementRect(el: HTMLElement | null): Rect | null {
|
||||
const [rect, setRect] = useState<Rect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!el) { setRect(null); return; }
|
||||
|
||||
const measure = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setRect({ left: r.left, top: r.top, width: r.width, height: r.height });
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
|
||||
}, [el]);
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a webxdc app embedded in the feed. Shows a launch button initially,
|
||||
* then loads the sandboxed iframe when the user clicks to interact.
|
||||
* Renders a webxdc app embedded in the feed. Shows a launch card initially,
|
||||
* then opens a fullscreen panel (covering the center column on desktop, the
|
||||
* full screen on mobile) when the user clicks Play — matching the nsite UX.
|
||||
*/
|
||||
export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedProps) {
|
||||
const [launched, setLaunched] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
const [showGamepad, setShowGamepad] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const webxdcHandleRef = useRef<WebxdcHandle>(null);
|
||||
|
||||
const centerColumn = useCenterColumn();
|
||||
const columnRect = useElementRect(launched ? centerColumn : null);
|
||||
|
||||
// Derive a private, stable subdomain from a device-local seed + the identifier.
|
||||
// This prevents event authors from choosing a subdomain that collides with
|
||||
// another app's origin on iframe.diy.
|
||||
const identifier = uuid ?? url;
|
||||
const iframeId = deriveIframeSubdomain('webxdc', identifier);
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
setIframeKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setLaunched(false);
|
||||
setIsFullscreen(false);
|
||||
setShowGamepad(false);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const toggleGamepad = useCallback(() => {
|
||||
setShowGamepad((prev) => {
|
||||
if (!prev) webxdcHandleRef.current?.focus();
|
||||
@@ -94,136 +109,84 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
if (!centerColumn || !columnRect) return null;
|
||||
|
||||
// Clamp to viewport top edge so the panel never grows taller than the viewport.
|
||||
const panelTop = Math.max(0, columnRect.top);
|
||||
const panelHeight = window.innerHeight - panelTop;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
isFullscreen
|
||||
? 'fixed inset-0 z-50 bg-background flex flex-col'
|
||||
: 'mt-3 rounded-2xl border border-border overflow-hidden flex flex-col',
|
||||
!isFullscreen && className,
|
||||
)}
|
||||
className="fixed z-50 flex flex-col bg-background"
|
||||
style={{
|
||||
left: columnRect.left,
|
||||
top: panelTop,
|
||||
width: columnRect.width,
|
||||
height: panelHeight,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Controls bar */}
|
||||
<div className={cn(
|
||||
'flex items-center justify-between px-3 py-1.5 bg-muted/60 border-b border-border',
|
||||
isFullscreen ? '' : 'rounded-t-2xl',
|
||||
)}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon}
|
||||
alt={name ?? 'Webxdc App'}
|
||||
className="size-5 rounded-md object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<Blocks className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground truncate">
|
||||
{name ?? 'Webxdc App'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-7', showGamepad && 'text-primary')}
|
||||
onClick={toggleGamepad}
|
||||
>
|
||||
<Gamepad2 className="size-3.5" />
|
||||
<span className="sr-only">
|
||||
{showGamepad ? 'Hide gamepad' : 'Show gamepad'}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{showGamepad ? 'Hide gamepad' : 'Show gamepad'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={handleReload}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
<span className="sr-only">Reload</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Reload</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="size-3.5" />
|
||||
) : (
|
||||
<Maximize2 className="size-3.5" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Nav bar */}
|
||||
<div className="min-h-11 border-b bg-muted/30 shrink-0 safe-area-top">
|
||||
<div className="px-3 py-2 flex items-center gap-2 w-full">
|
||||
{/* App icon + name */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon}
|
||||
alt={name ?? 'Webxdc App'}
|
||||
className="size-6 rounded-md object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Blocks className="size-3.5 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{name ?? 'Webxdc App'}</span>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-7 w-7 p-0 shrink-0', showGamepad && 'text-primary')}
|
||||
onClick={toggleGamepad}
|
||||
title={showGamepad ? 'Hide gamepad' : 'Show gamepad'}
|
||||
>
|
||||
<Gamepad2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={handleClose}
|
||||
title="Close"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Iframe area */}
|
||||
<div className={cn("bg-white", isFullscreen ? 'flex-1 relative' : 'relative')}>
|
||||
<div className="flex-1 min-h-0 bg-white relative">
|
||||
<WebxdcIframe
|
||||
key={iframeKey}
|
||||
ref={webxdcHandleRef}
|
||||
id={iframeId}
|
||||
url={url}
|
||||
uuid={uuid}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Game controls overlay */}
|
||||
{showGamepad && (
|
||||
<div className={cn(
|
||||
'border-t border-border bg-background/80 backdrop-blur-sm',
|
||||
isFullscreen ? '' : 'rounded-b-2xl',
|
||||
)}>
|
||||
<div className="border-t border-border bg-background/80 backdrop-blur-sm">
|
||||
<GameControls webxdcHandle={webxdcHandleRef.current} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,8 +198,7 @@ const WebxdcIframe = forwardRef<WebxdcHandle, {
|
||||
id: string;
|
||||
url: string;
|
||||
uuid?: string;
|
||||
isFullscreen: boolean;
|
||||
}>(function WebxdcIframe({ id, url, uuid, isFullscreen }, ref) {
|
||||
}>(function WebxdcIframe({ id, url, uuid }, ref) {
|
||||
const webxdc = useWebxdc(uuid ?? '');
|
||||
|
||||
return (
|
||||
@@ -246,8 +208,7 @@ const WebxdcIframe = forwardRef<WebxdcHandle, {
|
||||
xdc={url}
|
||||
webxdc={webxdc}
|
||||
allow="autoplay; fullscreen; gamepad"
|
||||
className="w-full border-0"
|
||||
style={{ height: isFullscreen ? '100%' : '400px' }}
|
||||
className="w-full h-full border-0"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronLeft, ChevronRight, ExternalLink, GitFork, Globe, Package, Shiel
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
@@ -243,8 +244,8 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps)
|
||||
const platforms = getAllTags(event.tags, 'f');
|
||||
const uniquePlatforms = useMemo(() => getUniquePlatforms(platforms), [platforms]);
|
||||
const hashtags = getAllTags(event.tags, 't');
|
||||
const websiteUrl = getTag(event.tags, 'url');
|
||||
const repoUrl = getTag(event.tags, 'repository');
|
||||
const websiteUrl = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
const repoUrl = sanitizeUrl(getTag(event.tags, 'repository'));
|
||||
const license = getTag(event.tags, 'license');
|
||||
const appId = getTag(event.tags, 'd');
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ZAPSTORE_RELAY } from '@/lib/appRelays';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Sanitize schema allowing only the subset needed for a CHANGELOG. */
|
||||
const CHANGELOG_SANITIZE_SCHEMA = {
|
||||
@@ -203,7 +204,7 @@ function useReleaseApp(appIdentifier: string | undefined, releasePubkey: string)
|
||||
/** Single asset download row. */
|
||||
function AssetRow({ event }: { event: NostrEvent }) {
|
||||
const mime = getTag(event.tags, 'm') ?? '';
|
||||
const url = getTag(event.tags, 'url');
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
const version = getTag(event.tags, 'version');
|
||||
const size = formatSize(getTag(event.tags, 'size'));
|
||||
const platforms = getAllTags(event.tags, 'f');
|
||||
@@ -561,7 +562,7 @@ interface ZapstoreAssetContentProps {
|
||||
/** Renders a kind 3063 Zapstore software asset event. */
|
||||
export function ZapstoreAssetContent({ event, compact }: ZapstoreAssetContentProps) {
|
||||
const mime = getTag(event.tags, 'm') ?? '';
|
||||
const url = getTag(event.tags, 'url');
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
const version = getTag(event.tags, 'version');
|
||||
const size = formatSize(getTag(event.tags, 'size'));
|
||||
const appIdentifier = getTag(event.tags, 'i');
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
generateNostrConnectURI,
|
||||
type NostrConnectParams,
|
||||
} from '@/hooks/useLoginActions';
|
||||
import { androidResume } from '@/lib/androidResume';
|
||||
import { getNsecCredential } from '@/lib/credentialManager';
|
||||
import { DialogTitle } from '@radix-ui/react-dialog';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
@@ -78,22 +78,18 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
}, [login, config.appName]);
|
||||
|
||||
// Start listening for connection (async) - runs after params are set.
|
||||
//
|
||||
// On Android, switching to Amber freezes the WebSocket so the NIP-46
|
||||
// response is silently dropped. When Ditto returns to the foreground we
|
||||
// abort the stale subscription and start a fresh one — the relay still
|
||||
// has the response event so `limit: 1` picks it up immediately.
|
||||
useEffect(() => {
|
||||
if (!nostrConnectParams || isWaitingForConnect) return;
|
||||
|
||||
let cancelled = false;
|
||||
let stopWatching: (() => void) | undefined;
|
||||
|
||||
const attemptConnect = async (signal: AbortSignal) => {
|
||||
const startListening = async () => {
|
||||
setIsWaitingForConnect(true);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
await login.nostrconnect(nostrConnectParams, signal);
|
||||
await login.nostrconnect(nostrConnectParams, abortControllerRef.current.signal);
|
||||
if (!cancelled) {
|
||||
stopWatching?.();
|
||||
onLogin();
|
||||
onClose();
|
||||
}
|
||||
@@ -101,42 +97,9 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
if (cancelled) return;
|
||||
// AbortError means we intentionally aborted (dialog closed or retry)
|
||||
if (error instanceof Error && error.name === 'AbortError') return;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const startListening = async () => {
|
||||
setIsWaitingForConnect(true);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// On Android, watch for foreground resume and retry the subscription.
|
||||
({ destroy: stopWatching } = androidResume({
|
||||
threshold: 0,
|
||||
onResume: () => {
|
||||
if (cancelled) return;
|
||||
console.log('[LoginDialog] foreground resume — retrying nostrconnect');
|
||||
// Abort the current (stale) subscription
|
||||
abortControllerRef.current?.abort();
|
||||
// Start a fresh subscription
|
||||
abortControllerRef.current = new AbortController();
|
||||
attemptConnect(abortControllerRef.current.signal).catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Nostrconnect retry failed:', error);
|
||||
setConnectError(error instanceof Error ? error.message : String(error));
|
||||
setIsWaitingForConnect(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
await attemptConnect(abortControllerRef.current.signal);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('Nostrconnect failed:', error);
|
||||
setConnectError(error instanceof Error ? error.message : String(error));
|
||||
setIsWaitingForConnect(false);
|
||||
}
|
||||
console.error('Nostrconnect failed:', error);
|
||||
setConnectError(error instanceof Error ? error.message : String(error));
|
||||
setIsWaitingForConnect(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,7 +107,6 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopWatching?.();
|
||||
};
|
||||
}, [nostrConnectParams, login, onLogin, onClose, isWaitingForConnect]);
|
||||
|
||||
@@ -299,6 +261,24 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
|
||||
const [isMoreOptionsOpen, setIsMoreOptionsOpen] = useState(false);
|
||||
|
||||
// Progressive enhancement: attempt to retrieve a stored credential from the
|
||||
// platform's password manager when the dialog opens.
|
||||
// On Capacitor iOS this shows the iCloud Keychain credential picker.
|
||||
// On Chromium browsers this shows the native credential chooser.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let cancelled = false;
|
||||
|
||||
getNsecCredential().then((cred) => {
|
||||
if (cancelled || !cred) return;
|
||||
if (validateNsec(cred.nsec)) {
|
||||
executeLogin(cred.nsec);
|
||||
}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const renderTabs = () => (
|
||||
<Tabs
|
||||
defaultValue="key"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// NOTE: This file is stable and usually should not be modified.
|
||||
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
|
||||
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Download, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -12,7 +11,7 @@ import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { downloadTextFile } from '@/lib/downloadFile';
|
||||
import { saveNsec } from '@/lib/credentialManager';
|
||||
import { ProfileCard } from '@/components/ProfileCard';
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||
@@ -39,14 +38,19 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
|
||||
// Generate a proper nsec key using nostr-tools
|
||||
// Generate a proper nsec key using nostr-tools.
|
||||
// The credential manager / file download is deferred until the user clicks "Continue".
|
||||
const generateKey = () => {
|
||||
const sk = generateSecretKey();
|
||||
setNsec(nip19.nsecEncode(sk));
|
||||
const encoded = nip19.nsecEncode(sk);
|
||||
setNsec(encoded);
|
||||
setStep('download');
|
||||
};
|
||||
|
||||
const downloadKey = async () => {
|
||||
// Continue handler for the save-key step — saves the key via the best
|
||||
// available method (native credential manager on iOS/Android, file download
|
||||
// on web), logs in, and advances to the profile step.
|
||||
const handleContinue = async () => {
|
||||
try {
|
||||
const decoded = nip19.decode(nsec);
|
||||
if (decoded.type !== 'nsec') {
|
||||
@@ -55,21 +59,15 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
|
||||
const pubkey = getPublicKey(decoded.data);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
|
||||
|
||||
await downloadTextFile(filename, nsec);
|
||||
await saveNsec(npub, nsec);
|
||||
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
toast({ title: 'Key saved', description: `Saved to Download/${filename}` });
|
||||
}
|
||||
|
||||
// Continue to profile step
|
||||
login.nsec(nsec);
|
||||
setStep('profile');
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: 'Could not download the key file. Please copy it manually.',
|
||||
title: 'Save failed',
|
||||
description: 'Could not save the key. Please copy it manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -166,7 +164,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Step */}
|
||||
{/* Save Key Step */}
|
||||
{step === 'download' && (
|
||||
<div className='space-y-4'>
|
||||
<div className="flex size-16 text-4xl bg-primary/10 rounded-full items-center justify-center justify-self-center">
|
||||
@@ -197,10 +195,9 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
|
||||
<Button
|
||||
className="w-full h-12 px-9"
|
||||
onClick={downloadKey}
|
||||
onClick={handleContinue}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Download key
|
||||
Continue
|
||||
</Button>
|
||||
|
||||
<div className='mx-auto max-w-sm'>
|
||||
@@ -211,7 +208,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-xs text-amber-900 dark:text-amber-300'>
|
||||
This key is your primary and only means of accessing your account. Store it safely and securely. Please download your key to continue.
|
||||
This key is your primary and only means of accessing your account. Store it safely and securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-[18px] w-[18px] shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
"peer h-[18px] w-[18px] shrink-0 rounded-xs border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -70,7 +70,7 @@ const SheetContent = React.forwardRef<
|
||||
? "left-full ml-3 top-4"
|
||||
: "right-4 top-4 rounded-sm ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2 data-[state=open]:bg-secondary"
|
||||
)}
|
||||
style={{ top: `calc(env(safe-area-inset-top, 0px) + 0.85rem)` }}
|
||||
style={{ top: `calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 0.85rem)` }}
|
||||
>
|
||||
<X className={side === "left" ? "h-5 w-5 text-white" : "h-4 w-4"} strokeWidth={side === "left" ? 2.5 : 2} />
|
||||
<span className="sr-only">Close</span>
|
||||
|
||||
@@ -14,7 +14,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[300] flex max-h-screen w-full flex-col-reverse p-4 pt-[max(1rem,env(safe-area-inset-top))] md:bottom-0 md:right-0 md:top-auto md:flex-col md:pt-4 md:max-w-[420px]",
|
||||
"fixed top-0 z-[300] flex max-h-screen w-full flex-col-reverse p-4 pt-[max(1rem,var(--safe-area-inset-top,env(safe-area-inset-top)))] md:bottom-0 md:right-0 md:top-auto md:flex-col md:pt-4 md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -241,6 +241,10 @@ export interface AppConfig {
|
||||
savedFeeds: SavedFeed[];
|
||||
/** Image upload quality: "compressed" resizes/optimizes, "original" uploads as-is. Default: "compressed". */
|
||||
imageQuality: 'compressed' | 'original';
|
||||
/** Hex pubkey of the curator whose follow list defines the Ditto feed. */
|
||||
curatorPubkey?: string;
|
||||
/** Wildcard domain used for iframe sandboxing (e.g. "iframe.diy"). Default: "iframe.diy". */
|
||||
sandboxDomain: string;
|
||||
}
|
||||
|
||||
export interface AppContextType {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/**
|
||||
* Tracks which nsite (by subdomain) currently has its player open.
|
||||
* Used by the sidebar to highlight the active nsite item, and by
|
||||
* NsiteCard to register/unregister the open player.
|
||||
*/
|
||||
export interface NsitePlayerState {
|
||||
/** The subdomain of the currently-open nsite player, or null. */
|
||||
activeSubdomain: string | null;
|
||||
/** Set the active nsite subdomain (call with null to clear). */
|
||||
setActiveSubdomain: (subdomain: string | null) => void;
|
||||
}
|
||||
|
||||
export const NsitePlayerContext = createContext<NsitePlayerState>({
|
||||
activeSubdomain: null,
|
||||
setActiveSubdomain: () => {},
|
||||
});
|
||||
|
||||
/** Hook to read/write the active nsite player subdomain. */
|
||||
export function useNsitePlayer(): NsitePlayerState {
|
||||
return useContext(NsitePlayerContext);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { DITTO_RELAYS } from '@/lib/appRelays';
|
||||
|
||||
/** Curated kinds for the Ditto feed: unique Ditto content types. */
|
||||
const CURATED_KINDS = [
|
||||
20, // Photos (NIP-68)
|
||||
21, // Videos (NIP-71)
|
||||
22, // Short Videos (NIP-71)
|
||||
34236, // Divines (addressable short videos)
|
||||
36787, // Music Tracks
|
||||
34139, // Music Playlists
|
||||
36767, // Themes
|
||||
37381, // Magic Decks
|
||||
3367, // Color Moments
|
||||
37516, // Treasures
|
||||
7516, // Treasures (Found Logs)
|
||||
30030, // Emoji Packs
|
||||
30009, // Badge Definitions
|
||||
10008, // Profile Badges
|
||||
30008, // Profile Badges (legacy)
|
||||
31124, // Blobbi
|
||||
];
|
||||
|
||||
/** Webxdc needs a MIME-type tag filter, so it gets its own filter object. */
|
||||
const WEBXDC_FILTER = { kinds: [1063], '#m': ['application/x-webxdc'] };
|
||||
|
||||
/**
|
||||
* Compute a short fingerprint of a string array for use in query keys.
|
||||
* Produces a stable, content-dependent value so the query busts when
|
||||
* the actual pubkey set changes (not just its length).
|
||||
*/
|
||||
function fingerprint(items: string[]): string {
|
||||
// Simple djb2-style hash — fast and collision-resistant enough for a cache key.
|
||||
let hash = 5381;
|
||||
for (const item of items) {
|
||||
for (let i = 0; i < item.length; i++) {
|
||||
hash = ((hash << 5) + hash + item.charCodeAt(i)) | 0;
|
||||
}
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated Ditto feed: latest content from the curator's follow list.
|
||||
* Standard NIP-01 reverse-chronological pagination (no sort:hot).
|
||||
*
|
||||
* @param authors - Pubkeys whose content to include (from useCuratorFollowList).
|
||||
* @param enabled - Whether the query should run.
|
||||
*/
|
||||
export function useCuratedDittoFeed(authors: string[] | undefined, enabled: boolean) {
|
||||
const { nostr } = useNostr();
|
||||
const authorsKey = authors ? fingerprint(authors) : '';
|
||||
|
||||
return useInfiniteQuery<NostrEvent[], Error>({
|
||||
queryKey: ['ditto-curated-feed', authorsKey],
|
||||
queryFn: async ({ pageParam, signal }) => {
|
||||
const base: Record<string, unknown> = {
|
||||
kinds: CURATED_KINDS,
|
||||
authors,
|
||||
limit: 20,
|
||||
};
|
||||
if (pageParam) base.until = pageParam;
|
||||
|
||||
// Webxdc needs a separate filter with MIME-type tag constraint
|
||||
const webxdcFilter: Record<string, unknown> = {
|
||||
...WEBXDC_FILTER,
|
||||
authors,
|
||||
limit: 20,
|
||||
};
|
||||
if (pageParam) webxdcFilter.until = pageParam;
|
||||
|
||||
const ditto = nostr.group(DITTO_RELAYS);
|
||||
return ditto.query(
|
||||
[base, webxdcFilter] as Parameters<typeof ditto.query>[0],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
|
||||
);
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.length === 0) return undefined;
|
||||
return lastPage[lastPage.length - 1].created_at - 1;
|
||||
},
|
||||
initialPageParam: undefined as number | undefined,
|
||||
enabled: enabled && !!authors && authors.length > 0,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-export for use in Feed.tsx landing hero / kind lists. */
|
||||
export { CURATED_KINDS, WEBXDC_FILTER };
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
/** localStorage key for cached curator follow list. */
|
||||
const CACHE_KEY = 'ditto:curatorFollowList';
|
||||
|
||||
/** Read cached curator follow list from localStorage. */
|
||||
function getCached(): string[] | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
if (!raw) return undefined;
|
||||
const cached = JSON.parse(raw);
|
||||
if (!Array.isArray(cached)) return undefined;
|
||||
return cached;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist curator follow list to localStorage. */
|
||||
function setCached(pubkeys: string[]): void {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(pubkeys));
|
||||
} catch {
|
||||
// Storage full or unavailable — non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the follow list (kind 3 `p` tags) for the curator pubkey.
|
||||
* Returns the curator's pubkey + all pubkeys they follow.
|
||||
* Cached in localStorage for instant display on return visits.
|
||||
*
|
||||
* The curator pubkey is read from `config.curatorPubkey`. When unset the
|
||||
* hook is disabled and returns `undefined`.
|
||||
*/
|
||||
export function useCuratorFollowList() {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const curatorPubkey = config.curatorPubkey;
|
||||
|
||||
return useQuery<string[]>({
|
||||
queryKey: ['curator-follow-list', curatorPubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!curatorPubkey) return [];
|
||||
|
||||
const [event] = await nostr.query(
|
||||
[{ kinds: [3], authors: [curatorPubkey], limit: 1 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
|
||||
);
|
||||
if (!event) return [curatorPubkey];
|
||||
|
||||
const pubkeys = event.tags
|
||||
.filter(([name]) => name === 'p')
|
||||
.map(([, pk]) => pk);
|
||||
|
||||
// Include the curator themselves
|
||||
const allPubkeys = [...new Set([curatorPubkey, ...pubkeys])];
|
||||
setCached(allPubkeys);
|
||||
return allPubkeys;
|
||||
},
|
||||
enabled: !!curatorPubkey,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
gcTime: 60 * 60 * 1000, // 1 hour
|
||||
placeholderData: getCached(),
|
||||
});
|
||||
}
|
||||
@@ -59,6 +59,8 @@ export interface EncryptedSettings {
|
||||
contentWarningPolicy?: ContentWarningPolicy;
|
||||
/** Whether the user has enabled push notifications */
|
||||
notificationsEnabled?: boolean;
|
||||
/** Notification delivery style on native: 'push' (default) or 'persistent' (foreground service) */
|
||||
notificationStyle?: 'push' | 'persistent';
|
||||
/** Timestamp of last viewed notification (Unix timestamp in seconds) */
|
||||
notificationsCursor?: number;
|
||||
/** Per-type notification preferences (all default to true/enabled) */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type FeedSettings } from "@/contexts/AppContext";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useEncryptedSettings } from "@/hooks/useEncryptedSettings";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { SIDEBAR_ITEMS, SIDEBAR_ITEM_IDS, SIDEBAR_DIVIDER_ID, isNostrUri, isExternalUri } from "@/lib/sidebarItems";
|
||||
import { SIDEBAR_ITEMS, SIDEBAR_ITEM_IDS, SIDEBAR_DIVIDER_ID, isNostrUri, isExternalUri, isNsiteUri } from "@/lib/sidebarItems";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
// ── Order computation ─────────────────────────────────────────────────────────
|
||||
@@ -49,7 +49,7 @@ function computeOrderedItems(
|
||||
if (seen.has(item)) continue;
|
||||
seen.add(item);
|
||||
|
||||
if (SIDEBAR_ITEM_IDS.has(item) || isNostrUri(item) || isExternalUri(item)) {
|
||||
if (SIDEBAR_ITEM_IDS.has(item) || isNostrUri(item) || isExternalUri(item) || isNsiteUri(item)) {
|
||||
ordered.push(item);
|
||||
}
|
||||
// else: unknown entry — skip
|
||||
|
||||
@@ -36,6 +36,7 @@ export function useHasUnreadNotifications(): boolean {
|
||||
const { data: followData } = useFollowList();
|
||||
|
||||
const prefs = settings?.notificationPreferences;
|
||||
const notificationStyle = settings?.notificationStyle ?? 'push';
|
||||
|
||||
// Derive enabled kinds from preferences so disabled types don't trigger the dot
|
||||
const enabledKinds = useMemo(
|
||||
@@ -77,7 +78,9 @@ export function useHasUnreadNotifications(): boolean {
|
||||
return events.some((e) => e.pubkey !== user.pubkey);
|
||||
},
|
||||
enabled: !!user && notificationsCursor !== null,
|
||||
refetchInterval: Capacitor.isNativePlatform() ? false : 60_000,
|
||||
// Disable polling on native only when using persistent mode (foreground service
|
||||
// handles it). In push mode on native, poll like web since there's no service.
|
||||
refetchInterval: Capacitor.isNativePlatform() && notificationStyle === 'persistent' ? false : 60_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getEnabledNotificationKinds } from '@/lib/notificationKinds';
|
||||
|
||||
/** Interface for the native DittoNotification Capacitor plugin. */
|
||||
interface DittoNotificationPlugin {
|
||||
configure(options: { userPubkey?: string; relayUrls?: string[]; enabledKinds?: number[]; authors?: string[] }): Promise<void>;
|
||||
configure(options: { userPubkey?: string; relayUrls?: string[]; enabledKinds?: number[]; authors?: string[]; notificationStyle?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
const DittoNotification = registerPlugin<DittoNotificationPlugin>('DittoNotification');
|
||||
@@ -36,6 +36,7 @@ export function useNativeNotifications(): void {
|
||||
|
||||
const prefs = settings?.notificationPreferences;
|
||||
const notificationsEnabled = settings?.notificationsEnabled ?? true;
|
||||
const notificationStyle = settings?.notificationStyle ?? 'push';
|
||||
const enabledKinds = useMemo(
|
||||
() => getEnabledNotificationKinds(prefs),
|
||||
[prefs],
|
||||
@@ -87,7 +88,8 @@ export function useNativeNotifications(): void {
|
||||
userPubkey: user.pubkey,
|
||||
relayUrls,
|
||||
enabledKinds,
|
||||
notificationStyle,
|
||||
...(authorsFilter ? { authors: authorsFilter } : {}),
|
||||
});
|
||||
}, [user, config.relayMetadata, config.useAppRelays, notificationsEnabled, enabledKinds, authorsFilter]);
|
||||
}, [user, config.relayMetadata, config.useAppRelays, notificationsEnabled, notificationStyle, enabledKinds, authorsFilter]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Hook that provides a JSON-RPC handler for proxying NIP-07 signer calls
|
||||
* from a sandboxed nsite iframe to the parent user's signer.
|
||||
*
|
||||
* Each `nostr.*` RPC method is gated by the permission system. If no
|
||||
* stored decision exists, a prompt is shown to the user. Prompts are
|
||||
* serialized (one at a time) to prevent overwhelming the user.
|
||||
*/
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
getNsitePermission,
|
||||
setNsitePermission,
|
||||
type NsitePermissionType,
|
||||
} from '@/lib/nsitePermissions';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Describes a pending permission prompt waiting for the user's decision. */
|
||||
export interface NsitePromptState {
|
||||
/** The permission type being requested. */
|
||||
type: NsitePermissionType;
|
||||
/** For signEvent: the event kind. Null otherwise. */
|
||||
kind: number | null;
|
||||
/** For signEvent: the unsigned event template. */
|
||||
event?: Record<string, unknown>;
|
||||
/** For encrypt/decrypt: the target pubkey. */
|
||||
targetPubkey?: string;
|
||||
}
|
||||
|
||||
/** The user's response to a permission prompt. */
|
||||
export interface NsitePromptDecision {
|
||||
/** Whether the operation is allowed. */
|
||||
allowed: boolean;
|
||||
/** Whether to remember this decision. */
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
interface UseNsiteSignerRpcOptions {
|
||||
/** Canonical nsite subdomain identifier. */
|
||||
siteId: string;
|
||||
/** Human-readable site name for storage. */
|
||||
siteName: string;
|
||||
}
|
||||
|
||||
interface UseNsiteSignerRpcResult {
|
||||
/** The `onRpc` callback to pass to SandboxFrame. */
|
||||
onRpc: (
|
||||
method: string,
|
||||
params: unknown,
|
||||
post: (msg: Record<string, unknown>) => void,
|
||||
) => Promise<unknown>;
|
||||
/** Current pending prompt, or null if no prompt is active. */
|
||||
pendingPrompt: NsitePromptState | null;
|
||||
/** Call this to resolve the current prompt. */
|
||||
resolvePrompt: (decision: NsitePromptDecision) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useNsiteSignerRpc({
|
||||
siteId,
|
||||
siteName,
|
||||
}: UseNsiteSignerRpcOptions): UseNsiteSignerRpcResult {
|
||||
const { user } = useCurrentUser();
|
||||
const [pendingPrompt, setPendingPrompt] = useState<NsitePromptState | null>(null);
|
||||
|
||||
// Ref to the resolve/reject pair for the current prompt, so the prompt UI
|
||||
// can resolve it without a stale closure.
|
||||
const promptResolverRef = useRef<{
|
||||
resolve: (decision: NsitePromptDecision) => void;
|
||||
reject: (err: Error) => void;
|
||||
} | null>(null);
|
||||
|
||||
/**
|
||||
* Show a permission prompt and wait for the user's decision.
|
||||
* Only one prompt is active at a time (enforced by the injected script's
|
||||
* serial queue — it only sends one RPC at a time).
|
||||
*/
|
||||
const showPrompt = useCallback(
|
||||
(state: NsitePromptState): Promise<NsitePromptDecision> => {
|
||||
return new Promise<NsitePromptDecision>((resolve, reject) => {
|
||||
promptResolverRef.current = { resolve, reject };
|
||||
setPendingPrompt(state);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Resolve the current prompt with the user's decision. */
|
||||
const resolvePrompt = useCallback(
|
||||
(decision: NsitePromptDecision) => {
|
||||
if (promptResolverRef.current) {
|
||||
promptResolverRef.current.resolve(decision);
|
||||
promptResolverRef.current = null;
|
||||
}
|
||||
setPendingPrompt(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Check permission and optionally prompt. Returns true if allowed.
|
||||
* Throws an error (with a user-facing message) if denied.
|
||||
*/
|
||||
const checkPermission = useCallback(
|
||||
async (
|
||||
type: NsitePermissionType,
|
||||
kind: number | null,
|
||||
promptState: NsitePromptState,
|
||||
): Promise<void> => {
|
||||
if (!user) throw new Error('Not logged in');
|
||||
|
||||
const stored = getNsitePermission(siteId, user.pubkey, type, kind);
|
||||
|
||||
if (stored === 'allow') return;
|
||||
if (stored === 'deny') throw new Error('User rejected');
|
||||
|
||||
// No stored decision — ask the user.
|
||||
const decision = await showPrompt(promptState);
|
||||
|
||||
if (decision.remember) {
|
||||
setNsitePermission(siteId, user.pubkey, siteName, type, kind, decision.allowed);
|
||||
}
|
||||
|
||||
if (!decision.allowed) {
|
||||
throw new Error('User rejected');
|
||||
}
|
||||
},
|
||||
[siteId, siteName, user, showPrompt],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RPC handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const onRpc = useCallback(
|
||||
async (
|
||||
method: string,
|
||||
params: unknown,
|
||||
): Promise<unknown> => {
|
||||
if (!user) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
|
||||
const signer = user.signer;
|
||||
const p = (params ?? {}) as Record<string, unknown>;
|
||||
|
||||
switch (method) {
|
||||
// ------------------------------------------------------------------
|
||||
// getPublicKey — always allowed
|
||||
// ------------------------------------------------------------------
|
||||
case 'nostr.getPublicKey': {
|
||||
return user.pubkey;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// signEvent — permission gated per kind
|
||||
// ------------------------------------------------------------------
|
||||
case 'nostr.signEvent': {
|
||||
const event = p.event as Record<string, unknown> | undefined;
|
||||
if (!event || typeof event.kind !== 'number') {
|
||||
throw new Error('Invalid event');
|
||||
}
|
||||
|
||||
const kind = event.kind as number;
|
||||
|
||||
await checkPermission('signEvent', kind, {
|
||||
type: 'signEvent',
|
||||
kind,
|
||||
event,
|
||||
});
|
||||
|
||||
// Build the event template the signer expects.
|
||||
const template = {
|
||||
kind: event.kind as number,
|
||||
content: (event.content as string) ?? '',
|
||||
tags: (event.tags as string[][]) ?? [],
|
||||
created_at: (event.created_at as number) ?? Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const signed = await signer.signEvent(template);
|
||||
return signed;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// NIP-04 encryption
|
||||
// ------------------------------------------------------------------
|
||||
case 'nostr.nip04.encrypt': {
|
||||
if (!signer.nip04) throw new Error('Signer does not support NIP-04');
|
||||
|
||||
const pubkey = p.pubkey as string;
|
||||
const plaintext = p.plaintext as string;
|
||||
if (!pubkey || typeof plaintext !== 'string') {
|
||||
throw new Error('Invalid params');
|
||||
}
|
||||
|
||||
await checkPermission('nip04.encrypt', null, {
|
||||
type: 'nip04.encrypt',
|
||||
kind: null,
|
||||
targetPubkey: pubkey,
|
||||
});
|
||||
|
||||
return await signer.nip04.encrypt(pubkey, plaintext);
|
||||
}
|
||||
|
||||
case 'nostr.nip04.decrypt': {
|
||||
if (!signer.nip04) throw new Error('Signer does not support NIP-04');
|
||||
|
||||
const pubkey = p.pubkey as string;
|
||||
const ciphertext = p.ciphertext as string;
|
||||
if (!pubkey || typeof ciphertext !== 'string') {
|
||||
throw new Error('Invalid params');
|
||||
}
|
||||
|
||||
await checkPermission('nip04.decrypt', null, {
|
||||
type: 'nip04.decrypt',
|
||||
kind: null,
|
||||
targetPubkey: pubkey,
|
||||
});
|
||||
|
||||
return await signer.nip04.decrypt(pubkey, ciphertext);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// NIP-44 encryption
|
||||
// ------------------------------------------------------------------
|
||||
case 'nostr.nip44.encrypt': {
|
||||
if (!signer.nip44) throw new Error('Signer does not support NIP-44');
|
||||
|
||||
const pubkey = p.pubkey as string;
|
||||
const plaintext = p.plaintext as string;
|
||||
if (!pubkey || typeof plaintext !== 'string') {
|
||||
throw new Error('Invalid params');
|
||||
}
|
||||
|
||||
await checkPermission('nip44.encrypt', null, {
|
||||
type: 'nip44.encrypt',
|
||||
kind: null,
|
||||
targetPubkey: pubkey,
|
||||
});
|
||||
|
||||
return await signer.nip44.encrypt(pubkey, plaintext);
|
||||
}
|
||||
|
||||
case 'nostr.nip44.decrypt': {
|
||||
if (!signer.nip44) throw new Error('Signer does not support NIP-44');
|
||||
|
||||
const pubkey = p.pubkey as string;
|
||||
const ciphertext = p.ciphertext as string;
|
||||
if (!pubkey || typeof ciphertext !== 'string') {
|
||||
throw new Error('Invalid params');
|
||||
}
|
||||
|
||||
await checkPermission('nip44.decrypt', null, {
|
||||
type: 'nip44.decrypt',
|
||||
kind: null,
|
||||
targetPubkey: pubkey,
|
||||
});
|
||||
|
||||
return await signer.nip44.decrypt(pubkey, ciphertext);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Method not found: ${method}`);
|
||||
}
|
||||
},
|
||||
[user, checkPermission],
|
||||
);
|
||||
|
||||
return { onRpc, pendingPrompt, resolvePrompt };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useCallback, type RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface DropdownPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
interface UsePortalDropdownOptions {
|
||||
/** Ref to the textarea the dropdown is anchored to. */
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
/** Whether the dropdown is currently visible. */
|
||||
isOpen: boolean;
|
||||
/** Callback to close the dropdown (e.g. on scroll/resize). */
|
||||
onClose: () => void;
|
||||
/** Max height of the dropdown in px (must match the CSS max-h value). */
|
||||
dropdownHeight: number;
|
||||
/** Width of the dropdown in px (must match the CSS width value). */
|
||||
dropdownWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes fixed viewport coordinates for an autocomplete dropdown anchored
|
||||
* to a caret position inside a textarea. The dropdown is positioned below
|
||||
* the caret line, or flipped above if it would overflow the viewport bottom.
|
||||
*
|
||||
* Also dismisses the dropdown on scroll or resize, since fixed positioning
|
||||
* would cause misalignment.
|
||||
*
|
||||
* Use `renderPortal` to render the dropdown as a portal to `document.body`
|
||||
* so it escapes ancestor overflow clipping and CSS transform containing
|
||||
* blocks (e.g. Radix Dialog).
|
||||
*/
|
||||
export function usePortalDropdown({
|
||||
textareaRef,
|
||||
isOpen,
|
||||
onClose,
|
||||
dropdownHeight,
|
||||
dropdownWidth = 280,
|
||||
}: UsePortalDropdownOptions) {
|
||||
|
||||
/** Compute fixed viewport position for the dropdown given a caret index. */
|
||||
const computePosition = useCallback(
|
||||
(caretCoords: { top: number; left: number }): DropdownPosition => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return { top: 0, left: 0 };
|
||||
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const top = rect.top + caretCoords.top - textarea.scrollTop + lineHeight + 4;
|
||||
const left = rect.left + Math.max(0, Math.min(caretCoords.left, textarea.clientWidth - dropdownWidth));
|
||||
|
||||
// If the dropdown would overflow the bottom of the viewport, flip above
|
||||
const flippedTop = rect.top + caretCoords.top - textarea.scrollTop - dropdownHeight - 4;
|
||||
const useFlipped = top + dropdownHeight > window.innerHeight && flippedTop > 0;
|
||||
|
||||
return {
|
||||
top: useFlipped ? flippedTop : top,
|
||||
left: Math.max(8, Math.min(left, window.innerWidth - dropdownWidth - 8)),
|
||||
};
|
||||
},
|
||||
[textareaRef, dropdownHeight, dropdownWidth],
|
||||
);
|
||||
|
||||
// Dismiss the dropdown when any ancestor scrolls or the window resizes,
|
||||
// since fixed positioning would cause the dropdown to become misaligned.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleDismiss = () => onClose();
|
||||
window.addEventListener('scroll', handleDismiss, true);
|
||||
window.addEventListener('resize', handleDismiss);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleDismiss, true);
|
||||
window.removeEventListener('resize', handleDismiss);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return { computePosition, renderPortal: createPortal };
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
export interface UserStatus {
|
||||
/** The status text, or null if no status / expired / cleared. */
|
||||
status: string | null;
|
||||
@@ -44,7 +46,7 @@ export function useUserStatus(pubkey: string | undefined): UserStatus & { isLoad
|
||||
const content = event.content.trim();
|
||||
if (!content) return { status: null, url: null };
|
||||
|
||||
const url = event.tags.find(([n]) => n === 'r')?.[1] ?? null;
|
||||
const url = sanitizeUrl(event.tags.find(([n]) => n === 'r')?.[1]) ?? null;
|
||||
|
||||
return { status: content, url };
|
||||
},
|
||||
|
||||
+22
-16
@@ -34,37 +34,43 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* ── Safe-area inset utilities ────────────────────────────────────────────
|
||||
Use var(--safe-area-inset-*, …) as the outer wrapper so that
|
||||
Capacitor's SystemBars plugin (which injects --safe-area-inset-* CSS
|
||||
variables on Android) takes precedence when available. The inner
|
||||
env(safe-area-inset-*, 0px) is the standard fallback for iOS / web. */
|
||||
|
||||
.safe-area-top {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.safe-area-inset-top {
|
||||
top: env(safe-area-inset-top, 0px);
|
||||
top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
.safe-area-inset-bottom {
|
||||
bottom: env(safe-area-inset-bottom, 0px);
|
||||
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* FAB bottom offset: clears bottom nav + safe area inset on mobile */
|
||||
.bottom-fab {
|
||||
bottom: calc(1.5rem + var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
bottom: calc(1.5rem + var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
/* Position above mobile bottom nav + safe area + arc overhang (28px) */
|
||||
.bottom-mobile-nav {
|
||||
bottom: calc(var(--bottom-nav-height) + 28px + env(safe-area-inset-bottom, 0px));
|
||||
bottom: calc(var(--bottom-nav-height) + 28px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
/* Bottom overscroll padding for the center column:
|
||||
clears the mobile bottom nav + safe area + generous extra space
|
||||
so content can be scrolled well past the bottom bar */
|
||||
.pb-overscroll {
|
||||
padding-bottom: calc(10vh + var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: calc(10vh + var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
@@ -75,12 +81,12 @@
|
||||
|
||||
/* Mobile top bar height + safe area inset for sticky elements */
|
||||
.top-mobile-bar {
|
||||
top: calc(var(--top-bar-height) + env(safe-area-inset-top, 0px));
|
||||
top: calc(var(--top-bar-height) + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
|
||||
}
|
||||
|
||||
/* New-posts pill: just below the SubHeaderBar on both mobile and desktop */
|
||||
.new-posts-pill {
|
||||
top: calc(var(--top-bar-height) + env(safe-area-inset-top, 0px) + 3.5rem);
|
||||
top: calc(var(--top-bar-height) + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 3.5rem);
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.new-posts-pill {
|
||||
@@ -94,29 +100,29 @@
|
||||
Must clear its own height (100%) + top bar + safe area + arc overhang (20px). */
|
||||
@media (max-width: 899px) {
|
||||
.nav-hidden-slide {
|
||||
transform: translateY(calc(-100% - var(--top-bar-height) - 20px - env(safe-area-inset-top, 0px)));
|
||||
transform: translateY(calc(-100% - var(--top-bar-height) - 20px - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))));
|
||||
}
|
||||
}
|
||||
|
||||
/* Negative margin to pull content area up behind the mobile top bar (only when it's visible) */
|
||||
@media (max-width: 899px) {
|
||||
.-mt-mobile-bar {
|
||||
margin-top: calc(-1 * var(--top-bar-height) - env(safe-area-inset-top, 0px));
|
||||
padding-top: calc(var(--top-bar-height) + env(safe-area-inset-top, 0px));
|
||||
margin-top: calc(-1 * var(--top-bar-height) - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
|
||||
padding-top: calc(var(--top-bar-height) + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
|
||||
}
|
||||
}
|
||||
|
||||
/* AI chat height on mobile: full viewport minus top bar, extends behind bottom nav.
|
||||
Padding-bottom keeps input above the nav. */
|
||||
.ai-chat-height {
|
||||
height: calc(100dvh - var(--top-bar-height) - env(safe-area-inset-top, 0px));
|
||||
padding-bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
height: calc(100dvh - var(--top-bar-height) - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
|
||||
padding-bottom: calc(var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
/* Live stream page height on mobile: full viewport minus top bar, bottom nav, and safe-area insets */
|
||||
.livestream-height {
|
||||
height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - env(safe-area-inset-bottom, 0px));
|
||||
max-height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - env(safe-area-inset-bottom, 0px));
|
||||
height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
max-height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
/* Vine feed slide height: full viewport on mobile (top bar + bottom nav are
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Detects when a web app returns to the foreground after being backgrounded,
|
||||
* primarily to work around Android's WebSocket zombie connection problem.
|
||||
*
|
||||
* Android aggressively throttles backgrounded tabs, causing WebSocket connections
|
||||
* to silently miss events without triggering close/error handlers. This utility
|
||||
* detects the resume and reports how long the app was in the background, so
|
||||
* callers can force reconnection or re-query missed data.
|
||||
*
|
||||
* Framework-agnostic — no React dependency. Can be used in libraries.
|
||||
*/
|
||||
|
||||
export interface AndroidResumeOptions {
|
||||
/** Minimum background duration (ms) before triggering. Default: 0 */
|
||||
threshold?: number;
|
||||
/** Called when the app returns to foreground after exceeding the threshold. */
|
||||
onResume?: (backgroundDurationMs: number) => void;
|
||||
/**
|
||||
* If true, only activates on Android user agents.
|
||||
* Set to false to test on desktop. Default: true
|
||||
*/
|
||||
androidOnly?: boolean;
|
||||
}
|
||||
|
||||
function isAndroid(): boolean {
|
||||
return typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export function androidResume(options: AndroidResumeOptions = {}): { destroy: () => void } {
|
||||
const { threshold = 0, onResume, androidOnly = true } = options;
|
||||
const noop = { destroy: () => {} };
|
||||
|
||||
// No-op in non-browser environments (e.g. Node.js, Deno without DOM).
|
||||
if (typeof document === 'undefined') return noop;
|
||||
|
||||
if (androidOnly && !isAndroid()) return noop;
|
||||
|
||||
let hiddenAt: number | null = null;
|
||||
|
||||
const handler = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
hiddenAt = Date.now();
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
if (hiddenAt === null) return;
|
||||
const duration = Date.now() - hiddenAt;
|
||||
hiddenAt = null;
|
||||
if (duration >= threshold) {
|
||||
onResume?.(duration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handler);
|
||||
return {
|
||||
destroy: () => {
|
||||
document.removeEventListener('visibilitychange', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Utility for storing and retrieving Nostr secret keys using the platform's
|
||||
* native credential / password manager.
|
||||
*
|
||||
* - **Capacitor iOS**: Uses `@capgo/capacitor-autofill-save-password` which
|
||||
* calls `SecAddSharedWebCredential` / `SecRequestSharedWebCredential` under
|
||||
* the hood, triggering the iCloud Keychain "Save Password" / credential
|
||||
* picker UI. Requires the `webcredentials:` Associated Domains entitlement
|
||||
* and a matching `apple-app-site-association` file on the domain.
|
||||
*
|
||||
* - **Chromium browsers** (Chrome, Edge, Opera, Android WebView): Uses the
|
||||
* `PasswordCredential` API to trigger the native "Save password?" prompt.
|
||||
*
|
||||
* - **Other browsers** (Safari web, Firefox): Silently falls back — all
|
||||
* functions return `false` / `undefined` without error.
|
||||
*/
|
||||
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { SavePassword } from '@capgo/capacitor-autofill-save-password';
|
||||
|
||||
import { downloadTextFile } from '@/lib/downloadFile';
|
||||
|
||||
/** The domain used for Shared Web Credentials on iOS. */
|
||||
const CREDENTIAL_DOMAIN = 'ditto.pub';
|
||||
|
||||
/** Whether the browser supports PasswordCredential (Chromium-only). */
|
||||
export function supportsPasswordCredential(): boolean {
|
||||
return typeof window !== 'undefined' && 'PasswordCredential' in window;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Store a Nostr secret key in the platform's credential manager.
|
||||
*
|
||||
* On Capacitor iOS this triggers the iCloud Keychain "Save Password?" sheet.
|
||||
* On Chromium browsers this triggers the native "Save password?" prompt.
|
||||
* On unsupported platforms this is a silent no-op.
|
||||
*
|
||||
* @param npub - The user's npub (used as the credential username / account)
|
||||
* @param nsec - The user's nsec (used as the credential password)
|
||||
* @param name - Optional display name (Chromium only — shown in the picker)
|
||||
* @returns `true` if the credential was stored, `false` if unsupported or rejected
|
||||
*/
|
||||
export async function storeNsecCredential(
|
||||
npub: string,
|
||||
nsec: string,
|
||||
name?: string,
|
||||
): Promise<boolean> {
|
||||
// Capacitor native path (iOS / Android).
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
await SavePassword.promptDialog({
|
||||
username: npub,
|
||||
password: nsec,
|
||||
url: CREDENTIAL_DOMAIN,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Chromium PasswordCredential path (web).
|
||||
if (!supportsPasswordCredential()) return false;
|
||||
|
||||
try {
|
||||
const credential = new PasswordCredential({
|
||||
id: npub,
|
||||
password: nsec,
|
||||
name: name ?? npub,
|
||||
});
|
||||
|
||||
await navigator.credentials.store(credential);
|
||||
return true;
|
||||
} catch {
|
||||
// User dismissed, or browser blocked the call (e.g. non-HTTPS, iframe).
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a previously-stored Nostr credential from the platform's
|
||||
* password manager.
|
||||
*
|
||||
* On Capacitor iOS this shows the iCloud Keychain credential picker.
|
||||
* On Chromium browsers this shows the native credential picker.
|
||||
*
|
||||
* @returns The stored credential, or `undefined` if unavailable / dismissed.
|
||||
*/
|
||||
export async function getNsecCredential(): Promise<
|
||||
{ npub: string; nsec: string } | undefined
|
||||
> {
|
||||
// Capacitor native path (iOS / Android).
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
const result = await SavePassword.readPassword();
|
||||
if (result.username && result.password) {
|
||||
return { npub: result.username, nsec: result.password };
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Chromium PasswordCredential path (web).
|
||||
if (!supportsPasswordCredential()) return undefined;
|
||||
|
||||
try {
|
||||
const credential = await navigator.credentials.get({
|
||||
password: true,
|
||||
mediation: 'optional',
|
||||
} as CredentialRequestOptions);
|
||||
|
||||
if (credential && 'password' in credential) {
|
||||
const pc = credential as PasswordCredential;
|
||||
if (pc.id && pc.password) {
|
||||
return { npub: pc.id, nsec: pc.password };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a Nostr secret key using the best method available on the platform.
|
||||
*
|
||||
* - **Native (iOS / Android)**: Prompts the credential manager
|
||||
* (iCloud Keychain / Google). Throws if the user dismisses so the caller
|
||||
* can block progression and retry.
|
||||
*
|
||||
* - **Web**: Downloads the key as a `.nsec.txt` file (always), and also
|
||||
* attempts to store it via `PasswordCredential` as a bonus (Chromium).
|
||||
* The bonus store is fire-and-forget — it never blocks or throws.
|
||||
*
|
||||
* @param npub - The user's npub (credential username / account)
|
||||
* @param nsec - The user's nsec (credential password)
|
||||
* @param name - Optional display name (Chromium only)
|
||||
* @throws On native platforms if the user dismisses the credential prompt.
|
||||
*/
|
||||
export async function saveNsec(
|
||||
npub: string,
|
||||
nsec: string,
|
||||
name?: string,
|
||||
): Promise<void> {
|
||||
// Native: credential manager is the sole save mechanism.
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const saved = await storeNsecCredential(npub, nsec, name);
|
||||
if (!saved) {
|
||||
throw new Error('Credential save was dismissed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Web: always download the file as the primary save mechanism.
|
||||
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
|
||||
await downloadTextFile(filename, nsec);
|
||||
|
||||
// Bonus: also try to store in the browser's password manager (Chromium).
|
||||
storeNsecCredential(npub, nsec, name).catch(() => {});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user