Compare commits

...

66 Commits

Author SHA1 Message Date
Alex Gleason f1daf61a65 Add diagnostics to sbx:// scheme handler to verify if it receives calls 2026-04-12 19:41:57 -05:00
Alex Gleason 02cd63dbd9 Fix iOS sandbox: use sbx:// custom scheme registered at WKWebView config time
Replace the swizzle-based approach with a proper WKURLSchemeHandler
registered via DittoBridgeViewController.webViewConfiguration(for:).
This runs before the WKWebView is created, so sbx:// iframe requests
are intercepted correctly — unlike the previous attempt which
registered the handler too late (in capacitorDidLoad).

Each sandbox loads from sbx://<sandbox-id>/path, giving every sandbox
a unique web origin with full localStorage/cookie isolation.
2026-04-12 19:34:59 -05:00
Alex Gleason 56d65be19a Add swizzle call counter and last URL to diagnose() output 2026-04-12 19:08:59 -05:00
Alex Gleason d23db1e5c2 Add diagnose() calls around iframe.src to debug swizzle on iOS 2026-04-12 19:06:50 -05:00
Alex Gleason 0416b20a46 Fix iOS sandbox: use capacitor:// scheme with swizzled asset handler
Root cause: WKWebView does not invoke WKURLSchemeHandler for iframe
loads when the iframe uses a different scheme than the parent page.
The app uses capacitor:// (iosScheme: 'https' is normalised to
capacitor:// by Capacitor since https is a built-in scheme), but
sandbox iframes were using sbx:// — a cross-scheme load that WebKit
silently ignores.

Fix: Sandbox iframes now use capacitor://<id>.sandbox.local/path
(same scheme as the parent app). Capacitor's WebViewAssetHandler is
the sole handler for the capacitor:// scheme, so we swizzle its
webView(_:start:) and webView(_:stop:) methods at runtime to
intercept requests whose hostname ends with .sandbox.local. These
are forwarded to the JS layer for resolution. All other requests
pass through to the original Capacitor implementation.

This removes the standalone IframeSandboxSchemeHandler and the sbx
scheme registration in DittoBridgeViewController. The swizzle is
installed in SandboxPlugin.load(), keeping the architecture simple:
one plugin, one request handler, no separate scheme.

Also cleans up diagnostic logging from the investigation, retaining
only the diagnose() plugin method for future debugging.
2026-04-12 19:03:42 -05:00
Alex Gleason eae9c1d00d Add diagnose() plugin method to inspect native sandbox state from JS
Since Swift.print/NSLog/CAPLog.print output is not visible in Xcode's
console when running on a physical device, add a diagnose() method to
SandboxPlugin that JS can call to get the native state back through
the Capacitor bridge (which we know works).

Returns: schemeHandlerSet, pluginConnected, schemeHandlerHasWebView,
bridgeHasWebView, hasListenersFetch, pendingTaskCount.

Called from SandboxFrameNative before and 1s after setting iframe.src
so we can see the full native state in the JS console log stream.
2026-04-12 18:46:41 -05:00
Alex Gleason 72cbc871f5 Use Swift.print instead of CAPLog.print for diagnostic logging
CAPLog.print may be suppressed by enableLogging flag. Use raw
Swift.print for the baseline diagnostics so they always output to
stdout regardless of Capacitor's logging configuration.
2026-04-12 18:42:40 -05:00
Alex Gleason c848e8b51e Route native diagnostic logs through JS console via evaluateJavaScript
NSLog output was not visible in Xcode's console panel. Switch to
injecting log messages into the WKWebView's JS console via
evaluateJavaScript so they appear in the same Capacitor log stream
(prefixed with ️) that is already visible.

- DittoBridgeViewController: injects a diagnostic after 2s delay
- IframeSandboxSchemeHandler: captures webView ref, logs to JS console
- SandboxPlugin: logs to JS console via bridge.webView
- All three still also use CAPLog.print as fallback
2026-04-12 18:34:31 -05:00
Alex Gleason 30886bf9fa Add AppDelegate and capacitorDidLoad logging to verify NSLog visibility
Adds baseline NSLog calls in AppDelegate.didFinishLaunchingWithOptions
and DittoBridgeViewController.capacitorDidLoad to confirm whether NSLog
output is reaching the Xcode console at all. Also logs the state of
sharedSchemeHandler and its plugin reference after capacitorDidLoad.
2026-04-12 18:29:48 -05:00
Alex Gleason 156c5f5388 Add diagnostic logging to sandbox iframe pipeline for iOS debugging
Instrument every stage of the native sandbox iframe flow to pinpoint
why iOS shows a blank screen:

- DittoBridgeViewController: log scheme handler registration
- IframeSandboxSchemeHandler: log request interception, plugin ref
  status, pending task counts, response delivery, and base64 decode
- SandboxPlugin: log load/init, respondToFetch calls, event emission
  with hasListeners status
- SandboxFrameNative (React): log setup lifecycle, fetch events
  received/skipped, iframe src assignment, postMessage RPC, and
  iframe onLoad event

All logging uses NSLog on iOS (visible in Xcode console) and
console.log/error on the JS side (visible in Safari Web Inspector).
2026-04-12 18:21:15 -05:00
Alex Gleason e7d35c71c6 Allow clipboard-write in sandbox iframes 2026-04-12 18:10:08 -05:00
Alex Gleason a074e7c730 Fix nsite nav bar spacing on native by separating safe area from content padding 2026-04-12 18:08:35 -05:00
Alex Gleason 9c70c2b42b Restore nsite card pin button and auto-play state cleanup
Re-add the Pin/Unpin button on NsiteCard and the useEffect in
PostDetailPage that clears router state after consuming nsiteAutoPlay,
both lost during the sandbox refactor squash.
2026-04-12 17:54:44 -05:00
Alex Gleason 9822fd2a0b Replace native WebView overlay with iframe served by native code
On native platforms, sandbox content now renders in a regular <iframe>
instead of a separate native WebView overlaid on top of the Capacitor
web layer. This fixes permission prompts, popovers, and other web UI
being hidden behind the native WebView.

iOS: Register a single WKURLSchemeHandler for the 'sbx' scheme on the
main Capacitor WKWebView. Each sandbox iframe loads from
sbx://<sandbox-id>/path — different hostnames = different origins with
isolated localStorage/IndexedDB.

Android: Subclass BridgeWebViewClient to intercept requests to
*.sandbox.native from iframes in the main WebView. Same origin
isolation via hostname differentiation.

React: SandboxFrameNative now renders a real <iframe> element with
standard postMessage communication, eliminating the need for native
WebView creation, positioning, resize observation, and the native
bridge script injection. A loading spinner overlay is shown until the
iframe content loads.
2026-04-12 17:51:24 -05:00
Alex Gleason c1147063c6 Add nsite:// sidebar pinning with auto-launch, favicon, and highlight
Introduce a new sidebar item type for nsites that auto-opens the nsite
preview when clicked, using React Router state to prevent external URLs
from triggering auto-launch.

- Add isNsiteUri/nsiteUriToSubdomain helpers and parseNsiteSubdomain
- Create NsiteSidebarItem with site favicon, link preview title label
- Wire nsite:// dispatch in SidebarNavList, useFeedSettings, SidebarMoreMenu
- NoteMoreMenu pins named nsite events as nsite:// URIs instead of nostr: URIs
- NsiteCard accepts autoPlayKey prop; useEffect re-opens on repeated clicks
- NsitePlayerContext tracks active subdomain for sidebar highlighting
- Provided in MainLayout so sidebar and pages share state
2026-04-12 16:45:30 -05:00
Alex Gleason eacb0e4371 Show site favicon in nsite permission prompt instead of shield icon 2026-04-12 16:00:21 -05:00
Alex Gleason 647b3d414d Show site favicon in nsite preview nav bar instead of generic package icon 2026-04-12 15:58:25 -05:00
Alex Gleason ad7f053129 Centralize kind labels into src/lib/kindLabels.ts
Add a comprehensive KIND_LABELS registry covering all kinds from the NIP
README table, Ditto reference page, and existing codebase maps. Labels
use short user-facing names (e.g. "Photo" not "Picture", "App" not
"Handler information", "Zapstore app" not "Software application").

Consumers updated to import from the central registry:
- signerWithNudge.ts: falls through to central registry after overrides
- nsitePermissions.ts: removed local KIND_LABELS, re-exports getKindLabel
- ExternalContentHeader.tsx: removed WELL_KNOWN_KIND_LABELS
- PostDetailPage.tsx: shellTitleForKind falls through to central registry

AGENTS.md updated to document the central registry and its relationship
to context-specific maps (CommentContext, NotificationsPage, NoteCard).
2026-04-12 15:53:21 -05:00
Alex Gleason 075025bceb Inject NIP-07 signer into nsite previews with permission system
When a logged-in user runs an nsite, a window.nostr provider is injected
into the sandboxed iframe. The provider proxies signEvent, nip04, and
nip44 calls to the parent signer over the existing JSON-RPC postMessage
bridge.

A permission system gates each operation:
- getPublicKey is auto-allowed (clicking Run implies consent)
- signEvent prompts are granular per event kind (like Amber)
- encrypt/decrypt prompts are per operation type
- Users can check "Remember for this site" to persist decisions
- Permissions are scoped to (userPubkey, siteId) in localStorage

The nsite preview nav bar gains a shield icon that opens a popover for
managing stored permissions (toggle, remove, revoke all).

New files:
- src/lib/nsitePermissions.ts: permission model + persistence
- src/lib/nsiteNostrProvider.ts: injected NIP-07 provider script
- src/hooks/useNsiteSignerRpc.ts: RPC handler with permission gating
- src/components/NsitePermissionPrompt.tsx: approval dialog overlay
- src/components/NsitePermissionManager.tsx: permission management popover
2026-04-12 15:31:58 -05:00
Alex Gleason 32908f7b4f release: v2.6.6 2026-04-12 14:32:14 -05:00
Alex Gleason bd333b9584 Fix Android WebView resize bugs caused by @capacitor/keyboard
Remove resizeOnFullScreen config which caused possiblyResizeChildOfContent()
to corrupt CoordinatorLayout height on Android 16 (API 36). Upgrade plugin
from 8.0.2 to 8.0.3 which adds a SystemBars guard as additional safety.
Platform-gate setAccessoryBarVisible to iOS only (unimplemented on Android).
2026-04-12 14:07:52 -05:00
Alex Gleason 3ac1dc6b0a Fix dialog obscured by virtual keyboard on Android Chrome
Add interactive-widget=resizes-content to the viewport meta tag so
Chrome on Android resizes the layout viewport when the on-screen
keyboard opens. This keeps fixed-position dialogs (compose, reply,
login, etc.) centered in the visible area above the keyboard.
2026-04-12 13:21:21 -05:00
Alex Gleason 025ecd8645 Upgrade nostrify: improve NIP-46 signing reliability 2026-04-12 12:02:45 -05:00
Alex Gleason 0fca39a1bd Remove androidResume utility and its foreground-resume retry logic
The visibility-change-based Android resume detection was causing more
problems than it solved. Remove the module and simplify LoginDialog and
signerWithNudge to operate without retry-on-resume behavior.
2026-04-12 11:37:10 -05:00
Chad Curtis 3152f7f0ec Merge branch 'fix/emoji-shortcode-autocomplete' into 'main'
Fix emoji shortcode autocomplete clipped by compose box and emojis rendering as text

Closes #216

See merge request soapbox-pub/ditto!160
2026-04-12 14:13:32 +00:00
Alex Gleason 7cba044b9d release: v2.6.5 2026-04-11 18:15:04 -05:00
Alex Gleason 4245b2aede Add Google Play publishing to CI release pipeline 2026-04-11 18:10:29 -05:00
Alex Gleason 3cdec3ceb6 Add more Zapstore publish relays to CI 2026-04-11 17:57:13 -05:00
Alex Gleason aa8f7539ae Fix iOS App Store blockers: bundle PrivacyInfo.xcprivacy and declare export compliance 2026-04-11 17:55:26 -05:00
Alex Gleason c6b3cb8758 Remove server.hostname to fix external API requests on Android
The WebView was intercepting all https://ditto.pub/* requests as local
assets, causing favicon and link-preview API calls to fail. Deep links
are unaffected as they use AndroidManifest intent-filters.
2026-04-11 17:37:57 -05:00
Alex Gleason 59f68efdc7 iOS: replace HTML spinner with native UIActivityIndicatorView overlay
The HTML spinner loaded via loadHTMLString was immediately replaced by
the real navigation and never had a chance to render. This is the same
problem Android had with its HTML spinner (though for a different
reason — Android's froze due to main thread saturation).

Use a native UIActivityIndicatorView on a dark overlay, matching the
Android approach with ProgressBar. The spinner is added as a subview
on top of the WKWebView inside a container UIView, and removed in
webView(_:didFinish:) via WKNavigationDelegate.

Also wraps the WKWebView in a container UIView (like Android's
FrameLayout) so the spinner overlay can sit on top independently.
2026-04-11 17:25:06 -05:00
Alex Gleason dc81585f9a Pre-fetch all nsite blobs on Android before WebView navigates
Android's shouldInterceptRequest blocks a pool of ~6 IO threads, each
waiting for JS to respond via the Capacitor bridge. With 200+ files
each requiring a network round-trip to Blossom, loading is painfully
slow. iOS doesn't have this problem — WKURLSchemeHandler is async.

Split the native plugin lifecycle into create() and navigate():
- create() adds the WebView container with spinner overlay (visible)
- navigate() loads the entry URL (triggers fetch interception)

On Android, onReady downloads all manifest blobs in parallel (12
concurrent fetches) into an in-memory cache while the native
ProgressBar spinner animates. Once navigate() fires, every resolveFile
call is an instant cache hit.

On iOS/web, onReady is a no-op and navigate() fires immediately.
2026-04-11 17:20:21 -05:00
Alex Gleason 54e6c964db Add Blossom server affinity to speed up nsite loading
The fetchFromBlossom function previously tried servers sequentially for
every file request. For nsites without server tags (falling back to 3
app default servers), each of the 200+ files paid a full round-trip
penalty when the first server returned 404 before falling through.

Now tracks a module-level preferred server. Once any server successfully
serves a blob it becomes preferred and is tried first for all subsequent
requests. This means only the first file pays the discovery cost; the
rest go directly to the server that has the content.
2026-04-11 17:20:06 -05:00
Alex Gleason dceda199c3 Add loading spinners to native sandbox WebViews
iOS: load inline spinner HTML (centered spinning ring on dark background)
before navigating to the real content URL. Supports light/dark mode via
prefers-color-scheme. The spinner is replaced when the real page loads.

Android: use a native ProgressBar overlay instead of HTML — the HTML
spinner froze because constant Capacitor bridge calls saturated the
main thread, starving the WebView compositor. The native ProgressBar
animates on the render thread independently. Wrapped in a FrameLayout
with a dark overlay behind the spinner.

Both platforms: set WebView background to #14161f (app dark theme)
instead of white. Increased Android shouldInterceptRequest timeout
from 10s to 60s to prevent premature timeouts on large nsites.
2026-04-11 17:20:01 -05:00
Alex Gleason 8967012035 release: v2.6.4 2026-04-11 15:43:47 -05:00
Alex Gleason 0b73d4aac5 Remove dedicated Share button from profile pages
The 'Copy profile link' option is already available in the more menu,
making the standalone Share button redundant.
2026-04-11 15:40:08 -05:00
Alex Gleason 6f53f7ad99 Fix avatar fallback showing '?' instead of name initial
ComposeBox and LeftSidebar avatar fallbacks only checked metadata.name,
ignoring display_name and genUserName. Now uses the same fallback chain
as ProfileCard: display_name -> name -> genUserName(pubkey). Also fixed
the getDisplayName helper in LeftSidebar to check display_name.
2026-04-11 15:36:47 -05:00
Alex Gleason 399df4da4d Improve empty feed state with icon and discover CTA
Redesign FeedEmptyState with a centered icon, cleaner layout, and
two actionable buttons for the follows tab: 'Discover people to
follow' linking to /packs, and 'Browse the Global feed' to switch
tabs. Other call sites are unaffected (new props are optional).
2026-04-11 15:29:10 -05:00
Alex Gleason c06a66ade4 Ensure sticky desktop FAB anchors to bottom on empty feeds
Add min-h-dvh to the Feed <main> element so it always fills at least
the viewport height. Without this, the sticky FAB (a sibling after
<main>) sits in normal flow right after the short content instead of
at the bottom of the center column.
2026-04-11 15:25:37 -05:00
Alex Gleason 1fca26ae2e Clean up signup profile step: hide pencil badges, remove extra fields
- Hide the small pencil icon on avatar and banner until an image is
  actually set (the hover overlay still shows so users can discover
  the action)
- Remove the Profile Fields collapsible from the signup flow to keep
  the onboarding lightweight
2026-04-11 15:12:28 -05:00
Alex Gleason ccd8f213f6 Replace Skip/Continue with single Continue button in profile step
handlePublishProfile already skips publishing when no data is entered,
so the Skip button was redundant. A single full-width Continue button
simplifies the UI.
2026-04-11 15:09:38 -05:00
Alex Gleason 1c25702453 Fix signup dialog not clearing background when switching to light/dark theme
ThemeStep was reading customTheme?.background?.url unconditionally,
so the background persisted even after selecting a built-in theme.
Now resolves the active theme config the same way AppProvider does,
only showing the background when the active theme actually has one.
2026-04-11 14:58:52 -05:00
Alex Gleason 357ba7d8c8 fix: migrate to SystemBars API for Android 16+ safe area inset support
Android 16 (API 36) enforces edge-to-edge rendering unconditionally,
breaking @capacitor/status-bar's setOverlaysWebView and setBackgroundColor.
Additionally, a Chromium bug (<140) causes env(safe-area-inset-*) to report
0 in some Android WebViews.

- Replace @capacitor/status-bar with SystemBars from @capacitor/core 8+
- Enable insetsHandling: 'css' in capacitor.config.ts so the SystemBars
  plugin injects --safe-area-inset-* CSS variables on Android
- Update all safe area CSS utilities and inline styles to use
  var(--safe-area-inset-*, env(safe-area-inset-*, 0px)) fallback pattern
- Remove @capacitor/status-bar dependency (no longer needed)
2026-04-11 14:47:15 -05:00
Alex Gleason 207ca6893a Add iCloud Keychain credential saving/restoring on iOS via @capgo/capacitor-autofill-save-password
- Use SecAddSharedWebCredential to prompt 'Save Password?' on signup
- Use ASAuthorizationPasswordProvider to restore credentials on login
- Add webcredentials:ditto.pub Associated Domains entitlement
- Deploy apple-app-site-association for domain validation
- Keep existing Chromium PasswordCredential flow as web fallback
- Add saveNsec() helper: native credential manager on iOS/Android,
  file download + bonus PasswordCredential on web
- Single 'Continue' button triggers the appropriate save method per platform
2026-04-11 14:01:34 -05:00
Alex Gleason 37df5d0bd1 release: v2.6.3 2026-04-10 23:27:38 -05:00
Alex Gleason 19906cf918 Merge branch 'fix/badge-image-aspect-ratio-hint' into 'main'
Show recommended 1:1 aspect ratio hint on badge image upload

Closes #212

See merge request soapbox-pub/ditto!178
2026-04-11 03:49:14 +00:00
Alex Gleason 874010c4fe Store nsec in browser password manager via Credential Management API
Progressive enhancement using PasswordCredential (Chromium-only).
On sign-up, the nsec is offered to the browser's password manager
alongside the existing file download. The prompt appears while the
user is looking at their key on the download step. On login, stored
credentials are retrieved for one-tap login on supported browsers.

Safari/Firefox/iOS silently skip — existing flows are unchanged.
2026-04-10 21:49:14 -05:00
Alex Gleason 126dce1dfc Surface account deletion as 'Delete Account' for App Store compliance
Add a 'Delete Account' pill button to the bottom of the Settings
page (Guideline 5.1.1v). Rename the Danger Zone heading in Advanced
Settings to match. Simplify the deletion dialog to a single screen:
plain-language warning, list of what gets deleted, type DELETE to
confirm, and Cancel/Delete buttons. Always broadcasts to all relays.

The underlying NIP-62 mechanism and components that render vanish
events to other users are unchanged.
2026-04-10 16:44:35 -05:00
Alex Gleason 105da53e2e Add NSCameraUsageDescription to Info.plist
File inputs with accept="image/*" present a camera option on iOS.
Without this usage description, WKWebView crashes or fails to show
the permission dialog when the user selects 'Take Photo'.
2026-04-10 16:10:35 -05:00
Alex Gleason 7bc4a632b0 Add XCode DEVELOPMENT_TEAM to project.pbxproj 2026-04-10 16:03:38 -05:00
Alex Gleason 0222248d76 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-10 15:49:15 -05:00
Alex Gleason a542dd3b36 Sanitize all event-sourced URLs and prevent CSS injection
Nostr events are untrusted user input. Any URL extracted from event tags
or metadata must be validated before use in any context — not just
navigable hrefs, but also img src, CSS url(), and style attributes.

Changes:
- Theme events (kind 16767/36767): validate background and font URLs
  through sanitizeUrl() at parse time in themeEvent.ts
- Badge definitions (kind 30009): validate image and thumb URLs through
  sanitizeUrl() at parse time in parseBadgeDefinition.ts
- Font family names: sanitize with an allowlist regex before
  interpolation into CSS declarations in fontLoader.ts
- Profile fields: replace weak startsWith('http://') checks with
  sanitizeUrl() in ProfileRightSidebar and ProfilePage
- Community descriptions: validate extracted URLs through sanitizeUrl()
  in CommunityContent.tsx
- AGENTS.md: mandate unconditional URL sanitization for all
  event-sourced URLs regardless of rendering context, document CSS
  injection prevention guidelines
2026-04-10 15:48:38 -05:00
Mary Kate Fain fc292a8654 Replace screenshots table with simpler Before/After format in MR template 2026-04-10 15:15:54 -05:00
Mary Kate Fain 9214bd823b Remove redundant Submission checklist from MR template 2026-04-10 15:14:54 -05:00
Mary Kate Fain 8f5b8264c9 Show recommended 1:1 aspect ratio hint on badge image upload 2026-04-10 15:13:18 -05:00
Alex Gleason 94f821d064 Merge branch 'contributor-quality-gates' into 'main'
Add contributor quality gates: CONTRIBUTING.md, MR template, and CI validation

See merge request soapbox-pub/ditto!177
2026-04-10 19:50:29 +00:00
Mary Kate 6d73e6d06b Add contributor quality gates: CONTRIBUTING.md, MR template, and CI validation 2026-04-10 19:50:28 +00:00
Alex Gleason bd724de1e8 Bump @unhead/addons and @unhead/react to ^2.1.13 to fix CVE-2026-39315
The vulnerability (GHSA-95h2-gj7x-gx9w) allows bypassing hasDangerousProtocol()
in useHeadSafe() via leading-zero padded HTML entities. Not currently reachable
in this codebase (we only use useSeoMeta), but closes the CVE in the dependency
tree.
2026-04-10 14:29:49 -05:00
Alex Gleason 9d899cfe87 Sanitize all user-supplied URLs from Nostr events to prevent javascript: XSS
Add a shared sanitizeUrl() utility that validates URLs are well-formed
https: before they reach href attributes, window.open(), or openUrl().

Apply sanitization across all components that render untrusted URLs:
- CalendarEventDetailPage: r-tag links
- ZapstoreAppContent: url and repository tags
- ZapstoreReleaseContent: asset url tags passed to openUrl()
- AppHandlerContent: web handler tags and metadata.website
- NsiteCard: source tag
- GitRepoCard: web tag URLs passed to openUrl()
- FileMetadataContent: url tag used in download href
- ProfilePage: metadata.website (tighten weak startsWith check)
- useUserStatus: r-tag URL

Document sanitizeUrl usage in AGENTS.md for future agent use.
2026-04-10 14:22:42 -05:00
Mary Kate Fain 173f789242 Extract shared portal dropdown logic into usePortalDropdown hook
Both EmojiShortcodeAutocomplete and MentionAutocomplete had identical
logic for fixed viewport positioning with viewport-flip, scroll/resize
dismissal, and portal rendering. Extract into a shared hook to reduce
duplication and centralize the positioning behavior.
2026-04-10 12:50:05 -05:00
Chad Curtis 72268dfde6 Merge branch 'feat/feed-blobbi-status-visuals' into 'main'
Reflect companion condition in feed Blobbi cards

See merge request soapbox-pub/ditto!169
2026-04-10 13:05:44 +00:00
filemon c4a10b1303 Merge branch 'main' into feat/feed-blobbi-status-visuals 2026-04-09 15:27:28 -03:00
filemon 61c84ed137 Fix conditional hook call in BlobbiStateCard
Move the early return for null companion below all hooks so useMemo
calls are unconditional. The null/egg guard is now inside the recipe
useMemo, and isSleeping/isEgg use optional chaining.
2026-04-06 13:46:50 -03:00
filemon a24b755e08 Use projected decay stats for feed Blobbi visuals
Replace raw companion.stats with calculateProjectedDecay() output so
feed cards reflect the Blobbi's real current condition after time-based
stat decay, matching what the room view shows via useProjectedBlobbiState.

The pure calculateProjectedDecay() function is called once per render
inside useMemo (no setInterval per card), keeping feed rendering
lightweight while staying consistent with the room's decay math.
2026-04-06 13:28:42 -03:00
filemon 46a970b900 Reflect companion condition in feed Blobbi cards
BlobbiStateCard now resolves the same status recipe used by the room
view (resolveStatusRecipe) from the on-chain stats, so feed Blobbis
show hunger, dirt, sleepiness, sadness, and sickness visuals.

A new attenuateRecipeForFeed() helper scales down body-effect particle
counts and removes flies to keep the smaller feed-card size readable.
Sleeping Blobbis get the buildSleepingRecipe() overlay, matching the
room behaviour.
2026-04-06 12:42:57 -03:00
Mary Kate Fain f4363dcbff Fix emoji shortcode autocomplete clipped by compose box and emojis rendering as text
- Switch autocomplete dropdowns from absolute to fixed positioning so they
  aren't clipped by ancestor overflow containers (e.g. the compose modal's
  overflow-y-auto wrapper)
- Add viewport-relative coordinate calculation using getBoundingClientRect
- Add flip logic to show dropdown above cursor when near viewport bottom
- Dismiss dropdown on scroll/resize since fixed position doesn't track
- Add font-emoji utility class to force emoji presentation for native
  Unicode characters (star, fire, etc.) that may render as text glyphs
- Apply same fixes to MentionAutocomplete for consistency

Closes #216
2026-04-05 17:32:59 -05:00
91 changed files with 4174 additions and 2060 deletions
+30 -1
View File
@@ -219,7 +219,7 @@ publish-zapstore:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
SIGN_WITH: $ZAPSTORE_BUNKER_URL
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub"
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub,wss://relay.dreamith.to,wss://relay.primal.net"
BLOSSOM_URL: "https://blossom.ditto.pub"
script:
- go install github.com/zapstore/zsp@latest
@@ -235,3 +235,32 @@ publish-zapstore:
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
publish-google-play:
stage: publish
image: ruby:3.3
needs:
- build-apk
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- gem install fastlane --no-document
# Decode base64-encoded service account JSON to a temp file
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json
# Upload the AAB to Google Play production track
- >-
fastlane supply
--aab artifacts/Ditto.aab
--package_name pub.ditto.app
--track production
--json_key /tmp/play-service-account.json
--skip_upload_metadata
--skip_upload_changelogs
--skip_upload_images
--skip_upload_screenshots
--skip_upload_apk
# Clean up
- rm -f /tmp/play-service-account.json
@@ -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
+110 -9
View File
@@ -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`)
+48
View File
@@ -1,5 +1,53 @@
# 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
+184
View File
@@ -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.
+11
View File
@@ -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)
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.6.2"
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.
+1 -1
View File
@@ -14,7 +14,7 @@ dependencies {
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,27 +1,19 @@
package pub.ditto.app;
import android.graphics.Color;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.JavascriptInterface;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
@@ -30,120 +22,41 @@ import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
* Capacitor plugin that intercepts requests from sandbox iframes in the
* main Capacitor WebView.
*
* Each sandbox uses shouldInterceptRequest to intercept all requests and forward
* them to the JS layer as fetch events — the same protocol iframe.diy uses.
* The React code can serve files identically regardless of platform.
* On Android, each sandbox iframe loads from
* {@code https://<sandbox-id>.sandbox.native/path}. A custom
* {@link BridgeWebViewClient} subclass intercepts these requests via
* {@code shouldInterceptRequest}, forwards them to the JS layer as "fetch"
* events, and blocks the WebView IO thread until JS responds with
* {@code respondToFetch()}.
*
* Each unique hostname is a different web origin, so localStorage / IndexedDB
* are fully isolated per sandbox — no separate WebView instances needed.
*/
@CapacitorPlugin(name = "SandboxPlugin")
public class SandboxPlugin extends Plugin {
private static final String TAG = "SandboxPlugin";
private final Map<String, SandboxInstance> sandboxes = new HashMap<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
@PluginMethod
public void create(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
/** Pending requests waiting for JS to respond. */
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
JSObject frame = call.getObject("frame");
if (frame == null) {
call.reject("Missing required parameter: frame");
return;
}
int x = frame.optInt("x", 0);
int y = frame.optInt("y", 0);
int width = frame.optInt("width", 0);
int height = frame.optInt("height", 0);
if (sandboxes.containsKey(sandboxId)) {
call.reject("Sandbox already exists: " + sandboxId);
return;
}
float density = getActivity().getResources().getDisplayMetrics().density;
int pxX = Math.round(x * density);
int pxY = Math.round(y * density);
int pxWidth = Math.round(width * density);
int pxHeight = Math.round(height * density);
mainHandler.post(() -> {
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
sandboxes.put(sandboxId, sandbox);
// Add the WebView on top of the Capacitor WebView.
// The parent is a CoordinatorLayout — using the wrong LayoutParams
// type causes a ClassCastException when it intercepts touch events.
View capWebView = getBridge().getWebView();
ViewGroup parent = (ViewGroup) capWebView.getParent();
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
parent.addView(sandbox.webView, params);
// Load the initial page.
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
call.resolve();
});
}
@PluginMethod
public void updateFrame(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject frame = call.getObject("frame");
if (frame == null) {
call.reject("Missing required parameter: frame");
return;
}
int x = frame.optInt("x", 0);
int y = frame.optInt("y", 0);
int width = frame.optInt("width", 0);
int height = frame.optInt("height", 0);
float density = getActivity().getResources().getDisplayMetrics().density;
int pxX = Math.round(x * density);
int pxY = Math.round(y * density);
int pxWidth = Math.round(width * density);
int pxHeight = Math.round(height * density);
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
sandbox.webView.setLayoutParams(params);
call.resolve();
});
@Override
public void load() {
// Replace the main WebView's client with our subclass that intercepts
// sandbox iframe requests.
Bridge bridge = getBridge();
bridge.setWebViewClient(new SandboxBridgeWebViewClient(bridge, this));
}
@PluginMethod
public void respondToFetch(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
String requestId = call.getString("requestId");
if (requestId == null) {
call.reject("Missing required parameter: requestId");
@@ -155,12 +68,6 @@ public class SandboxPlugin extends Plugin {
return;
}
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
int status = response.optInt("status", 200);
String statusText = response.optString("statusText", "OK");
String bodyBase64 = response.optString("body", null);
@@ -174,54 +81,34 @@ public class SandboxPlugin extends Plugin {
}
}
sandbox.resolveRequest(requestId, status, statusText, headers, bodyBase64);
call.resolve();
}
@PluginMethod
public void postMessage(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject message = call.getObject("message");
if (message == null) {
call.reject("Missing required parameter: message");
return;
}
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
call.resolve();
}
@PluginMethod
public void destroy(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.remove(sandboxId);
if (sandbox != null) {
ViewGroup parent = (ViewGroup) sandbox.webView.getParent();
if (parent != null) {
parent.removeView(sandbox.webView);
}
sandbox.webView.destroy();
}
PendingRequest pending = pendingRequests.remove(requestId);
if (pending == null) {
call.resolve();
});
return;
}
byte[] bodyBytes = null;
if (bodyBase64 != null && !bodyBase64.equals("null")) {
try {
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
} catch (Exception e) {
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
}
}
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
String encoding = contentType.contains("text/") ? "UTF-8" : null;
InputStream body = bodyBytes != null
? new ByteArrayInputStream(bodyBytes)
: new ByteArrayInputStream(new byte[0]);
WebResourceResponse webResponse = new WebResourceResponse(
contentType, encoding, status, statusText, headers, body
);
pending.resolve(webResponse);
call.resolve();
}
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
@@ -232,113 +119,48 @@ public class SandboxPlugin extends Plugin {
notifyListeners("fetch", data);
}
void emitScriptMessage(String sandboxId, JSObject message) {
JSObject data = new JSObject();
data.put("id", sandboxId);
data.put("message", message);
notifyListeners("scriptMessage", data);
}
// -------------------------------------------------------------------------
// Custom BridgeWebViewClient that intercepts sandbox iframe requests
// -------------------------------------------------------------------------
/**
* A single sandboxed WebView instance.
* Extends Capacitor's BridgeWebViewClient to additionally intercept
* requests from sandbox iframes (URLs matching *.sandbox.native).
*/
private static class SandboxInstance {
final String id;
final WebView webView;
final SandboxPlugin plugin;
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
private static class SandboxBridgeWebViewClient extends BridgeWebViewClient {
private final SandboxPlugin plugin;
SandboxInstance(String id, SandboxPlugin plugin) {
this.id = id;
SandboxBridgeWebViewClient(Bridge bridge, SandboxPlugin plugin) {
super(bridge);
this.plugin = plugin;
this.webView = new WebView(plugin.getActivity());
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setDatabaseEnabled(true);
webView.setBackgroundColor(Color.WHITE);
// Add JavaScript interface for script->native communication.
webView.addJavascriptInterface(new SandboxBridge(this), "__sandboxNative");
// Inject the bridge script and intercept requests.
webView.setWebViewClient(new SandboxWebViewClient(this));
}
void postMessageToWebView(String jsonString) {
String js = "(function() { " +
"if (window.__sandboxBridge && window.__sandboxBridge.onMessage) { " +
"window.__sandboxBridge.onMessage(" + jsonString + "); " +
"} " +
"})();";
webView.evaluateJavascript(js, null);
}
void resolveRequest(String requestId, int status, String statusText,
Map<String, String> headers, String bodyBase64) {
PendingRequest pending = pendingRequests.remove(requestId);
if (pending == null) return;
byte[] bodyBytes = null;
if (bodyBase64 != null && !bodyBase64.equals("null")) {
try {
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
} catch (Exception e) {
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
}
}
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
String encoding = contentType.contains("text/") ? "UTF-8" : null;
InputStream body = bodyBytes != null
? new ByteArrayInputStream(bodyBytes)
: new ByteArrayInputStream(new byte[0]);
WebResourceResponse response = new WebResourceResponse(
contentType, encoding, status, statusText, headers, body
);
pending.resolve(response);
}
}
/**
* WebViewClient that intercepts all requests and forwards them to JS.
*/
private static class SandboxWebViewClient extends WebViewClient {
private final SandboxInstance sandbox;
private boolean bridgeInjected = false;
SandboxWebViewClient(SandboxInstance sandbox) {
this.sandbox = sandbox;
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
String host = request.getUrl().getHost();
// Only intercept requests to the sandbox domain.
if (!url.contains(".sandbox.native")) {
return null;
// Intercept requests to *.sandbox.native (from sandbox iframes).
if (host != null && host.endsWith(".sandbox.native")) {
return handleSandboxRequest(request, host);
}
// Everything else: delegate to Capacitor's default handling.
return super.shouldInterceptRequest(view, request);
}
private WebResourceResponse handleSandboxRequest(WebResourceRequest request, String host) {
// Extract sandbox ID from the hostname (e.g. "abc123.sandbox.native" -> "abc123").
String sandboxId = host.replace(".sandbox.native", "");
String requestId = UUID.randomUUID().toString();
// Create a pending request with a blocking latch.
PendingRequest pending = new PendingRequest();
sandbox.pendingRequests.put(requestId, pending);
plugin.pendingRequests.put(requestId, pending);
// Rewrite URL to include the sandbox ID for the JS handler.
// Serialise the request for the JS layer.
String path = request.getUrl().getPath();
if (path == null || path.isEmpty()) path = "/";
String rewrittenURL = "https://" + sandbox.id + ".sandbox.native" + path;
String rewrittenURL = "https://" + sandboxId + ".sandbox.native" + path;
// Serialise the request.
JSObject serialisedRequest = new JSObject();
serialisedRequest.put("url", rewrittenURL);
serialisedRequest.put("method", request.getMethod());
@@ -351,106 +173,37 @@ public class SandboxPlugin extends Plugin {
serialisedRequest.put("body", JSONObject.NULL);
// Emit to JS.
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
plugin.emitFetchRequest(sandboxId, requestId, serialisedRequest);
// Block this thread until JS responds (with a timeout).
WebResourceResponse response = pending.awaitResponse(10000);
// 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.
sandbox.pendingRequests.remove(requestId);
plugin.pendingRequests.remove(requestId);
return new WebResourceResponse(
"text/plain", "UTF-8", 504,
"Gateway Timeout", new HashMap<>(),
new ByteArrayInputStream("Request timed out".getBytes())
);
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
if (!bridgeInjected) {
bridgeInjected = true;
view.evaluateJavascript(getBridgeScript(), null);
}
}
private String getBridgeScript() {
return "(function() {" +
"'use strict';" +
"var messageListeners = [];" +
"window.__sandboxBridge = {" +
" onMessage: function(data) {" +
" var event = {" +
" data: data," +
" origin: 'https://" + sandbox.id + ".sandbox.native'," +
" source: window.parent," +
" type: 'message'" +
" };" +
" for (var i = 0; i < messageListeners.length; i++) {" +
" try { messageListeners[i](event); } catch(e) {}" +
" }" +
" }" +
"};" +
"var origAdd = window.addEventListener;" +
"window.addEventListener = function(type, fn, opts) {" +
" if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);" +
" return origAdd.call(window, type, fn, opts);" +
"};" +
"var origRemove = window.removeEventListener;" +
"window.removeEventListener = function(type, fn, opts) {" +
" if (type === 'message') {" +
" var idx = messageListeners.indexOf(fn);" +
" if (idx !== -1) messageListeners.splice(idx, 1);" +
" }" +
" return origRemove.call(window, type, fn, opts);" +
"};" +
"if (!window.parent || window.parent === window) window.parent = {};" +
"window.parent.postMessage = function(data) {" +
" if (data && typeof data === 'object' && data.jsonrpc === '2.0') {" +
" try { window.__sandboxNative.postMessage(JSON.stringify(data)); } catch(e) {}" +
" }" +
"};" +
"})();";
}
}
/**
* JavaScript interface exposed to the sandbox WebView.
*/
private static class SandboxBridge {
private final SandboxInstance sandbox;
SandboxBridge(SandboxInstance sandbox) {
this.sandbox = sandbox;
}
@JavascriptInterface
public void postMessage(String json) {
try {
JSONObject obj = new JSONObject(json);
JSObject jsObj = new JSObject();
for (java.util.Iterator<String> it = obj.keys(); it.hasNext(); ) {
String key = it.next();
jsObj.put(key, obj.get(key));
}
sandbox.plugin.emitScriptMessage(sandbox.id, jsObj);
} catch (JSONException e) {
Log.w(TAG, "Failed to parse script message", e);
}
}
}
// -------------------------------------------------------------------------
// Pending request helper
// -------------------------------------------------------------------------
/**
* A pending request that blocks the WebViewClient thread until resolved.
* A pending request that blocks the WebView IO thread until JS responds.
*/
private static class PendingRequest {
private WebResourceResponse response;
private final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
private volatile WebResourceResponse response;
private final CountDownLatch latch = new CountDownLatch(1);
void resolve(WebResourceResponse response) {
this.response = response;
@@ -459,7 +212,7 @@ public class SandboxPlugin extends Plugin {
WebResourceResponse awaitResponse(long timeoutMs) {
try {
latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
+2 -2
View File
@@ -17,8 +17,8 @@ project(':capacitor-local-notifications').projectDir = new File('../node_modules
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')
+4 -4
View File
@@ -5,8 +5,6 @@ const config: CapacitorConfig = {
appName: 'Ditto',
webDir: 'dist',
server: {
// Handle deep links from your domain
hostname: 'ditto.pub',
androidScheme: 'https',
iosScheme: 'https'
},
@@ -21,8 +19,10 @@ const config: CapacitorConfig = {
scheme: 'Ditto'
},
plugins: {
Keyboard: {
resizeOnFullScreen: true,
SystemBars: {
// Inject --safe-area-inset-* CSS variables on Android to work around
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
insetsHandling: 'css',
},
},
};
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<title>Ditto — Your content. Your vibe. Your rules.</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<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 -->
+12 -2
View File
@@ -17,6 +17,7 @@
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -32,6 +33,8 @@
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 */
@@ -67,6 +70,7 @@
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
B1A2C3D40004000100000002 /* App.entitlements */,
504EC3071FED79650016851F /* AppDelegate.swift */,
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
@@ -74,6 +78,7 @@
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
@@ -151,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;
};
@@ -303,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.2;
MARKETING_VERSION = 2.6.6;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,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.2;
MARKETING_VERSION = 2.6.6;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+11
View File
@@ -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>
@@ -1,7 +1,22 @@
import UIKit
import WebKit
import Capacitor
class DittoBridgeViewController: CAPBridgeViewController {
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
let config = super.webViewConfiguration(for: instanceConfiguration)
// Register the sbx:// custom scheme handler BEFORE the WKWebView is
// created. Each sandbox iframe loads from sbx://<sandbox-id>/path,
// giving every sandbox a unique web origin with full storage isolation.
let handler = SandboxRequestHandler()
_sandboxHandler = handler
config.setURLSchemeHandler(handler, forURLScheme: "sbx")
return config
}
override func capacitorDidLoad() {
super.capacitorDidLoad()
webView?.allowsBackForwardNavigationGestures = true
+4
View File
@@ -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>
+118 -363
View File
@@ -2,366 +2,48 @@ import Foundation
import Capacitor
import WebKit
// MARK: - Plugin
// MARK: - Shared Handler Singleton
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
/// The sandbox request handler singleton.
/// Created by `DittoBridgeViewController` at WKWebView configuration time,
/// then connected to the `SandboxPlugin` when the plugin loads.
var _sandboxHandler: SandboxRequestHandler?
// MARK: - Sandbox Scheme Handler
/// `WKURLSchemeHandler` for the `sbx://` custom scheme.
///
/// Each sandbox gets a unique custom URL scheme (`sbx-<id>://`) so that
/// every embedded app has its own origin (separate localStorage, cookies, etc.).
/// All requests on the custom scheme are intercepted via `WKURLSchemeHandler`
/// and forwarded to the JS layer as fetch events the same protocol
/// iframe.diy uses. This lets the existing React code serve files identically.
@objc(SandboxPlugin)
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "SandboxPlugin"
public let jsName = "SandboxPlugin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateFrame", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "postMessage", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "destroy", returnType: CAPPluginReturnPromise),
]
/// Active sandbox instances, keyed by sandbox ID.
private var sandboxes: [String: SandboxInstance] = [:]
// MARK: - Plugin Methods
@objc func create(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let frame = call.getObject("frame"),
let x = frame["x"] as? Double,
let y = frame["y"] as? Double,
let width = frame["width"] as? Double,
let height = frame["height"] as? Double else {
call.reject("Missing or invalid parameter: frame")
return
}
if sandboxes[sandboxId] != nil {
call.reject("Sandbox already exists: \(sandboxId)")
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let webViewFrame = CGRect(x: x, y: y, width: width, height: height)
let sandbox = SandboxInstance(
id: sandboxId,
frame: webViewFrame,
plugin: self
)
self.sandboxes[sandboxId] = sandbox
// Add the WebView on top of the Capacitor WebView.
if let bridge = self.bridge,
let webView = bridge.webView {
webView.superview?.addSubview(sandbox.webView)
}
call.resolve()
}
}
@objc func updateFrame(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let frame = call.getObject("frame"),
let x = frame["x"] as? Double,
let y = frame["y"] as? Double,
let width = frame["width"] as? Double,
let height = frame["height"] as? Double else {
call.reject("Missing or invalid parameter: frame")
return
}
DispatchQueue.main.async { [weak self] in
guard let sandbox = self?.sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.webView.frame = CGRect(x: x, y: y, width: width, height: height)
call.resolve()
}
}
@objc func respondToFetch(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let requestId = call.getString("requestId") else {
call.reject("Missing required parameter: requestId")
return
}
guard let response = call.getObject("response") else {
call.reject("Missing required parameter: response")
return
}
guard let sandbox = sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.schemeHandler.resolveRequest(
requestId: requestId,
status: response["status"] as? Int ?? 200,
statusText: response["statusText"] as? String ?? "OK",
headers: response["headers"] as? [String: String] ?? [:],
bodyBase64: response["body"] as? String
)
call.resolve()
}
@objc func postMessage(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let message = call.getObject("message") else {
call.reject("Missing required parameter: message")
return
}
guard let sandbox = sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
DispatchQueue.main.async {
sandbox.postMessageToWebView(message)
}
call.resolve()
}
@objc func destroy(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
sandbox.webView.removeFromSuperview()
sandbox.schemeHandler.cancelAll()
}
call.resolve()
}
}
// MARK: - Event Forwarding
/// Forward a fetch request from the native WebView to JS.
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
notifyListeners("fetch", data: [
"id": sandboxId,
"requestId": requestId,
"request": request,
])
}
/// Forward a script message from the sandbox to JS.
func emitScriptMessage(sandboxId: String, message: [String: Any]) {
notifyListeners("scriptMessage", data: [
"id": sandboxId,
"message": message,
])
}
}
// MARK: - SandboxInstance
/// Manages a single sandboxed WKWebView instance.
private class SandboxInstance: NSObject, WKScriptMessageHandler {
let id: String
let webView: WKWebView
let schemeHandler: SandboxSchemeHandler
private weak var plugin: SandboxPlugin?
private let customScheme: String
init(id: String, frame: CGRect, plugin: SandboxPlugin) {
self.id = id
self.plugin = plugin
// Each sandbox gets a unique custom URL scheme so that WKWebView
// assigns a distinct origin, isolating localStorage/IndexedDB/cookies.
self.customScheme = "sbx-\(id)"
self.schemeHandler = SandboxSchemeHandler(
sandboxId: id,
scheme: self.customScheme,
plugin: plugin
)
let config = WKWebViewConfiguration()
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: self.customScheme)
// Add a script message handler for communication from injected scripts.
let userContentController = WKUserContentController()
// Inject a bridge script that:
// 1. Provides window.parent.postMessage()-like functionality
// 2. Routes messages through the native bridge
let bridgeScript = WKUserScript(
source: SandboxInstance.bridgeScript(scheme: self.customScheme),
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
userContentController.addUserScript(bridgeScript)
config.userContentController = userContentController
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.defaultWebpagePreferences.allowsContentJavaScript = true
self.webView = WKWebView(frame: frame, configuration: config)
self.webView.isOpaque = false
self.webView.backgroundColor = .white
self.webView.scrollView.bounces = false
super.init()
// Register the message handler after super.init().
userContentController.add(self, name: "sandboxBridge")
// Load the initial page via the custom scheme.
let initialURL = URL(string: "\(self.customScheme)://app/index.html")!
self.webView.load(URLRequest(url: initialURL))
}
/// Post a JSON-RPC message to injected scripts inside the WebView.
func postMessageToWebView(_ message: [String: Any]) {
guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
let jsonString = String(data: jsonData, encoding: .utf8) else {
return
}
let js = """
(function() {
if (window.__sandboxBridge && window.__sandboxBridge.onMessage) {
window.__sandboxBridge.onMessage(\(jsonString));
}
})();
"""
webView.evaluateJavaScript(js, completionHandler: nil)
}
// MARK: - WKScriptMessageHandler
/// Receive messages from injected scripts via webkit.messageHandlers.sandboxBridge.
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "sandboxBridge",
let body = message.body as? [String: Any] else {
return
}
plugin?.emitScriptMessage(sandboxId: id, message: body)
}
// MARK: - Bridge Script
/// JavaScript injected at document start that provides:
/// - `window.parent.postMessage()` emulation via WKScriptMessageHandler
/// - `window.__sandboxBridge.onMessage()` for receiving messages from parent
/// - `window.addEventListener("message", ...)` support for injected scripts
private static func bridgeScript(scheme: String) -> String {
return """
(function() {
'use strict';
// Message listeners registered by injected scripts.
var messageListeners = [];
// Bridge object for native communication.
window.__sandboxBridge = {
onMessage: function(data) {
// Dispatch to all registered message listeners.
var event = {
data: data,
origin: '\(scheme)://app',
source: window.parent,
type: 'message'
};
for (var i = 0; i < messageListeners.length; i++) {
try {
messageListeners[i](event);
} catch (e) {
console.error('[SandboxBridge] Listener error:', e);
}
}
}
};
// Override addEventListener to capture "message" listeners.
var originalAddEventListener = window.addEventListener;
window.addEventListener = function(type, listener, options) {
if (type === 'message' && typeof listener === 'function') {
messageListeners.push(listener);
}
return originalAddEventListener.call(window, type, listener, options);
};
var originalRemoveEventListener = window.removeEventListener;
window.removeEventListener = function(type, listener, options) {
if (type === 'message') {
var idx = messageListeners.indexOf(listener);
if (idx !== -1) messageListeners.splice(idx, 1);
}
return originalRemoveEventListener.call(window, type, listener, options);
};
// Emulate window.parent.postMessage for scripts that use it
// (e.g. the webxdc bridge script, preview injected script).
if (!window.parent || window.parent === window) {
window.parent = {};
}
window.parent.postMessage = function(data, targetOrigin, transfer) {
if (data && typeof data === 'object' && data.jsonrpc === '2.0') {
try {
window.webkit.messageHandlers.sandboxBridge.postMessage(data);
} catch (e) {
console.error('[SandboxBridge] postMessage failed:', e);
}
}
};
})();
""";
}
}
// MARK: - SandboxSchemeHandler
/// WKURLSchemeHandler that intercepts all requests on the sandbox's custom
/// URL scheme and forwards them to the JS layer as fetch events.
private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
private let sandboxId: String
private let scheme: String
private weak var plugin: SandboxPlugin?
/// Pending scheme tasks waiting for a response from JS.
/// Key: requestId (UUID string), Value: the WKURLSchemeTask to respond to.
/// Each sandbox iframe loads from `sbx://<sandbox-id>/path`, giving every
/// sandbox a unique web origin with full localStorage / IndexedDB / cookie
/// isolation.
///
/// Intercepted requests are forwarded to the JS layer via the Capacitor
/// plugin bridge. JS resolves the file and responds with `respondToFetch()`.
class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
private var pendingTasks: [String: WKURLSchemeTask] = [:]
private let lock = NSLock()
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
self.sandboxId = sandboxId
self.scheme = scheme
self.plugin = plugin
weak var plugin: SandboxPlugin?
/// Diagnostics: total number of start calls received.
var startCallCount: Int = 0
/// Diagnostics: last URL received by the handler.
var lastURL: String = "(none)"
/// Number of pending tasks (for diagnostics).
var pendingTaskCount: Int {
lock.lock()
let count = pendingTasks.count
lock.unlock()
return count
}
// MARK: WKURLSchemeHandler
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
startCallCount += 1
lastURL = urlSchemeTask.request.url?.absoluteString ?? "(nil)"
let request = urlSchemeTask.request
guard let url = request.url else {
urlSchemeTask.didFailWithError(NSError(
@@ -377,10 +59,9 @@ private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
pendingTasks[requestId] = urlSchemeTask
lock.unlock()
// Serialise the request for the fetch event.
// Rewrite the URL so it looks like a normal HTTP URL to the parent
// (e.g. "sbx-abc123://app/index.html" -> "https://<sandboxId>.sandbox.native/index.html")
// The JS side only cares about the pathname.
// Extract the sandbox ID from the hostname: sbx://<sandbox-id>/path
let sandboxId = url.host ?? "unknown"
var headers: [String: String] = [:]
if let allHeaders = request.allHTTPHeaderFields {
headers = allHeaders
@@ -392,6 +73,8 @@ private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
}
let path = url.path.isEmpty ? "/" : url.path
// Rewrite URL so JS sees a consistent format matching Android.
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
let serialisedRequest: [String: Any] = [
@@ -409,7 +92,6 @@ private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
// Remove the task from pending JS response will be ignored if it arrives later.
lock.lock()
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
if let key = removed?.key {
@@ -418,7 +100,8 @@ private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
lock.unlock()
}
/// Called by the plugin when JS responds to a fetch request.
// MARK: Response Resolution
func resolveRequest(
requestId: String,
status: Int,
@@ -433,15 +116,12 @@ private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
}
lock.unlock()
// Decode the base64 body.
var bodyData: Data? = nil
if let b64 = bodyBase64 {
bodyData = Data(base64Encoded: b64)
}
// Build the response.
// Use the task's original URL for the response.
let responseURL = task.request.url ?? URL(string: "\(scheme)://app/")!
let responseURL = task.request.url ?? URL(string: "sbx://unknown/")!
let response = HTTPURLResponse(
url: responseURL,
statusCode: status,
@@ -458,7 +138,6 @@ private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
}
}
/// Cancel all pending tasks (called on destroy).
func cancelAll() {
lock.lock()
let tasks = pendingTasks
@@ -473,3 +152,79 @@ private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
}
}
}
// MARK: - Plugin
/// Capacitor plugin that bridges sandbox fetch events between native and JS.
///
/// On iOS, sandbox iframes use the `sbx://` custom URL scheme, registered
/// on the WKWebView configuration before the web view is created. Each
/// sandbox loads from `sbx://<sandbox-id>/path`, providing full origin
/// isolation (separate localStorage, cookies, etc.).
///
/// On Android, a custom `BridgeWebViewClient` subclass intercepts requests
/// to `https://<sandbox-id>.sandbox.native/path`.
@objc(SandboxPlugin)
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "SandboxPlugin"
public let jsName = "SandboxPlugin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "diagnose", returnType: CAPPluginReturnPromise),
]
public override func load() {
// Connect the shared handler to this plugin so it can emit events.
_sandboxHandler?.plugin = self
}
@objc func respondToFetch(_ call: CAPPluginCall) {
guard let requestId = call.getString("requestId") else {
call.reject("Missing required parameter: requestId")
return
}
guard let response = call.getObject("response") else {
call.reject("Missing required parameter: response")
return
}
guard let handler = _sandboxHandler else {
call.reject("Sandbox handler not initialised")
return
}
handler.resolveRequest(
requestId: requestId,
status: response["status"] as? Int ?? 200,
statusText: response["statusText"] as? String ?? "OK",
headers: response["headers"] as? [String: String] ?? [:],
bodyBase64: response["body"] as? String
)
call.resolve()
}
/// Diagnostic method callable from JS to inspect native state.
@objc func diagnose(_ call: CAPPluginCall) {
let handler = _sandboxHandler
call.resolve([
"sandboxHandlerSet": handler != nil,
"pluginConnected": handler?.plugin != nil,
"bridgeHasWebView": bridge?.webView != nil,
"hasListenersFetch": hasListeners("fetch"),
"pendingTaskCount": handler?.pendingTaskCount ?? 0,
"startCallCount": handler?.startCallCount ?? 0,
"lastURL": handler?.lastURL ?? "(no handler)",
])
}
// MARK: - Event Forwarding
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
notifyListeners("fetch", data: [
"id": sandboxId,
"requestId": requestId,
"request": request,
])
}
}
+2 -2
View File
@@ -17,7 +17,7 @@ let package = Package(
.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: [
@@ -31,7 +31,7 @@ let package = Package(
.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")
]
)
+254 -106
View File
@@ -1,20 +1,20 @@
{
"name": "ditto",
"version": "2.6.2",
"version": "2.6.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.6.2",
"version": "2.6.6",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/keyboard": "^8.0.2",
"@capacitor/keyboard": "^8.0.3",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@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",
@@ -60,7 +60,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.0",
"@nostrify/react": "^0.5.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -92,8 +92,8 @@
"@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",
@@ -218,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": {
@@ -382,9 +429,9 @@
}
},
"node_modules/@capacitor/keyboard": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.2.tgz",
"integrity": "sha512-he6xKmTBp5AhVrWJeEi6RYkJ25FjLLdNruBU2wafpITk3Nb7UdzOj96x3K6etFuEj8/rtn9WXBTs1o2XA86A1A==",
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz",
"integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
@@ -408,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",
@@ -1800,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": {
@@ -1822,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",
@@ -1838,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",
@@ -2540,9 +2584,9 @@
"license": "MIT"
},
"node_modules/@nostrify/react": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.0.tgz",
"integrity": "sha512-IQf74SSusSIyhI9FkUQSUTsX20yeww5xHIUeexvxcWXEpVhYJYCwduK2yRB75NvYgXjcqYeDUGA2RvzBhDc/eA==",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.1.tgz",
"integrity": "sha512-gQUct8A7KLKvoLtv4bHpVDfmvzJlIHjZZI6DMui8vrSuzm8IqMRdAYADbR3ry1mlIQp8/c4EeR24piBpHK0WUw==",
"dependencies": {
"@nostrify/nostrify": "0.51.1",
"@nostrify/types": "0.36.9"
@@ -5668,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",
@@ -6519,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",
@@ -6910,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"
@@ -7207,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"
@@ -7344,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",
@@ -10015,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"
@@ -11006,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": {
@@ -13678,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": {
@@ -13691,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"
@@ -13814,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",
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.6.2",
"version": "2.6.6",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -18,10 +18,10 @@
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/keyboard": "^8.0.2",
"@capacitor/keyboard": "^8.0.3",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@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",
@@ -67,7 +67,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.0",
"@nostrify/react": "^0.5.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -99,8 +99,8 @@
"@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",
@@ -0,0 +1,7 @@
{
"webcredentials": {
"apps": [
"GZLTTH5DLM.pub.ditto.app"
]
}
}
+48
View File
@@ -1,5 +1,53 @@
# 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
+7 -8
View File
@@ -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";
@@ -184,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
});
}
}, []);
+48
View File
@@ -876,3 +876,51 @@ export const ACTION_EMOTION_MAP: Record<ActionType, BlobbiEmotion> = {
export function getActionEmotion(action: ActionType): BlobbiEmotion {
return ACTION_EMOTION_MAP[action];
}
// ─── Feed Attenuation ─────────────────────────────────────────────────────────
/**
* Produce a lighter version of a visual recipe suitable for feed cards.
*
* Feed Blobbis are rendered at a smaller size (size-48/56 vs size-64+) and
* need to remain readable at a glance. This function keeps all facial parts
* (eyes, mouth, eyebrows) and extras untouched — they are already sized
* relative to the SVG viewBox — but reduces body-effect particle counts
* and removes flies to prevent visual clutter at small sizes.
*
* The input recipe is produced by the same `resolveStatusRecipe()` used
* by the room view, so thresholds and priorities are identical.
*/
export function attenuateRecipeForFeed(recipe: BlobbiVisualRecipe): BlobbiVisualRecipe {
// Empty / no body effects → return as-is (stable reference path)
if (!recipe.bodyEffects) return recipe;
const { bodyEffects, ...rest } = recipe;
const attenuated: BodyEffectsRecipe = {};
// Dirt marks: reduce count by ~40%, lower intensity cap
if (bodyEffects.dirtMarks?.enabled) {
attenuated.dirtMarks = {
...bodyEffects.dirtMarks,
count: Math.max(1, Math.ceil((bodyEffects.dirtMarks.count ?? 3) * 0.6)),
intensity: Math.min(bodyEffects.dirtMarks.intensity ?? 0.6, 0.55),
};
}
// Stink clouds: reduce count, remove flies entirely
if (bodyEffects.stinkClouds?.enabled) {
attenuated.stinkClouds = {
...bodyEffects.stinkClouds,
count: Math.max(1, Math.ceil((bodyEffects.stinkClouds.count ?? 3) * 0.5)),
flies: false,
flyCount: 0,
};
}
// Anger rise: pass through unchanged (single overlay, scales with SVG)
if (bodyEffects.angerRise) {
attenuated.angerRise = bodyEffects.angerRise;
}
return { ...rest, bodyEffects: attenuated };
}
+4 -5
View File
@@ -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>
+2 -1
View File
@@ -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]);
+29 -3
View File
@@ -3,17 +3,41 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { parseBlobbiEvent } from '@/blobbi/core/lib/blobbi';
import { calculateProjectedDecay } from '@/blobbi/core/hooks/useProjectedBlobbiState';
import { resolveStatusRecipe, attenuateRecipeForFeed, EMPTY_RECIPE } from '@/blobbi/ui/lib/status-reactions';
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
export function BlobbiStateCard({ event }: { event: NostrEvent }) {
const companion = useMemo(() => parseBlobbiEvent(event), [event]);
if (!companion) return null;
const isSleeping = companion?.state === 'sleeping';
const isEgg = companion?.stage === 'egg';
const isSleeping = companion.state === 'sleeping';
// ── Project stats forward in time, then resolve visual recipe ──
// Feed cards show a snapshot, not a live ticker, so we call the pure
// calculateProjectedDecay() once per render instead of using the
// interval-based useProjectedBlobbiState hook. This gives us the
// same decay math the room view uses (applyBlobbiDecay under the
// hood) without any per-card setInterval overhead.
const { recipe: feedRecipe, recipeLabel: feedRecipeLabel } = useMemo(() => {
if (!companion || isEgg) return { recipe: EMPTY_RECIPE, recipeLabel: 'neutral' };
const { stats } = calculateProjectedDecay(companion);
const result = resolveStatusRecipe(stats);
// Attenuate body effects for feed-card size, then apply sleep overlay
const attenuated = attenuateRecipeForFeed(result.recipe);
const final = isSleeping ? buildSleepingRecipe(attenuated) : attenuated;
return { recipe: final, recipeLabel: isSleeping ? 'sleeping' : result.label };
}, [companion, isEgg, isSleeping]);
if (!companion) return null;
return (
<div className="flex flex-col items-center py-4">
{/* Blobbi visual — same as /blobbi hero */}
{/* Blobbi visual — reflects current condition */}
<div className="relative">
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
@@ -21,6 +45,8 @@ export function BlobbiStateCard({ event }: { event: NostrEvent }) {
size="lg"
animated={!isSleeping}
lookMode="forward"
recipe={feedRecipe}
recipeLabel={feedRecipeLabel}
className="size-48 sm:size-56"
/>
</div>
+2 -1
View File
@@ -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]);
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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>
+3
View File
@@ -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 */}
+20 -10
View File
@@ -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);
}
+3 -11
View File
@@ -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(() => {
+3 -2
View File
@@ -229,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
@@ -327,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')
+28 -11
View File
@@ -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>
);
+2 -1
View File
@@ -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');
+2 -1
View File
@@ -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",
);
+41 -61
View File
@@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
import {
Check,
ChevronRight,
Download,
Eye,
EyeOff,
Heart,
@@ -13,7 +12,7 @@ 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,
@@ -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,26 +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);
// 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",
});
}
@@ -447,7 +448,7 @@ function SetupQuestionnaire({
{step === "keygen" && <KeygenStep onGenerate={handleGenerate} />}
{step === "download" && (
<DownloadStep nsec={nsec} onDownload={handleDownloadAndLogin} />
<DownloadStep nsec={nsec} onContinue={handleDownloadContinue} />
)}
{step === "profile" && (
@@ -514,10 +515,10 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
function DownloadStep({
nsec,
onDownload,
onContinue,
}: {
nsec: string;
onDownload: () => void;
onContinue: () => void;
}) {
const [showKey, setShowKey] = useState(false);
@@ -528,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>
@@ -561,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>
);
@@ -599,9 +599,6 @@ function ProfileStep({
banner: "",
website: "",
});
const [extraFields, setExtraFields] = useState<
Array<{ label: string; value: string }>
>([]);
const [cropState, setCropState] = useState<{
imageSrc: string;
aspect: number;
@@ -656,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 {
@@ -679,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">
@@ -725,8 +715,6 @@ function ProfileStep({
}
onPickImage={handlePickImage}
showNip05={false}
extraFields={extraFields}
onExtraFieldsChange={setExtraFields}
/>
</div>
@@ -736,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>
);
}
@@ -780,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 (
<>
+2 -2
View File
@@ -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>
)}
+10 -2
View File
@@ -8,6 +8,7 @@ import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { CursorFireEffect } from '@/components/CursorFireEffect';
import { Skeleton } from '@/components/ui/skeleton';
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
import { NsitePlayerContext, type NsitePlayerState } from '@/contexts/NsitePlayerContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { cn } from '@/lib/utils';
@@ -118,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} />
@@ -138,10 +139,17 @@ function MainLayoutInner() {
*/
export function MainLayout() {
const store = useMemo(() => new LayoutStore(), []);
const [activeSubdomain, setActiveSubdomain] = useState<string | null>(null);
const nsitePlayer = useMemo<NsitePlayerState>(
() => ({ activeSubdomain, setActiveSubdomain }),
[activeSubdomain],
);
return (
<LayoutStoreContext.Provider value={store}>
<MainLayoutInner />
<NsitePlayerContext.Provider value={nsitePlayer}>
<MainLayoutInner />
</NsitePlayerContext.Provider>
</LayoutStoreContext.Provider>
);
}
+19 -10
View File
@@ -8,6 +8,7 @@ import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles
import { genUserName } from '@/lib/genUserName';
import { useNip05Verify } from '@/hooks/useNip05Verify';
import { cn } from '@/lib/utils';
import { usePortalDropdown } from '@/hooks/usePortalDropdown';
interface MentionAutocompleteProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
@@ -89,6 +90,14 @@ export function MentionAutocomplete({
const dropdownRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const handleClose = useCallback(() => setIsOpen(false), []);
const { computePosition, renderPortal } = usePortalDropdown({
textareaRef,
isOpen,
onClose: handleClose,
dropdownHeight: 240, // must match max-h-[240px] below
});
const { data: profiles, followedPubkeys } = useSearchProfiles(
isOpen ? mentionQuery : '',
);
@@ -140,15 +149,11 @@ export function MentionAutocomplete({
setIsOpen(true);
setSelectedIndex(0);
// Position the dropdown below the @ character, relative to the textarea's
// offsetParent (the `relative` wrapper div) so it stays inside the modal.
// Position the dropdown using fixed viewport coordinates so it isn't
// clipped by ancestor overflow containers (e.g. the compose modal).
const coords = getCaretCoordinates(textarea, atPos);
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
setDropdownPos({
top: coords.top + lineHeight + 4,
left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)),
});
}, [textareaRef]);
setDropdownPos(computePosition(coords));
}, [textareaRef, computePosition]);
// Listen for input/cursor changes on the textarea element.
// Re-attaches whenever the underlying DOM element changes (e.g. after
@@ -254,10 +259,10 @@ export function MentionAutocomplete({
return null;
}
return (
const dropdown = (
<div
ref={dropdownRef}
className="absolute z-[100] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
>
<div ref={listRef} className="max-h-[240px] overflow-y-auto py-1">
@@ -273,6 +278,10 @@ export function MentionAutocomplete({
</div>
</div>
);
// Portal to document.body so the dropdown escapes any ancestor overflow
// clipping and CSS transform containing blocks (e.g. Radix Dialog).
return renderPortal(dropdown, document.body);
}
function MentionItem({
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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">
+10 -3
View File
@@ -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();
+77 -13
View File
@@ -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}
/>
</>
);
+203
View File
@@ -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>
);
}
+223
View File
@@ -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>
);
}
+209 -38
View File
@@ -3,12 +3,20 @@ 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';
@@ -68,25 +76,108 @@ function resolveServers(event: NostrEvent, appServers: string[]): string[] {
}
/**
* Fetch a blob from the given sha256 by trying each Blossom server in order.
* Returns a Response from the first server that responds successfully, or
* throws if all servers fail.
* Module-level preferred server. Once a Blossom server successfully serves
* a blob, it is promoted here so subsequent requests try it first — avoiding
* the round-trip penalty of 404s on servers that don't have the content.
*/
let preferredServer: string | null = null;
/**
* Fetch a blob from the given sha256 by trying Blossom servers.
*
* If a server previously succeeded (the "preferred" server), it is tried
* first. On success the preferred server is reinforced; on failure we fall
* through to the remaining servers in order. Whichever server ultimately
* succeeds is promoted to preferred for the next call.
*/
async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Response> {
let lastError: unknown;
for (const server of servers) {
/** Try a single server. Returns the Response on success, or null. */
async function tryServer(server: string): Promise<Response | null> {
const base = server.replace(/\/+$/, '');
const url = `${base}/${sha256}`;
try {
const res = await fetch(url);
if (res.ok) return res;
if (res.ok) {
preferredServer = server;
return res;
}
} catch (err) {
lastError = err;
}
return null;
}
// Try the preferred server first if it's in the list.
if (preferredServer && servers.includes(preferredServer)) {
const res = await tryServer(preferredServer);
if (res) return res;
}
// Fall through to the full list, skipping the preferred (already tried).
for (const server of servers) {
if (server === preferredServer) continue;
const res = await tryServer(server);
if (res) return res;
}
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
}
/** Max concurrent Blossom fetches during pre-fetch. */
const PREFETCH_CONCURRENCY = 12;
/**
* Pre-fetch all unique blobs from the manifest into an in-memory cache.
*
* **Android only.** Android's WebView uses `shouldInterceptRequest` which
* blocks a pool of ~6 IO threads via `CountDownLatch` until JS responds.
* If each response requires a network round-trip to Blossom, the 6-at-a-time
* serialisation makes loading 200+ files extremely slow. By downloading
* every blob *before* the WebView starts loading, each bridge round-trip
* drops from seconds (network) to ~1-5ms (memory).
*
* iOS does NOT need this — `WKURLSchemeHandler` is fully async and can
* handle many concurrent requests without any thread pool bottleneck.
*
* Uses bounded concurrency to saturate the network without overwhelming it.
*/
async function prefetchAllBlobs(
manifest: Map<string, string>,
servers: string[],
cache: Map<string, Uint8Array>,
): Promise<void> {
// Deduplicate — many paths may share the same hash (e.g. SPA fallbacks).
const uniqueHashes = [...new Set(manifest.values())];
// Skip hashes already in the cache (e.g. from a previous open).
const toFetch = uniqueHashes.filter((h) => !cache.has(h));
if (toFetch.length === 0) return;
let cursor = 0;
const total = toFetch.length;
async function worker(): Promise<void> {
while (cursor < total) {
const idx = cursor++;
const sha256 = toFetch[idx];
try {
const res = await fetchFromBlossom(sha256, servers);
const buffer = await res.arrayBuffer();
cache.set(sha256, new Uint8Array(buffer));
} catch {
// Non-fatal — resolveFile will fetch on demand for cache misses.
}
}
}
const workers = Array.from(
{ length: Math.min(PREFETCH_CONCURRENCY, total) },
() => worker(),
);
await Promise.all(workers);
}
interface NsitePreviewDialogProps {
/** The nsite event (kind 15128 or 35128) containing path and server tags. */
event: NostrEvent;
@@ -113,17 +204,32 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
const centerColumn = useCenterColumn();
const columnRect = useElementRect(open ? centerColumn : null);
const { config } = useAppContext();
const { user } = useCurrentUser();
// Use the NIP-5A canonical subdomain as the stable identifier, then derive
// a private HMAC-SHA256 subdomain so the raw identifier is never exposed as
// a sandbox origin (preventing cross-app localStorage/IndexedDB collisions).
const nsiteSubdomain = getNsiteSubdomain(event);
const siteUrl = `https://${nsiteSubdomain}.nsite.lol`;
const previewSubdomain = useMemo(() => deriveIframeSubdomain('nsite', nsiteSubdomain), [nsiteSubdomain]);
// NIP-07 signer proxy — only active when a user is logged in.
const signerRpc = useNsiteSignerRpc({
siteId: nsiteSubdomain,
siteName: appName,
});
// Build the manifest and server list from the event (memoised per event identity)
const manifest = useRef<Map<string, string>>(new Map());
const servers = useRef<string[]>([]);
/**
* 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(
@@ -133,11 +239,44 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
servers.current = resolveServers(event, appServers.length > 0 ? appServers : APP_BLOSSOM_SERVERS.servers);
}, [event, config.blossomServerMetadata, config.useAppBlossomServers]);
/** Injected scripts: just the path normalisation snippet for SPA support. */
const injectedScripts = useMemo<InjectedScript[]>(() => [{
path: '__injected__/preview.js',
content: getPreviewInjectedScript(),
}], []);
/** Injected scripts: SPA path normalisation + NIP-07 provider (when logged in). */
const injectedScripts = useMemo<InjectedScript[]>(() => {
const scripts: InjectedScript[] = [{
path: '__injected__/preview.js',
content: getPreviewInjectedScript(),
}];
// When a user is logged in, inject a NIP-07 provider so the nsite can
// use window.nostr to interact with the user's signer.
if (user) {
scripts.push({
path: '__injected__/nostr-provider.js',
content: getNsiteNostrProviderScript(user.pubkey),
});
}
return scripts;
}, [user]);
/**
* Called by SandboxFrame before the native WebView is created.
*
* On Android: blocks until all blobs are pre-fetched. Android's WebView
* uses `shouldInterceptRequest` which blocks ~6 IO threads — if each
* response requires a network fetch the whole thing is painfully slow.
* The native ProgressBar spinner (render thread) stays visible and
* animating during the download. Once the WebView starts, every
* resolveFile call is an instant cache hit.
*
* On iOS: no-op. WKURLSchemeHandler is async and handles concurrent
* requests without a thread pool bottleneck.
*
* On web: no-op. iframe.diy's service worker handles fetches efficiently.
*/
const onReady = useCallback(async () => {
if (Capacitor.getPlatform() !== 'android') return;
await prefetchAllBlobs(manifest.current, servers.current, blobCache.current);
}, []);
/** Resolve a pathname to file content from the Blossom manifest. */
const resolveFile = useCallback(async (pathname: string): Promise<FileResponse | null> => {
@@ -153,11 +292,21 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
if (!sha256) return null;
// Fetch the blob from Blossom, trying each server in order.
// Serve from cache if available (pre-fetched on Android).
const cached = blobCache.current.get(sha256);
if (cached) {
const contentType = getMimeType(servingPath);
return { status: 200, contentType, body: cached };
}
// Cache miss — fetch from Blossom (normal path on iOS/web).
const res = await fetchFromBlossom(sha256, servers.current);
const buffer = await res.arrayBuffer();
const body = new Uint8Array(buffer);
// Store in cache for future requests (e.g. SPA navigations).
blobCache.current.set(sha256, body);
// Always determine content type from the file extension.
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
// files), which causes browsers to reject module scripts. The file path from
@@ -186,45 +335,67 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
}}
>
{/* Nav bar */}
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{appPicture ? (
<img
src={appPicture}
alt={appName}
className="size-6 rounded-md object-cover shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Package className="size-3.5 text-primary/50" />
</div>
)}
<span className="text-sm font-medium truncate">{appName}</span>
</div>
<div className="min-h-11 border-b bg-muted/30 shrink-0 safe-area-top">
<div className="px-3 py-2 flex items-center gap-2 w-full">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{appPicture ? (
<img
src={appPicture}
alt={appName}
className="size-6 rounded-md object-cover shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<ExternalFavicon
url={siteUrl}
size={18}
fallback={<Package className="size-3.5 text-primary/50" />}
/>
</div>
)}
<span className="text-sm font-medium truncate">{appName}</span>
</div>
{/* Close */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => onOpenChange(false)}
title="Close"
>
<X className="size-3.5" />
</Button>
{/* Permissions + Close */}
{user && (
<NsitePermissionManager siteId={nsiteSubdomain} siteName={appName} />
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => onOpenChange(false)}
title="Close"
>
<X className="size-3.5" />
</Button>
</div>
</div>
{/* Sandboxed iframe */}
<div className="flex-1 min-h-0 bg-background">
<div className="flex-1 min-h-0 bg-background relative">
<SandboxFrame
key={`${previewSubdomain}-${open}`}
id={previewSubdomain}
resolveFile={resolveFile}
onReady={onReady}
onRpc={user ? signerRpc.onRpc : undefined}
injectedScripts={injectedScripts}
className="w-full h-full border-0"
title={`${appName} preview`}
/>
{/* Permission prompt overlay */}
{signerRpc.pendingPrompt && (
<NsitePermissionPrompt
appPicture={appPicture}
appName={appName}
siteUrl={siteUrl}
prompt={signerRpc.pendingPrompt}
onResolve={signerRpc.resolvePrompt}
/>
)}
</div>
</div>,
document.body,
+154
View File
@@ -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>
);
}
+10 -6
View File
@@ -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}>
+13 -12
View File
@@ -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>
+92 -410
View File
@@ -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">&mdash;</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>
);
}
+137 -160
View File
@@ -1,5 +1,6 @@
import {
useRef,
useState,
useEffect,
useCallback,
useMemo,
@@ -9,6 +10,7 @@ import {
} from 'react';
import { Capacitor } from '@capacitor/core';
import { Loader2 } from 'lucide-react';
import { useAppContext } from '@/hooks/useAppContext';
import {
@@ -25,7 +27,6 @@ import type {
import {
SandboxPlugin,
type SandboxFetchEvent,
type SandboxScriptMessageEvent,
} from '@/lib/sandboxPlugin';
// ---------------------------------------------------------------------------
@@ -324,6 +325,7 @@ const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
<iframe
ref={iframeRef}
src={`${origin}/`}
allow="clipboard-write"
{...iframeProps}
/>
);
@@ -331,17 +333,37 @@ const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
);
// ---------------------------------------------------------------------------
// Native (Capacitor) implementation
// Native (Capacitor) implementation — uses a real <iframe> served by native
// ---------------------------------------------------------------------------
/**
* Compute the iframe origin for native platforms.
*
* - iOS: `sbx://<sandbox-id>` — intercepted by the `SandboxRequestHandler`
* registered on the WKWebView configuration as a `WKURLSchemeHandler` for
* the `sbx://` custom scheme. Each sandbox ID is a unique origin, giving
* full localStorage / IndexedDB / cookie isolation.
* - Android: `https://<sandbox-id>.sandbox.native` — intercepted by the
* custom BridgeWebViewClient subclass.
*/
function getNativeOrigin(id: string): string {
if (Capacitor.getPlatform() === 'ios') {
return `sbx://${id}`;
}
// Android
return `https://${id}.sandbox.native`;
}
const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
function SandboxFrameNative(
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, className, style, title },
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, className, style, ...iframeProps },
ref,
) {
const placeholderRef = useRef<HTMLDivElement>(null);
const createdRef = useRef(false);
const destroyedRef = useRef(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const readyRef = useRef(false);
const [loading, setLoading] = useState(true);
const origin = useMemo(() => getNativeOrigin(id), [id]);
// Keep latest callbacks in refs.
const resolveFileRef = useRef(resolveFile);
@@ -357,17 +379,14 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
// -----------------------------------------------------------------
// Post a message into the native sandbox
// Post a message to the iframe via postMessage
// -----------------------------------------------------------------
const postToSandbox = useCallback(
const post = useCallback(
(msg: Record<string, unknown>) => {
if (!createdRef.current || destroyedRef.current) return;
SandboxPlugin.postMessage({ id, message: msg }).catch((err) => {
console.error('[SandboxFrame] postMessage failed:', err);
});
iframeRef.current?.contentWindow?.postMessage(msg, origin);
},
[id],
[origin],
);
// Expose imperative handle.
@@ -375,50 +394,27 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
ref,
() => ({
postMessage: (msg: Record<string, unknown>) => {
postToSandbox(msg);
post(msg);
},
focus: () => {
// No-op on native — the WebView is overlaid, not an iframe.
iframeRef.current?.focus();
},
}),
[postToSandbox],
[post],
);
// -----------------------------------------------------------------
// Lifecycle: onReady -> create WebView -> listen for events -> destroy
// Handle fetch events from the native scheme handler
// -----------------------------------------------------------------
useEffect(() => {
if (createdRef.current) return;
const listeners: Array<{ remove: () => void }> = [];
let cancelled = false;
const listeners: Array<{ remove: () => void }> = [];
async function setup() {
// Run onReady first so the consumer can prepare (e.g. download and
// unzip a .xdc archive) before the native WebView starts loading
// resources. This mirrors the web behaviour where onReady runs
// before `init` is sent.
try {
await onReadyRef.current?.();
} catch (err) {
console.error('[SandboxFrame] onReady failed:', err);
}
if (cancelled || destroyedRef.current) return;
// Measure the placeholder position.
const el = placeholderRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
// Register listeners BEFORE creating the WebView. On Android,
// `shouldInterceptRequest` fires on a background thread as soon
// as the WebView starts loading — if the fetch listener isn't
// registered yet, the event is lost and the request times out
// (the thread blocks via CountDownLatch waiting for a response
// that never arrives).
// Register the fetch listener BEFORE doing anything else.
// On Android, shouldInterceptRequest fires on a background thread
// as soon as the iframe src is set — the listener must be ready.
const fetchListener = await SandboxPlugin.addListener(
'fetch',
(event: SandboxFetchEvent) => {
@@ -428,41 +424,45 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
);
listeners.push(fetchListener);
const scriptListener = await SandboxPlugin.addListener(
'scriptMessage',
(event: SandboxScriptMessageEvent) => {
if (event.id !== id) return;
handleNativeScriptMessage(event);
},
);
listeners.push(scriptListener);
if (cancelled) return;
if (cancelled || destroyedRef.current) return;
// Create the native WebView. Fetch events from the initial load
// will be handled by the listeners registered above.
await SandboxPlugin.create({
id,
frame: {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
});
if (cancelled || destroyedRef.current) {
// Component unmounted while we were awaiting — clean up immediately.
SandboxPlugin.destroy({ id }).catch(() => {});
return;
// Run onReady (e.g. Android pre-fetches all blobs here).
try {
await onReadyRef.current?.();
} catch (err) {
console.error('[SandboxFrame] onReady failed:', err);
}
createdRef.current = true;
}
if (cancelled) return;
// ---------------------------------------------------------------
// Handle a fetch request from the native WebView
// ---------------------------------------------------------------
// 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;
@@ -471,9 +471,6 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
try {
pathname = new URL(reqUrl).pathname;
} catch {
// The native handler rewrites custom-scheme URLs to
// https://<id>.sandbox.native/<path> so we can parse them.
// If that fails, try extracting the path directly.
const pathMatch = reqUrl.match(/\/\/[^/]+(\/.*)/);
pathname = pathMatch?.[1] ?? '/';
}
@@ -485,7 +482,6 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
cspRef.current,
(result) => {
SandboxPlugin.respondToFetch({
id,
requestId: event.requestId,
response: result as {
status: number;
@@ -499,7 +495,6 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
},
(_code, message) => {
SandboxPlugin.respondToFetch({
id,
requestId: event.requestId,
response: {
status: 500,
@@ -514,104 +509,78 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
);
}
// ---------------------------------------------------------------
// Handle a script message from the native WebView
// ---------------------------------------------------------------
async function handleNativeScriptMessage(event: SandboxScriptMessageEvent) {
const msg = event.message;
if (!msg || typeof msg !== 'object') return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rpc = msg as any;
if (rpc.jsonrpc !== '2.0') return;
// Handle RPC requests (have both `id` and `method`).
if (rpc.id !== undefined && rpc.method && onRpcRef.current) {
try {
const result = await onRpcRef.current(
rpc.method,
rpc.params ?? {},
postToSandbox,
);
postToSandbox({
jsonrpc: '2.0',
id: rpc.id,
result: result ?? null,
});
} catch (err) {
postToSandbox({
jsonrpc: '2.0',
id: rpc.id,
error: { code: -1, message: String(err) },
});
}
}
}
setup().catch((err) => {
console.error('[SandboxFrame] native setup failed:', err);
});
return () => {
cancelled = true;
destroyedRef.current = true;
for (const listener of listeners) {
listener.remove();
}
if (createdRef.current) {
SandboxPlugin.destroy({ id }).catch((err) => {
console.error('[SandboxFrame] destroy failed:', err);
});
createdRef.current = false;
}
};
}, [id, postToSandbox]);
}, [id, origin]);
// -----------------------------------------------------------------
// Keep frame in sync with placeholder size/position
//
// Both consumers (WebxdcEmbed, NsitePreviewDialog) render inside
// position:fixed panels, so the placeholder never moves on scroll.
// A ResizeObserver is sufficient to track layout changes.
// Listen for postMessage from the iframe (RPC from injected scripts)
// -----------------------------------------------------------------
useEffect(() => {
const el = placeholderRef.current;
if (!el) return;
function onMessage(event: MessageEvent) {
// On iOS the origin is "sbx://<id>",
// on Android "https://<id>.sandbox.native".
if (event.origin !== origin) return;
if (event.source !== iframeRef.current?.contentWindow) return;
function updateFrame() {
if (!createdRef.current || destroyedRef.current) return;
const rect = el!.getBoundingClientRect();
SandboxPlugin.updateFrame({
id,
frame: {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
}).catch(() => {
// Ignore — WebView may not be created yet.
});
const msg = event.data;
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
// Handle RPC requests from injected scripts.
if (msg.id !== undefined && msg.method && onRpcRef.current) {
handleRpc(msg.id, msg.method, msg.params ?? {});
}
}
const ro = new ResizeObserver(updateFrame);
ro.observe(el);
async function handleRpc(
rpcId: string | number,
method: string,
params: unknown,
) {
try {
const result = await onRpcRef.current!(method, params, post);
post({ jsonrpc: '2.0', id: rpcId, result: result ?? null });
} catch (err) {
post({
jsonrpc: '2.0',
id: rpcId,
error: { code: -1, message: String(err) },
});
}
}
return () => {
ro.disconnect();
};
}, [id]);
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [origin, post]);
// Hide the spinner once the iframe fires its load event (initial HTML parsed).
const handleLoad = useCallback(() => setLoading(false), []);
// Don't set src initially — it's set after onReady completes in setup().
return (
<div
ref={placeholderRef}
className={className}
style={style}
title={title}
data-sandbox-id={id}
/>
<div className={className} style={{ ...style, position: 'relative' }}>
<iframe
ref={iframeRef}
onLoad={handleLoad}
allow="clipboard-write"
style={{ width: '100%', height: '100%', border: 'none' }}
{...iframeProps}
/>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 className="size-10 animate-spin text-primary/70" />
</div>
)}
</div>
);
},
);
@@ -626,9 +595,17 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
* On web, this creates an iframe on a unique subdomain (`<id>.<sandboxDomain>`)
* and implements the iframe.diy handshake + fetch proxy protocol.
*
* On native platforms (iOS/Android via Capacitor), this creates a native
* WKWebView/WebView overlay with a custom URL scheme handler that intercepts
* all requests and routes them through the same `resolveFile` callback.
* On native platforms (iOS/Android via Capacitor), this creates a regular
* `<iframe>` element whose requests are intercepted by native code:
* - iOS: `WKURLSchemeHandler` for the `sbx://` custom scheme, registered
* on the WKWebView configuration. Each sandbox loads from
* `sbx://<sandbox-id>/path` with full origin isolation.
* - Android: Custom BridgeWebViewClient intercepting `*.sandbox.native`
*
* Each sandbox gets a unique origin (via hostname or scheme+host), so
* localStorage/IndexedDB are isolated per sandbox. Since the sandbox is a
* regular DOM element, web UI (permission dialogs, popovers) naturally
* layers on top.
*
* All file serving is delegated to the `resolveFile` callback.
* Custom RPC methods are delegated to the optional `onRpc` callback.
+17 -1
View File
@@ -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
/>
+15 -1
View File
@@ -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
+2 -2
View File
@@ -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.
+38 -36
View File
@@ -127,43 +127,45 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
onClick={(e) => e.stopPropagation()}
>
{/* Nav bar */}
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{icon ? (
<img
src={icon}
alt={name ?? 'Webxdc App'}
className="size-6 rounded-md object-cover shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Blocks className="size-3.5 text-primary/50" />
</div>
)}
<span className="text-sm font-medium truncate">{name ?? 'Webxdc App'}</span>
</div>
<div className="min-h-11 border-b bg-muted/30 shrink-0 safe-area-top">
<div className="px-3 py-2 flex items-center gap-2 w-full">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{icon ? (
<img
src={icon}
alt={name ?? 'Webxdc App'}
className="size-6 rounded-md object-cover shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Blocks className="size-3.5 text-primary/50" />
</div>
)}
<span className="text-sm font-medium truncate">{name ?? 'Webxdc App'}</span>
</div>
{/* Controls */}
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
className={cn('h-7 w-7 p-0 shrink-0', showGamepad && 'text-primary')}
onClick={toggleGamepad}
title={showGamepad ? 'Hide gamepad' : 'Show gamepad'}
>
<Gamepad2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={handleClose}
title="Close"
>
<X className="size-3.5" />
</Button>
{/* Controls */}
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
className={cn('h-7 w-7 p-0 shrink-0', showGamepad && 'text-primary')}
onClick={toggleGamepad}
title={showGamepad ? 'Hide gamepad' : 'Show gamepad'}
>
<Gamepad2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={handleClose}
title="Close"
>
<X className="size-3.5" />
</Button>
</div>
</div>
</div>
+3 -2
View File
@@ -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');
+3 -2
View File
@@ -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');
+27 -47
View File
@@ -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"
+17 -15
View File
@@ -2,7 +2,7 @@
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
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";
@@ -11,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';
@@ -38,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') {
@@ -54,17 +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);
// 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',
});
}
@@ -161,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">
@@ -192,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'>
@@ -206,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>
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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}
+23
View File
@@ -0,0 +1,23 @@
import { createContext, useContext } from 'react';
/**
* Tracks which nsite (by subdomain) currently has its player open.
* Used by the sidebar to highlight the active nsite item, and by
* NsiteCard to register/unregister the open player.
*/
export interface NsitePlayerState {
/** The subdomain of the currently-open nsite player, or null. */
activeSubdomain: string | null;
/** Set the active nsite subdomain (call with null to clear). */
setActiveSubdomain: (subdomain: string | null) => void;
}
export const NsitePlayerContext = createContext<NsitePlayerState>({
activeSubdomain: null,
setActiveSubdomain: () => {},
});
/** Hook to read/write the active nsite player subdomain. */
export function useNsitePlayer(): NsitePlayerState {
return useContext(NsitePlayerContext);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { type FeedSettings } from "@/contexts/AppContext";
import { useAppContext } from "@/hooks/useAppContext";
import { useEncryptedSettings } from "@/hooks/useEncryptedSettings";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { SIDEBAR_ITEMS, SIDEBAR_ITEM_IDS, SIDEBAR_DIVIDER_ID, isNostrUri, isExternalUri } 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
+277
View File
@@ -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 };
}
+79
View File
@@ -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 };
}
+3 -1
View File
@@ -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
View File
@@ -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
-59
View File
@@ -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);
},
};
}
+164
View File
@@ -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(() => {});
}
+15 -3
View File
@@ -11,6 +11,17 @@
import type { ThemeFont } from '@/themes';
import { findBundledFont, loadBundledFont, resolveCssFamily } from '@/lib/fonts';
// ─── CSS string sanitisation ──────────────────────────────────────────
/**
* Sanitize a string for safe interpolation into a double-quoted CSS context.
* Uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens,
* underscores, apostrophes, and periods are permitted. Everything else is stripped.
*/
function sanitizeCssString(value: string): string {
return value.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
}
// ─── @font-face injection for remote fonts ────────────────────────────
/** Style element ID for injected @font-face rules. */
@@ -33,9 +44,10 @@ function injectFontFace(family: string, url: string): void {
document.head.appendChild(style);
}
const safeFamily = sanitizeCssString(family);
const rule = `
@font-face {
font-family: "${family}";
font-family: "${safeFamily}";
src: url("${url}");
font-display: swap;
}`;
@@ -73,7 +85,7 @@ export function applyFontOverride(font: ThemeFont | undefined): void {
document.head.appendChild(style);
}
const cssFamily = resolveCssFamily(font.family);
const cssFamily = sanitizeCssString(resolveCssFamily(font.family));
style.textContent = `html { font-family: "${cssFamily}", ${DEFAULT_FONT_STACK} !important; }\n`;
}
@@ -133,7 +145,7 @@ export function applyTitleFontOverride(font: ThemeFont | undefined): void {
document.head.appendChild(style);
}
const cssFamily = resolveCssFamily(font.family);
const cssFamily = sanitizeCssString(resolveCssFamily(font.family));
style.textContent = `:root { --title-font-family: "${cssFamily}", ${DEFAULT_FONT_STACK}; }\n`;
}
+395
View File
@@ -0,0 +1,395 @@
/**
* Central registry of Nostr event kind labels.
*
* This is the single source of truth for kind → human-readable label mappings.
* All other files that need kind labels should import from here rather than
* maintaining their own maps.
*
* Sources:
* - NIP README kinds table (https://github.com/nostr-protocol/nips)
* - Ditto reference (https://about.ditto.pub/reference)
* - Existing codebase registries (consolidated)
*
* Labels are bare noun phrases (no articles, no verbs) so each consumer can
* add its own grammar:
* - NsitePermissionPrompt: "Sign: Short text note"
* - CommentContext: "a short text note"
* - NotificationsPage: "reacted to your short text note"
* - signerWithNudge: "Approve post in signer" (uses its own override)
*/
// ---------------------------------------------------------------------------
// The registry
// ---------------------------------------------------------------------------
/** Map of every known Nostr event kind to a short human-readable label. */
export const KIND_LABELS: Record<number, string> = {
// NIP-01 core
0: 'Profile',
1: 'Short text note',
2: 'Recommend relay',
3: 'Follows',
4: 'Encrypted message',
5: 'Deletion',
6: 'Repost',
7: 'Reaction',
8: 'Badge award',
9: 'Chat message',
10: 'Group chat threaded reply',
11: 'Thread',
12: 'Group thread reply',
13: 'Seal',
14: 'Direct message',
15: 'File message',
16: 'Generic repost',
17: 'Reaction to a website',
20: 'Photo',
21: 'Video',
22: 'Short video',
24: 'Public message',
// NKBIP-03
30: 'Internal reference',
31: 'External web reference',
32: 'Hardcopy reference',
33: 'Prompt reference',
// NIP-28 Public Chat
40: 'Channel creation',
41: 'Channel metadata',
42: 'Channel message',
43: 'Channel hide message',
44: 'Channel mute user',
// NIP-62
62: 'Request to vanish',
// NIP-64
64: 'Chess (PGN)',
// Marmot
443: 'KeyPackage',
444: 'Welcome message',
445: 'Group event',
// NIP-54
818: 'Merge request',
// NIP-88 Poll
1018: 'Poll vote',
// NIP-15 Marketplace
1021: 'Bid',
1022: 'Bid confirmation',
// NIP-03
1040: 'OpenTimestamps',
// NIP-59
1059: 'Gift wrap',
// NIP-94
1063: 'File metadata',
// NIP-88
1068: 'Poll',
// NIP-22
1111: 'Comment',
// NIP-A0 Voice
1222: 'Voice message',
1244: 'Voice message comment',
// NIP-53 Live
1311: 'Live chat message',
// NIP-C0
1337: 'Code snippet',
// NIP-34 Git
1617: 'Patch',
1618: 'Pull request',
1619: 'Pull request update',
1621: 'Issue',
1622: 'Git reply',
1630: 'Git status (open)',
1631: 'Git status (applied)',
1632: 'Git status (closed)',
1633: 'Git status (draft)',
// Nostrocket
1971: 'Problem tracker',
// NIP-56
1984: 'Report',
// NIP-32
1985: 'Label',
// Relay reviews
1986: 'Relay review',
// AI embeddings
1987: 'AI embeddings',
// NIP-35 Torrents
2003: 'Torrent',
2004: 'Torrent comment',
// Coinjoin
2022: 'Coinjoin pool',
// NIP-82 (Zapstore)
3063: 'Zapstore asset',
// Ditto custom kinds
3367: 'Color moment',
// NIP-72
4550: 'Community post approval',
// NIP-90 DVM (ranges)
5000: 'Job request',
6000: 'Job result',
7000: 'Job feedback',
// NIP-60 Cashu
7374: 'Reserved Cashu wallet tokens',
7375: 'Cashu wallet tokens',
7376: 'Cashu wallet history',
// Geocaching
7516: 'Found log',
7517: 'Geocache proof of find',
// NIP-43
8000: 'Add user',
8001: 'Remove user',
// Ditto letters
8211: 'Letter',
// NIP-29 Group control (range)
9000: 'Group control event',
// NIP-75
9041: 'Zap goal',
// NIP-61
9321: 'Nutzap',
// Tidal
9467: 'Tidal login',
// NIP-57 Zaps
9734: 'Zap request',
9735: 'Zap',
// NIP-84
9802: 'Highlight',
// ---- Replaceable events (10000+) ----
// NIP-51 Lists
10000: 'Mute list',
10001: 'Pin list',
// NIP-65
10002: 'Relay list',
// NIP-51
10003: 'Bookmark list',
10004: 'Communities list',
10005: 'Public chats list',
10006: 'Blocked relays list',
10007: 'Search relays list',
// NIP-58
10008: 'Profile badges',
// NIP-29
10009: 'User groups',
// NIP-39
10011: 'External identities',
// NIP-51
10012: 'Favorite relays list',
// NIP-37
10013: 'Private event relay list',
// NIP-51
10015: 'Interests list',
// NIP-61
10019: 'Nutzap mint recommendation',
// NIP-51
10020: 'Media follows',
10030: 'Emoji list',
// NIP-17
10050: 'DM relay list',
// Marmot
10051: 'KeyPackage relays list',
// Blossom
10063: 'Blossom server list',
// NIP-96 (deprecated)
10096: 'File storage server list',
// NIP-66
10166: 'Relay monitor announcement',
// NIP-53
10312: 'Room presence',
// Nostr Epoxy
10377: 'Proxy announcement',
11111: 'Transport method announcement',
// Bookstr
10073: 'Read books',
10074: 'Currently reading',
10075: 'To be read',
// Blobbi
11125: 'Blobbonaut profile',
// NIP-47 Wallet
13194: 'Wallet info',
// NIP-43
13534: 'Membership lists',
// Corny Chat
14388: 'User sound effect lists',
// Blobbi
14919: 'Blobbi interaction',
14920: 'Blobbi breeding',
14921: 'Blobbi record',
// NIP-5A nsites
15128: 'Nsite',
// Weather station
16158: 'Weather station',
// Theme
16767: 'Active profile theme',
// Profile tabs
16769: 'Profile tabs',
// NIP-60
17375: 'Cashu wallet event',
// Lightning.Pub
21000: 'Lightning Pub RPC',
// NIP-42
22242: 'Client authentication',
// NIP-47
23194: 'Wallet request',
23195: 'Wallet response',
// NIP-46
24133: 'Nostr Connect',
// Blossom
24242: 'Blob stored on mediaserver',
// NIP-98
27235: 'HTTP auth',
// NIP-43
28934: 'Join request',
28935: 'Invite request',
28936: 'Leave request',
// Webxdc
4932: 'Webxdc sync',
20932: 'Webxdc sync',
// ---- Addressable events (30000+) ----
// NIP-51 Sets
30000: 'Follow set',
30001: 'Generic list',
30002: 'Relay set',
30003: 'Bookmark set',
30004: 'Curation set',
30005: 'Video set',
30006: 'Picture set',
30007: 'Kind mute set',
// NIP-58
30008: 'Badge set',
30009: 'Badge definition',
// NIP-51
30015: 'Interest set',
// NIP-15 Marketplace
30017: 'Stall',
30018: 'Product',
30019: 'Marketplace UI/UX',
30020: 'Auction product',
// NIP-23
30023: 'Article',
30024: 'Draft long-form content',
// NIP-30
30030: 'Emoji set',
// NKBIP-01
30040: 'Curated publication index',
30041: 'Curated publication content',
// NIP-82 (Zapstore)
30063: 'Zapstore release',
// NIP-78
30078: 'App settings',
// NIP-66
30166: 'Relay discovery',
// NIP-51
30267: 'App curation set',
// NIP-53
30311: 'Live event',
30312: 'Interactive room',
30313: 'Conference event',
// NIP-38
30315: 'User status',
// NIP-85
30382: 'User trusted assertion',
30383: 'Event trusted assertion',
30384: 'Addressable event trusted assertion',
// Corny Chat
30388: 'Slide set',
// NIP-99
30402: 'Classified listing',
30403: 'Draft classified listing',
// Podcasts
30054: 'Podcast episode',
30055: 'Podcast trailer',
// NIP-34 Git
30617: 'Repository',
30618: 'Repository state',
// NIP-54 Wiki
30818: 'Wiki article',
30819: 'Wiki redirect',
// Custom NIP
30817: 'Custom NIP',
// NIP-37
31234: 'Draft event',
// Corny Chat
31388: 'Link set',
// Custom Feeds
31890: 'Feed',
// NIP-52 Calendar
31922: 'Date calendar event',
31923: 'Time calendar event',
31924: 'Calendar',
31925: 'Calendar event RSVP',
// NIP-89
31989: 'App recommendation',
31990: 'App',
// Bookstr
31985: 'Book review',
// Blobbi
31124: 'Blobbi',
// Zapstore
32267: 'Zapstore app',
// Corny Chat
32388: 'User room favorites',
33388: 'High scores',
// NIP-71
34235: 'Addressable video',
34236: 'Addressable short video',
// Corny Chat
34388: 'Sound effects',
// Music
34139: 'Music playlist',
// NIP-72
34550: 'Community definition',
// NIP-5A
34128: 'Nsite (legacy)',
35128: 'Nsite',
// Theme
36767: 'Theme definition',
// Music
36787: 'Music track',
// Ditto custom
37381: 'Magic deck',
37516: 'Geocache listing',
// NIP-87
38172: 'Cashu mint announcement',
38173: 'Fedimint announcement',
// NIP-69
38383: 'Peer-to-peer order',
// NIP-51
39089: 'Follow pack',
39092: 'Media follow pack',
// NIP-B0
39701: 'Web bookmark',
};
// ---------------------------------------------------------------------------
// Lookup function
// ---------------------------------------------------------------------------
/**
* Get the human-readable label for a Nostr event kind.
*
* Falls back to `"Kind <n>"` for unknown kinds, unless a custom fallback
* is provided.
*/
export function getKindLabel(kind: number, fallback?: string): string {
return KIND_LABELS[kind] ?? fallback ?? `Kind ${kind}`;
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Generates the injected NIP-07 provider script for nsite sandboxed iframes.
*
* The script defines `window.nostr` conforming to the NIP-07 interface.
* `getPublicKey()` returns the embedded pubkey instantly (always allowed).
* All other methods (`signEvent`, `nip04.*`, `nip44.*`) send JSON-RPC 2.0
* requests to the parent frame via `postMessage` and await responses.
*
* A serial queue ensures only one RPC is in flight at a time, preventing
* the parent from being overwhelmed with concurrent permission prompts.
*/
export function getNsiteNostrProviderScript(pubkey: string): string {
return `(function() {
'use strict';
// ------------------------------------------------------------------
// Serial queue — one RPC at a time to avoid concurrent prompts
// ------------------------------------------------------------------
var _queue = [];
var _running = false;
function enqueue(fn) {
return new Promise(function(resolve, reject) {
_queue.push({ fn: fn, resolve: resolve, reject: reject });
drain();
});
}
function drain() {
if (_running || _queue.length === 0) return;
_running = true;
var item = _queue.shift();
item.fn().then(
function(v) { _running = false; item.resolve(v); drain(); },
function(e) { _running = false; item.reject(e); drain(); }
);
}
// ------------------------------------------------------------------
// JSON-RPC transport over postMessage
// ------------------------------------------------------------------
var _nextId = 1;
var _pending = {};
window.addEventListener('message', function(event) {
var msg = event.data;
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
if (msg.id === undefined || msg.id === null) return;
var cb = _pending[msg.id];
if (!cb) return;
delete _pending[msg.id];
if (msg.error) {
cb.reject(new Error(msg.error.message || 'RPC error'));
} else {
cb.resolve(msg.result);
}
});
function rpc(method, params) {
return enqueue(function() {
return new Promise(function(resolve, reject) {
var id = _nextId++;
_pending[id] = { resolve: resolve, reject: reject };
window.parent.postMessage({
jsonrpc: '2.0',
id: id,
method: method,
params: params || {}
}, '*');
});
});
}
// ------------------------------------------------------------------
// NIP-07 provider
// ------------------------------------------------------------------
var pubkey = ${JSON.stringify(pubkey)};
window.nostr = {
getPublicKey: function() {
return Promise.resolve(pubkey);
},
signEvent: function(event) {
return rpc('nostr.signEvent', { event: event });
},
getRelays: function() {
return Promise.resolve({});
},
nip04: {
encrypt: function(pubkey, plaintext) {
return rpc('nostr.nip04.encrypt', { pubkey: pubkey, plaintext: plaintext });
},
decrypt: function(pubkey, ciphertext) {
return rpc('nostr.nip04.decrypt', { pubkey: pubkey, ciphertext: ciphertext });
}
},
nip44: {
encrypt: function(pubkey, plaintext) {
return rpc('nostr.nip44.encrypt', { pubkey: pubkey, plaintext: plaintext });
},
decrypt: function(pubkey, ciphertext) {
return rpc('nostr.nip44.decrypt', { pubkey: pubkey, ciphertext: ciphertext });
}
}
};
// Signal availability to the nsite.
try {
window.dispatchEvent(new Event('nostr:ready'));
} catch(e) {}
})();`;
}
+238
View File
@@ -0,0 +1,238 @@
/**
* Permission model and localStorage persistence for nsite NIP-07 signer proxy.
*
* Permissions are scoped to (userPubkey, siteId) and are granular:
* - `signEvent` permissions are stored per event kind
* - Encryption/decryption permissions are stored per operation type
*
* `getPublicKey` is always allowed (clicking "Run" implies consent) and is
* not tracked in this system.
*/
import { getKindLabel } from '@/lib/kindLabels';
// Re-export so existing consumers of `getKindLabel` from this module keep working.
export { getKindLabel } from '@/lib/kindLabels';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Operations that require permission. `getPublicKey` is always allowed. */
export type NsitePermissionType =
| 'signEvent'
| 'nip04.encrypt'
| 'nip04.decrypt'
| 'nip44.encrypt'
| 'nip44.decrypt';
/** A single remembered permission decision. */
export interface NsitePermission {
/** Operation type. */
type: NsitePermissionType;
/** Event kind — only meaningful for `signEvent`, null otherwise. */
kind: number | null;
/** Whether this operation is allowed. */
allowed: boolean;
}
/** All remembered permissions for one (user, site) pair. */
export interface NsiteAllowance {
/** Canonical nsite subdomain identifier (from `getNsiteSubdomain`). */
siteId: string;
/** Human-readable site name. */
siteName: string;
/** Hex pubkey of the user who granted the permissions. */
userPubkey: string;
/** Individual permission decisions. */
permissions: NsitePermission[];
/** Unix timestamp (ms) when this allowance was first created. */
createdAt: number;
}
// ---------------------------------------------------------------------------
// Storage helpers
// ---------------------------------------------------------------------------
const STORAGE_KEY = 'nostr:nsite-permissions';
/** Read all allowances from localStorage. */
function readAllowances(): NsiteAllowance[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/** Write all allowances to localStorage and notify same-tab subscribers. */
function writeAllowances(allowances: NsiteAllowance[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(allowances));
// The `storage` event only fires across tabs. Dispatch a custom event so
// same-tab subscribers (e.g. NsitePermissionManager) also re-render.
window.dispatchEvent(new Event('nsite-permissions-changed'));
}
/** Find the allowance for a specific (siteId, userPubkey) pair. */
function findAllowance(
allowances: NsiteAllowance[],
siteId: string,
userPubkey: string,
): NsiteAllowance | undefined {
return allowances.find(
(a) => a.siteId === siteId && a.userPubkey === userPubkey,
);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Look up a stored permission decision.
*
* @returns `'allow'` or `'deny'` if remembered, `'ask'` if no decision stored.
*/
export function getNsitePermission(
siteId: string,
userPubkey: string,
type: NsitePermissionType,
kind: number | null = null,
): 'allow' | 'deny' | 'ask' {
const allowances = readAllowances();
const allowance = findAllowance(allowances, siteId, userPubkey);
if (!allowance) return 'ask';
const match = allowance.permissions.find((p) => {
if (p.type !== type) return false;
// For signEvent, match on kind; for others kind is always null.
if (type === 'signEvent') return p.kind === kind;
return true;
});
if (!match) return 'ask';
return match.allowed ? 'allow' : 'deny';
}
/**
* Store a permission decision. Creates the allowance if it doesn't exist.
* Updates an existing permission entry if one matches.
*/
export function setNsitePermission(
siteId: string,
userPubkey: string,
siteName: string,
type: NsitePermissionType,
kind: number | null,
allowed: boolean,
): void {
const allowances = readAllowances();
let allowance = findAllowance(allowances, siteId, userPubkey);
if (!allowance) {
allowance = {
siteId,
siteName,
userPubkey,
permissions: [],
createdAt: Date.now(),
};
allowances.push(allowance);
}
// Find existing entry for this (type, kind) pair.
const idx = allowance.permissions.findIndex((p) => {
if (p.type !== type) return false;
if (type === 'signEvent') return p.kind === kind;
return true;
});
const entry: NsitePermission = { type, kind, allowed };
if (idx >= 0) {
allowance.permissions[idx] = entry;
} else {
allowance.permissions.push(entry);
}
writeAllowances(allowances);
}
/**
* Remove a single permission entry from a site's allowance.
*/
export function removeNsitePermission(
siteId: string,
userPubkey: string,
type: NsitePermissionType,
kind: number | null,
): void {
const allowances = readAllowances();
const allowance = findAllowance(allowances, siteId, userPubkey);
if (!allowance) return;
allowance.permissions = allowance.permissions.filter((p) => {
if (p.type !== type) return true;
if (type === 'signEvent') return p.kind !== kind;
return false;
});
// Remove the allowance entirely if no permissions remain.
if (allowance.permissions.length === 0) {
const idx = allowances.indexOf(allowance);
if (idx >= 0) allowances.splice(idx, 1);
}
writeAllowances(allowances);
}
/**
* Clear all stored permissions for a site.
*/
export function clearNsitePermissions(
siteId: string,
userPubkey: string,
): void {
const allowances = readAllowances();
const filtered = allowances.filter(
(a) => !(a.siteId === siteId && a.userPubkey === userPubkey),
);
writeAllowances(filtered);
}
/**
* Get the full allowance record for a site, or undefined if none exists.
*/
export function getNsiteAllowance(
siteId: string,
userPubkey: string,
): NsiteAllowance | undefined {
return findAllowance(readAllowances(), siteId, userPubkey);
}
// ---------------------------------------------------------------------------
// Human-readable labels
// ---------------------------------------------------------------------------
/** Get a human-readable label for a permission type and optional kind. */
export function getPermissionLabel(
type: NsitePermissionType,
kind: number | null,
): string {
switch (type) {
case 'signEvent': {
if (kind === null) return 'Sign event';
return `Sign: ${getKindLabel(kind)}`;
}
case 'nip04.encrypt':
return 'Encrypt (NIP-04)';
case 'nip04.decrypt':
return 'Decrypt (NIP-04)';
case 'nip44.encrypt':
return 'Encrypt (NIP-44)';
case 'nip44.decrypt':
return 'Decrypt (NIP-44)';
}
}
+58 -1
View File
@@ -1,6 +1,9 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
/** The fixed length of a base36-encoded 32-byte pubkey. */
const BASE36_PUBKEY_LENGTH = 50;
/** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */
export function hexToBase36(hex: string): string {
let n = 0n;
@@ -8,7 +11,61 @@ export function hexToBase36(hex: string): string {
n = n * 16n + BigInt(parseInt(hex[i], 16));
}
const b36 = n.toString(36);
return b36.padStart(50, '0');
return b36.padStart(BASE36_PUBKEY_LENGTH, '0');
}
/** Decode a base36-encoded pubkey back to a 64-char hex string. */
function base36ToHex(b36: string): string {
const n = [...b36].reduce((acc, ch) => acc * 36n + BigInt(parseInt(ch, 36)), 0n);
return n.toString(16).padStart(64, '0');
}
/**
* Parsed nsite subdomain — either a root site (kind 15128) or a named site (kind 35128).
*/
export interface ParsedNsiteSubdomain {
/** The hex pubkey of the site owner. */
pubkey: string;
/** The event kind (15128 for root, 35128 for named). */
kind: 15128 | 35128;
/** The d-tag identifier (empty string for root sites). */
identifier: string;
}
/**
* Parse an nsite subdomain back into its components.
*
* - Root site subdomain: `<npub1...>` → kind 15128, identifier ""
* - Named site subdomain: `<50-char-base36><dTag>` → kind 35128, identifier = dTag
*
* Returns null if the subdomain cannot be parsed.
*/
export function parseNsiteSubdomain(subdomain: string): ParsedNsiteSubdomain | null {
// Root site: subdomain is an npub
if (subdomain.startsWith('npub1')) {
try {
const decoded = nip19.decode(subdomain);
if (decoded.type !== 'npub') return null;
return { pubkey: decoded.data as string, kind: 15128, identifier: '' };
} catch {
return null;
}
}
// Named site: first 50 chars are base36 pubkey, rest is d-tag
if (subdomain.length <= BASE36_PUBKEY_LENGTH) return null;
const b36Part = subdomain.slice(0, BASE36_PUBKEY_LENGTH);
const dTag = subdomain.slice(BASE36_PUBKEY_LENGTH);
// Validate base36 characters
if (!/^[0-9a-z]+$/.test(b36Part)) return null;
try {
const pubkey = base36ToHex(b36Part);
return { pubkey, kind: 35128, identifier: dTag };
} catch {
return null;
}
}
/**
+7 -2
View File
@@ -1,5 +1,7 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
/** Parsed NIP-58 badge definition data. */
export interface BadgeData {
identifier: string;
@@ -20,13 +22,16 @@ export function parseBadgeDefinition(event: NostrEvent): BadgeData | null {
const name = event.tags.find(([n]) => n === 'name')?.[1] || identifier;
const description = event.tags.find(([n]) => n === 'description')?.[1];
const imageTag = event.tags.find(([n]) => n === 'image');
const image = imageTag?.[1];
const image = sanitizeUrl(imageTag?.[1]);
const imageDimensions = imageTag?.[2];
const thumbs: Array<{ url: string; dimensions?: string }> = [];
for (const tag of event.tags) {
if (tag[0] === 'thumb' && tag[1]) {
thumbs.push({ url: tag[1], dimensions: tag[2] });
const url = sanitizeUrl(tag[1]);
if (url) {
thumbs.push({ url, dimensions: tag[2] });
}
}
}
+32 -64
View File
@@ -1,13 +1,19 @@
/**
* SandboxPlugin — Capacitor plugin for native sandboxed WebViews.
* SandboxPlugin — Capacitor plugin for native sandbox iframe support.
*
* On iOS, each sandbox gets a WKWebView with a custom URL scheme handler
* (`sbx-<id>://`) that intercepts all resource requests and forwards them
* to the JS layer. On Android, the same is achieved via
* `shouldInterceptRequest`. This replaces iframe.diy on native platforms.
* On iOS, sandbox iframes use the `sbx://` custom URL scheme, registered on
* the WKWebView configuration via `setURLSchemeHandler(_:forURLScheme:)` in
* `DittoBridgeViewController`. Each sandbox loads from `sbx://<sandbox-id>/path`,
* giving every sandbox a unique web origin with full storage isolation.
*
* The plugin is registered as "SandboxPlugin" and is only usable on native
* platforms. On web, SandboxFrame uses iframe.diy directly.
* On Android, a custom BridgeWebViewClient subclass intercepts requests to
* `https://<sandbox-id>.sandbox.native/path` from iframes in the main WebView.
*
* Both platforms forward intercepted requests to the JS layer as `fetch`
* events. JS resolves the file and responds with `respondToFetch()`.
*
* Sandbox content lives in regular `<iframe>` elements, so web UI
* (permission prompts, popovers) naturally layers on top.
*/
import { registerPlugin } from '@capacitor/core';
@@ -17,24 +23,11 @@ import type { PluginListenerHandle } from '@capacitor/core';
// Plugin method options
// ---------------------------------------------------------------------------
/** Options for creating a new sandbox WebView. */
export interface SandboxCreateOptions {
/** Unique identifier for this sandbox (the HMAC-derived subdomain ID). */
id: string;
/** Absolute position and size of the WebView within the app window. */
frame: { x: number; y: number; width: number; height: number };
}
/** Options for updating the WebView frame (position/size). */
export interface SandboxUpdateFrameOptions {
id: string;
frame: { x: number; y: number; width: number; height: number };
}
/** A serialised fetch response sent back to the native WebView. */
/** A serialised fetch response sent back to the native scheme handler. */
export interface SandboxRespondToFetchOptions {
id: string;
/** Unique request ID from the fetch event. */
requestId: string;
/** The serialised HTTP response. */
response: {
status: number;
statusText: string;
@@ -43,24 +36,13 @@ export interface SandboxRespondToFetchOptions {
};
}
/** Options for posting a message into the sandbox (to injected scripts). */
export interface SandboxPostMessageOptions {
id: string;
message: Record<string, unknown>;
}
/** Options for destroying a sandbox. */
export interface SandboxDestroyOptions {
id: string;
}
// ---------------------------------------------------------------------------
// Plugin event payloads
// ---------------------------------------------------------------------------
/** A fetch request forwarded from the native WebView's URL scheme handler. */
/** A fetch request forwarded from the native scheme handler. */
export interface SandboxFetchEvent {
/** The sandbox ID this request belongs to. */
/** The sandbox ID (hostname) this request belongs to. */
id: string;
/** Unique request ID — pass back to `respondToFetch`. */
requestId: string;
@@ -73,45 +55,31 @@ export interface SandboxFetchEvent {
};
}
/** A JSON-RPC message from an injected script inside the sandbox. */
export interface SandboxScriptMessageEvent {
/** The sandbox ID this message came from. */
id: string;
/** The JSON-RPC message body. */
message: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Plugin interface
// ---------------------------------------------------------------------------
/** Diagnostic state returned by the native plugin. */
export interface SandboxDiagnostics {
sandboxHandlerSet: boolean;
pluginConnected: boolean;
bridgeHasWebView: boolean;
hasListenersFetch: boolean;
pendingTaskCount: number;
}
export interface SandboxPluginInterface {
/** Create a new sandbox WebView with a unique custom URL scheme. */
create(options: SandboxCreateOptions): Promise<void>;
/** Update the position/size of an existing sandbox WebView. */
updateFrame(options: SandboxUpdateFrameOptions): Promise<void>;
/** Send a fetch response back to the native WebView for a pending request. */
/** Send a fetch response back to the native scheme handler for a pending request. */
respondToFetch(options: SandboxRespondToFetchOptions): Promise<void>;
/** Post a JSON-RPC message to injected scripts inside the sandbox. */
postMessage(options: SandboxPostMessageOptions): Promise<void>;
/** Return diagnostic state from the native side (iOS only). */
diagnose(): Promise<SandboxDiagnostics>;
/** Destroy a sandbox WebView and clean up all resources. */
destroy(options: SandboxDestroyOptions): Promise<void>;
/** Listen for fetch requests from the native WebView. */
/** Listen for fetch requests from sandbox iframes intercepted by native code. */
addListener(
eventName: 'fetch',
handler: (event: SandboxFetchEvent) => void,
): Promise<PluginListenerHandle>;
/** Listen for JSON-RPC messages from injected scripts inside the sandbox. */
addListener(
eventName: 'scriptMessage',
handler: (event: SandboxScriptMessageEvent) => void,
): Promise<PluginListenerHandle>;
}
// ---------------------------------------------------------------------------
@@ -121,6 +89,6 @@ export interface SandboxPluginInterface {
/**
* The SandboxPlugin Capacitor plugin.
* Only usable on native platforms (iOS/Android). On web, SandboxFrame
* falls back to the iframe.diy service worker sandbox.
* uses the iframe.diy fetch proxy sandbox.
*/
export const SandboxPlugin = registerPlugin<SandboxPluginInterface>('SandboxPlugin');
+21
View File
@@ -0,0 +1,21 @@
/**
* Validate that a string is a well-formed HTTPS URL.
*
* Returns the normalised `href` when valid, or `undefined` otherwise.
* This **must** be used whenever a URL originates from untrusted Nostr
* event data (tags, metadata fields, etc.) and will be placed into an
* `href`, `window.open()`, or `openUrl()` call. Without this check a
* malicious `javascript:` URI could execute arbitrary code.
*/
export function sanitizeUrl(raw: string | undefined | null): string | undefined {
if (!raw) return undefined;
try {
const parsed = new URL(raw);
if (parsed.protocol === 'https:') {
return parsed.href;
}
} catch {
// not a valid URL
}
return undefined;
}
+19
View File
@@ -59,6 +59,16 @@ export function isNostrUri(id: string): boolean {
return id.startsWith("nostr:");
}
/** Returns true if the given sidebar order ID is an `nsite://` URI. */
export function isNsiteUri(id: string): boolean {
return id.startsWith("nsite://");
}
/** Extracts the nsite subdomain from an `nsite://` URI. */
export function nsiteUriToSubdomain(uri: string): string {
return uri.slice("nsite://".length);
}
/** Extracts the NIP-19 bech32 identifier from a `nostr:` URI. Returns the raw string if not a nostr: URI. */
export function nostrUriToNip19(uri: string): string {
return uri.startsWith("nostr:") ? uri.slice(6) : uri;
@@ -278,6 +288,15 @@ export function isItemActive(
return pathname === `/${nip19Id}`;
}
// Nsite URI items: active when the nsite preview is open for this subdomain.
// The pathname will be the naddr of the nsite event, which we can't cheaply
// derive here without async resolution. For now, nsite items are never
// highlighted as "active" via pathname — the visual indication comes from
// the nsite preview panel being open.
if (isNsiteUri(id)) {
return false;
}
// External content items: active when pathname matches /i/<encoded-value>
if (isExternalUri(id)) {
return pathname === `/i/${encodeURIComponent(id)}` || pathname === `/i/${id}`;
+61 -111
View File
@@ -1,8 +1,8 @@
import type { NostrEvent, NostrSigner } from '@nostrify/types';
import { createElement } from 'react';
import { toast } from '@/hooks/useToast';
import { androidResume } from '@/lib/androidResume';
import { NudgeToastContent } from '@/components/SignerToastContent';
import { getKindLabel } from '@/lib/kindLabels';
// ---------------------------------------------------------------------------
// Constants
@@ -16,9 +16,6 @@ const NUDGE_DELAY_MS = 4_000;
const HARD_TIMEOUT_MS = 45_000;
/** Max number of automatic retries on Android foreground resume. */
const MAX_RETRIES = 2;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -29,18 +26,15 @@ function isAndroid(): boolean {
type OpType = 'sign' | 'encrypt' | 'decrypt';
/** Human-readable labels for event kinds shown in nudge toasts. */
const KIND_LABELS: Record<number, string> = {
/**
* Context-specific overrides for nudge toast descriptions.
* Falls back to the central kind label registry for kinds not listed here.
*/
const NUDGE_OVERRIDES: Record<number, string> = {
0: 'profile update',
1: 'post',
3: 'contact list update',
5: 'deletion',
6: 'repost',
7: 'reaction',
11: 'post',
16: 'repost',
1111: 'comment',
1984: 'report',
4932: 'webxdc sync',
10000: 'mute list update',
10001: 'pinned notes update',
@@ -58,7 +52,11 @@ const KIND_LABELS: Record<number, string> = {
};
function labelForOp(kind: number | undefined, opType: OpType): string {
if (kind !== undefined && KIND_LABELS[kind]) return KIND_LABELS[kind];
if (kind !== undefined) {
if (NUDGE_OVERRIDES[kind]) return NUDGE_OVERRIDES[kind];
const central = getKindLabel(kind, '');
if (central) return central.toLowerCase();
}
if (opType === 'encrypt') return 'encryption';
if (opType === 'decrypt') return 'decryption';
return 'signing';
@@ -70,9 +68,8 @@ function labelForOp(kind: number | undefined, opType: OpType): string {
const CANCEL = Symbol('cancel');
const TIMEOUT = Symbol('timeout');
const RESUME = Symbol('resume');
type Signal = typeof CANCEL | typeof TIMEOUT | typeof RESUME;
type Signal = typeof CANCEL | typeof TIMEOUT;
// ---------------------------------------------------------------------------
// Toast deduplication — prevent a storm of identical nudge toasts
@@ -98,10 +95,9 @@ function showNudgeToast(opts: {
kind: number | undefined;
opType: OpType;
isBunkerConnected: (() => boolean) | undefined;
afterForegroundResume: boolean;
onCancel: () => void;
}): { dismiss: () => void } {
const { kind, opType, isBunkerConnected, afterForegroundResume, onCancel } = opts;
const { kind, opType, isBunkerConnected, onCancel } = opts;
const android = isAndroid();
const relayOk = isBunkerConnected ? isBunkerConnected() : true;
const subject = labelForOp(kind, opType);
@@ -120,9 +116,6 @@ function showNudgeToast(opts: {
if (!relayOk) {
title = 'Signer relay unreachable';
descriptionText = 'Check your connection and try again.';
} else if (android && afterForegroundResume) {
title = `Approve ${subject} — try again`;
descriptionText = 'Use the button below. Switching apps manually can interrupt the connection.';
} else if (android) {
title = `Approve ${subject}`;
descriptionText = 'Set to auto-approve for a smoother experience.';
@@ -184,11 +177,6 @@ interface RunResult<T> {
* Runs `op` with:
* - A nudge toast after NUDGE_DELAY_MS if still pending.
* - A hard timeout at HARD_TIMEOUT_MS.
* - On Android, automatic retry when the app returns to the foreground
* (WebSocket connections are frozen while backgrounded, so NIP-46 responses
* are missed).
*
* Uses an iterative retry loop instead of recursion.
*/
async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<RunResult<T>> {
const { kind, opType, isBunkerConnected } = opts;
@@ -200,96 +188,61 @@ async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<Run
| { tag: 'signal'; signal: Signal };
let nudgeFired = false;
let afterForegroundResume = false;
// Previous op promises that are still in-flight. On Android foreground
// resume we issue a fresh `op()` but keep racing previous ones so a late
// response from an earlier attempt is still accepted (avoids duplicate
// signer prompts).
const pendingOps: Promise<Outcome>[] = [];
// Signal channels — each resolves with a sentinel when its condition fires.
const cancelSignal = deferred<typeof CANCEL>();
const timeoutSignal = deferred<typeof TIMEOUT>();
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
// Signal channels — each resolves with a sentinel when its condition fires.
const cancelSignal = deferred<typeof CANCEL>();
const timeoutSignal = deferred<typeof TIMEOUT>();
const resumeSignal = deferred<typeof RESUME>();
// --- Nudge timer ---
let dismissNudge: (() => void) | undefined;
const delay = NUDGE_DELAY_MS;
const nudgeTimer = setTimeout(() => {
nudgeFired = true;
const handle = showNudgeToast({
kind, opType, isBunkerConnected, afterForegroundResume,
onCancel: () => cancelSignal.resolve(CANCEL),
});
dismissNudge = handle.dismiss;
}, delay);
// --- Hard timeout ---
const hardTimer = setTimeout(() => timeoutSignal.resolve(TIMEOUT), HARD_TIMEOUT_MS);
// --- Android foreground resume watcher ---
const { destroy: stopWatching } = androidResume({
threshold: 0,
onResume: () => {
toast({ title: 'Checking for signer response\u2026', duration: 4000 });
resumeSignal.resolve(RESUME);
},
// --- Nudge timer ---
let dismissNudge: (() => void) | undefined;
const nudgeTimer = setTimeout(() => {
nudgeFired = true;
const handle = showNudgeToast({
kind, opType, isBunkerConnected,
onCancel: () => cancelSignal.resolve(CANCEL),
});
dismissNudge = handle.dismiss;
}, NUDGE_DELAY_MS);
function cleanup() {
clearTimeout(nudgeTimer);
clearTimeout(hardTimer);
stopWatching();
dismissNudge?.();
}
// --- Hard timeout ---
const hardTimer = setTimeout(() => timeoutSignal.resolve(TIMEOUT), HARD_TIMEOUT_MS);
// Start a new op and add it to the pending set.
const newOp: Promise<Outcome> = op().then(
(value): Outcome => ({ tag: 'value', value }),
(error): Outcome => ({ tag: 'error', error }),
);
pendingOps.push(newOp);
const signalOutcome: Promise<Outcome> = Promise.race([
cancelSignal.promise,
timeoutSignal.promise,
resumeSignal.promise,
]).then((signal): Outcome => ({ tag: 'signal', signal }));
// Race all pending ops (current + any still in-flight from prior
// attempts) against the signal channels.
const outcome = await Promise.race([...pendingOps, signalOutcome]);
cleanup();
// --- Handle outcome ---
if (outcome.tag === 'value') {
if (nudgeFired) showSuccessToast(opType);
return { value: outcome.value, nudgeFired };
}
if (outcome.tag === 'error') {
throw outcome.error;
}
// outcome.tag === 'signal'
switch (outcome.signal) {
case CANCEL:
throw new Error('Signing cancelled by user');
case TIMEOUT:
throw new Error('Signer timed out');
case RESUME:
afterForegroundResume = true;
console.log('[signerWithNudge] retrying after foreground resume');
continue;
}
function cleanup() {
clearTimeout(nudgeTimer);
clearTimeout(hardTimer);
dismissNudge?.();
}
throw new Error('Signer timed out after retries');
const opOutcome: Promise<Outcome> = op().then(
(value): Outcome => ({ tag: 'value', value }),
(error): Outcome => ({ tag: 'error', error }),
);
const signalOutcome: Promise<Outcome> = Promise.race([
cancelSignal.promise,
timeoutSignal.promise,
]).then((signal): Outcome => ({ tag: 'signal', signal }));
const outcome = await Promise.race([opOutcome, signalOutcome]);
cleanup();
if (outcome.tag === 'value') {
if (nudgeFired) showSuccessToast(opType);
return { value: outcome.value, nudgeFired };
}
if (outcome.tag === 'error') {
throw outcome.error;
}
// outcome.tag === 'signal'
switch (outcome.signal) {
case CANCEL:
throw new Error('Signing cancelled by user');
case TIMEOUT:
throw new Error('Signer timed out');
}
}
// ---------------------------------------------------------------------------
@@ -301,9 +254,6 @@ async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<Run
*
* - Shows a nudge toast after 4s if a signing or encryption op is still
* pending, so the user knows to check their signer app.
* - On Android, automatically retries when the app returns to the foreground,
* recovering from missed NIP-46 responses dropped while the WebSocket was
* frozen in the background.
* - When a nip44 encrypt is immediately followed by a signEvent (e.g. saving
* encrypted settings), shows a phase-transition toast so the user knows to
* approve the second request.
+5 -2
View File
@@ -1,6 +1,7 @@
import type { NostrEvent } from '@nostrify/nostrify';
import type { CoreThemeColors, ThemeConfig, ThemeFont, ThemeBackground } from '@/themes';
import { hslStringToHex, hexToHslString } from '@/lib/colorUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ─── Kind Constants ───────────────────────────────────────────────────
@@ -75,7 +76,8 @@ function parseFontTags(tags: string[][]): { font?: ThemeFont; titleFont?: ThemeF
if (tag[0] !== 'f' || !tag[1]) continue;
const role = tag[3]; // 4th element: "body", "title", or absent (legacy)
const parsed: ThemeFont = { family: tag[1] };
if (tag[2]) parsed.url = tag[2];
const fontUrl = sanitizeUrl(tag[2]);
if (fontUrl) parsed.url = fontUrl;
if (role === 'title') {
if (!titleFont) titleFont = parsed;
@@ -116,7 +118,8 @@ function parseBackgroundTag(tags: string[][]): ThemeBackground | undefined {
kv.set(entry.slice(0, spaceIdx), entry.slice(spaceIdx + 1));
}
const url = kv.get('url');
const rawUrl = kv.get('url');
const url = sanitizeUrl(rawUrl);
if (!url) return undefined;
const bg: ThemeBackground = { url };
+17 -16
View File
@@ -20,29 +20,30 @@ import '@fontsource-variable/inter';
// Runs before React so the very first paint matches the persisted theme.
// Uses a MutationObserver so it reacts to all subsequent theme changes
// (class changes for builtin themes, style-content changes for custom themes).
import { Capacitor } from '@capacitor/core';
import { StatusBar, Style } from '@capacitor/status-bar';
import { Keyboard } from '@capacitor/keyboard';
import { getBackgroundThemeMode, getBackgroundHex } from '@/lib/colorUtils';
import { Capacitor, SystemBars, SystemBarsStyle } from '@capacitor/core';
import { getBackgroundThemeMode } from '@/lib/colorUtils';
if (Capacitor.isNativePlatform()) {
// Hide the iOS keyboard accessory bar (prev/next/done toolbar above the keyboard)
Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => {});
// Hide the iOS keyboard accessory bar (prev/next/done toolbar above the keyboard).
// Only runs on iOS — setAccessoryBarVisible is unimplemented on Android.
if (Capacitor.getPlatform() === 'ios') {
import('@capacitor/keyboard').then(({ Keyboard }) => {
Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => {});
}).catch(() => {});
}
/**
* Read --background from the computed style of <html>, convert the HSL
* value to a hex color, and update the native status bar to match.
* Sync the native system bar icon style with the active CSS theme.
*
* Style.Dark = light/white icons (use on dark backgrounds)
* Style.Light = dark/black icons (use on light backgrounds)
* SystemBarsStyle.Dark = light/white icons (use on dark backgrounds)
* SystemBarsStyle.Light = dark/black icons (use on light backgrounds)
*
* On Android 16+ (API 36) setBackgroundColor no longer works — the bars
* are transparent and the web content renders behind them. The app already
* draws its own safe-area backgrounds in CSS, so only icon style matters.
*/
function updateStatusBar() {
const hex = getBackgroundHex();
if (!hex) return;
const isDark = getBackgroundThemeMode() === 'dark';
StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => {});
StatusBar.setBackgroundColor({ color: hex }).catch(() => {});
SystemBars.setStyle({ style: isDark ? SystemBarsStyle.Dark : SystemBarsStyle.Light }).catch(() => {});
}
// Apply immediately (theme class is set synchronously by AppProvider useLayoutEffect
+3
View File
@@ -1017,6 +1017,9 @@ function EditBadgeForm({
e.target.files?.[0] && handleFileSelect(e.target.files[0])
}
/>
<p className="text-xs text-muted-foreground mt-1.5">
Recommended aspect ratio is 1:1 (max 1024x1024 px).
</p>
</div>
<div>
<Label htmlFor="edit-name" className="text-sm font-medium mb-1.5 block">
+32 -23
View File
@@ -20,7 +20,7 @@ import {
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
/** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the detail page bundle. */
const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent })));
import { AudioVisualizer } from "@/components/AudioVisualizer";
@@ -92,6 +92,7 @@ import { useAppContext } from "@/hooks/useAppContext";
import { type AddrCoords, useAddrEvent, useEvent } from "@/hooks/useEvent";
import { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
import { KIND_LABELS } from "@/lib/kindLabels";
import { formatNumber } from "@/lib/formatNumber";
import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls";
@@ -123,36 +124,27 @@ const BOOK_REVIEW_KIND = 31985;
/** NIP-62 Request to Vanish. */
const VANISH_KIND = 62;
/** Map a kind number to a human-readable shell title for the loading state. */
/**
* Map a kind number to a human-readable shell title for the loading state.
*
* Group-based overrides and composite labels (e.g. "Badge Details",
* "Badge Collection") are kept here. Everything else falls through to the
* central kind label registry.
*/
function shellTitleForKind(kind?: number): string {
if (!kind) return "Loading...";
// Group-based overrides
if (MUSIC_KINDS.has(kind)) return "Track Details";
if (PODCAST_KINDS.has(kind)) return "Episode Details";
if (CALENDAR_EVENT_KINDS.has(kind)) return "Event Details";
if (FOLLOW_PACK_KINDS.has(kind)) return "Follow Pack";
if (kind === LIVE_STREAM_KIND) return "Live Stream";
if (kind === 30617) return "Repository";
if (kind === 1617) return "Patch";
if (kind === 1618) return "Pull Request";
if (kind === 30817) return "Custom NIP";
// Composite labels that differ from the raw kind name
if (kind === BADGE_DEFINITION_KIND) return "Badge Details";
if (kind === BADGE_PROFILE_KIND_NEW || kind === BADGE_PROFILE_KIND_LEGACY) return "Badge Collection";
if (kind === BOOK_REVIEW_KIND) return "Book Review";
if (kind === 32267) return "Zapstore App";
if (kind === 30063) return "Zapstore Release";
if (kind === 3063) return "Zapstore Asset";
if (kind === 31990) return "App";
if (kind === 15128 || kind === 35128) return "Nsite";
if (kind === VANISH_KIND) return "Request to Vanish";
if (kind === 20) return "Photo";
if (kind === 4) return "Encrypted Message";
if (kind === 8211) return "Letter";
if (kind === 6 || kind === 16) return "Repost";
if (kind === 7) return "Reaction";
if (kind === 1018) return "Poll Vote";
if (kind === 9735) return "Zap";
if (kind === 0) return "Profile";
if (kind === 31124) return "Blobbi";
// Fall back to the central registry
const label = KIND_LABELS[kind];
if (label) return label;
return "Post Details";
}
@@ -937,11 +929,28 @@ function BookReviewRating({ event }: { event: NostrEvent }) {
function PostDetailContent({ event }: { event: NostrEvent }) {
const { muteItems } = useMuteList();
const queryClient = useQueryClient();
const location = useLocation();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = getDisplayName(metadata, event.pubkey);
const navigate = useNavigate();
// Auto-play nsite when navigated from a pinned nsite sidebar item.
// Uses React Router state (not URL params) so external URLs cannot trigger auto-launch.
// The state includes a timestamp so each sidebar click produces a distinct key,
// allowing the player to re-open even when already on the same page.
const routeState = location.state as Record<string, unknown> | null;
const nsiteAutoPlayKey = routeState?.nsiteAutoPlay ? (routeState.nsiteAutoPlayTs as number) || 1 : 0;
// Clear the router state after consuming it so a page refresh doesn't re-trigger auto-play.
useEffect(() => {
if (routeState?.nsiteAutoPlay) {
navigate(location.pathname, { replace: true, state: {} });
}
}, [routeState?.nsiteAutoPlay]); // eslint-disable-line react-hooks/exhaustive-deps
// Refetch the author's profile whenever we navigate to a post by this author.
useEffect(() => {
queryClient.refetchQueries({ queryKey: ["author", event.pubkey] });
@@ -2138,7 +2147,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</Suspense>
) : isNsite ? (
<div className="mt-3">
<NsiteCard event={event} />
<NsiteCard event={event} autoPlayKey={nsiteAutoPlayKey} />
</div>
) : isZapstoreApp ? (
<div className="mt-3 rounded-xl border border-border overflow-hidden px-4 pt-4 pb-4">
+19 -34
View File
@@ -6,7 +6,7 @@ import { useNostr } from '@nostrify/react';
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Zap, Flame, MoreHorizontal, Share2, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Pin, X, QrCode, Check, Copy, Loader2, Download, Palette, Pencil, Trash2, Eye, EyeOff, RefreshCw, RotateCcw, MessageSquare, Globe, Mail, Plus, GripVertical, ListPlus, Award, PanelLeft } from 'lucide-react';
import { Zap, Flame, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Pin, X, QrCode, Check, Copy, Loader2, Download, Palette, Pencil, Trash2, Eye, EyeOff, RefreshCw, RotateCcw, MessageSquare, Globe, Mail, Plus, GripVertical, ListPlus, Award, PanelLeft } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape, isEmoji, emojiAvatarBorderStyle } from '@/lib/avatarShape';
@@ -47,7 +47,6 @@ import { useNip05Resolve } from '@/hooks/useNip05Resolve';
import { genUserName } from '@/lib/genUserName';
import { canZap } from '@/lib/canZap';
import { shareOrCopy } from '@/lib/share';
import { openUrl } from '@/lib/downloadFile';
import { EmojifiedText } from '@/components/CustomEmoji';
import { BioContent } from '@/components/BioContent';
@@ -102,8 +101,10 @@ import { SubHeaderBar } from '@/components/SubHeaderBar';
import { useActiveTabIndicator } from '@/components/SubHeaderBarContext';
import { TabButton } from '@/components/TabButton';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { cn } from '@/lib/utils';
import type { AddrCoords } from '@/hooks/useEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import type { FeedItem } from '@/lib/feedUtils';
import type { NostrEvent } from '@nostrify/nostrify';
import QRCode from 'qrcode';
@@ -669,7 +670,8 @@ function ProfileFieldInline({ field }: { field: { label: string; value: string }
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const isBtc = field.label === '$BTC';
const isUrl = field.value.startsWith('http://') || field.value.startsWith('https://');
const safeUrl = sanitizeUrl(field.value);
const isUrl = !!safeUrl;
const handleCopy = async () => {
await navigator.clipboard.writeText(field.value);
@@ -758,17 +760,17 @@ function ProfileFieldInline({ field }: { field: { label: string; value: string }
);
}
if (isUrl && isAudioUrl(field.value)) {
return <MiniAudioPlayer src={field.value} label={field.label || undefined} />;
if (isUrl && safeUrl && isAudioUrl(safeUrl)) {
return <MiniAudioPlayer src={safeUrl} label={field.label || undefined} />;
}
if (isUrl && isImageUrl(field.value)) {
if (isUrl && safeUrl && isImageUrl(safeUrl)) {
return (
<div className="min-w-0">
{field.label && <div className="text-sm text-muted-foreground mb-1">{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 max-w-sm rounded-lg object-cover"
loading="lazy"
@@ -778,29 +780,29 @@ function ProfileFieldInline({ field }: { field: { label: string; value: string }
);
}
if (isUrl && isVideoUrl(field.value)) {
if (isUrl && safeUrl && isVideoUrl(safeUrl)) {
return (
<div className="min-w-0">
{field.label && <div className="text-sm text-muted-foreground mb-1">{field.label}</div>}
<div className="rounded-lg overflow-hidden max-w-sm">
<VideoPlayer src={field.value} />
<VideoPlayer src={safeUrl} />
</div>
</div>
);
}
if (isUrl) {
if (isUrl && safeUrl) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<ExternalFavicon url={field.value} size={16} className="shrink-0" />
<ExternalFavicon url={safeUrl} size={16} className="shrink-0" />
<span className="text-sm text-muted-foreground shrink-0">{field.label}</span>
<a
href={field.value}
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline truncate"
>
{field.value.replace(/^https?:\/\//, '')}
{safeUrl.replace(/^https?:\/\//, '')}
</a>
</div>
);
@@ -2121,23 +2123,6 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
>
<MoreHorizontal className="size-5" />
</Button>
{/* Share button (mobile only) */}
{pubkey && (
<Button
variant="outline"
size="icon"
className="rounded-full size-10 sidebar:hidden"
title="Share profile"
onClick={async () => {
const npubId = nip19.npubEncode(pubkey);
const url = `${window.location.origin}/${npubId}`;
const result = await shareOrCopy(url);
if (result === 'copied') toast({ title: 'Profile link copied to clipboard' });
}}
>
<Share2 className="size-5" />
</Button>
)}
{/* Follow QR code button (own profile only) */}
{isOwnProfile && (
<Button
@@ -2187,11 +2172,11 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
{metadata?.nip05 && (
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey ?? ''} className="text-sm text-muted-foreground" />
)}
{metadata?.website && (
{metadata?.website && sanitizeUrl(metadata.website.startsWith('http') ? metadata.website : `https://${metadata.website}`) && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-0.5">
<Globe className="size-3.5 text-muted-foreground shrink-0" />
<a
href={metadata.website.startsWith('http') ? metadata.website : `https://${metadata.website}`}
href={sanitizeUrl(metadata.website.startsWith('http') ? metadata.website : `https://${metadata.website}`)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-primary hover:underline"
+22 -1
View File
@@ -1,5 +1,5 @@
import { useSeoMeta } from '@unhead/react';
import { useState, useEffect, useRef } from 'react';
import { lazy, Suspense, useState, useEffect, useRef } from 'react';
import { ChevronRight, Settings } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader';
@@ -9,6 +9,8 @@ import { IntroImage } from '@/components/IntroImage';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { toast } from '@/hooks/useToast';
const RequestToVanishDialog = lazy(() => import('@/components/RequestToVanishDialog').then(m => ({ default: m.RequestToVanishDialog })));
interface SettingsSection {
id: string;
label: string;
@@ -79,6 +81,7 @@ export function SettingsPage() {
const navigate = useNavigate();
const [sigilFlash, setSigilFlash] = useState(false);
const [sigilVisible, setSigilVisible] = useState(false);
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
const inactivityTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@@ -164,6 +167,24 @@ export function SettingsPage() {
})}
</div>
{/* Delete Account */}
{user && (
<div className="flex justify-center pt-4 pb-1">
<button
onClick={() => setDeleteAccountOpen(true)}
className="text-xs text-destructive-foreground bg-destructive/80 hover:bg-destructive rounded-full px-4 py-1.5 transition-colors"
>
Delete Account
</button>
</div>
)}
{user && (
<Suspense fallback={null}>
<RequestToVanishDialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen} />
</Suspense>
)}
{/* Bottom ornament */}
<div className="flex items-center gap-3 px-6 pt-4 pb-2">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-primary/20 to-primary/30" />
+6 -6
View File
@@ -527,7 +527,7 @@ export function VineCard({
{/* ── Mute toggle (bottom-right) — only shown once video is ready ──── */}
{isVideoReady && (
<button
className="absolute bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] right-4 z-10 size-9 rounded-full bg-black/40 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white hover:bg-black/60 transition-colors"
className="absolute bottom-[calc(1rem+var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px)))] right-4 z-10 size-9 rounded-full bg-black/40 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white hover:bg-black/60 transition-colors"
onClick={toggleMute}
aria-label={isMuted ? "Unmute" : "Mute"}
>
@@ -541,7 +541,7 @@ export function VineCard({
{/* ── Right action sidebar — only shown once video is ready ─────── */}
{isVideoReady && (
<div className="absolute right-3 bottom-[calc(6rem+env(safe-area-inset-bottom,0px))] z-10 flex flex-col items-center gap-5">
<div className="absolute right-3 bottom-[calc(6rem+var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px)))] z-10 flex flex-col items-center gap-5">
{/* Author avatar */}
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
@@ -619,7 +619,7 @@ export function VineCard({
{/* ── Bottom info strip — only shown once video is ready ────────── */}
{isVideoReady && (
<div className="absolute bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))] left-4 right-20 z-10 space-y-1.5">
<div className="absolute bottom-[calc(1.5rem+var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px)))] left-4 right-20 z-10 space-y-1.5">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
@@ -851,7 +851,7 @@ export function VinesFeedPage() {
{/* Bottom gradient */}
<div className="absolute inset-x-0 bottom-0 h-64 bg-gradient-to-t from-black/90 via-black/40 to-transparent pointer-events-none" />
{/* Bottom info strip */}
<div className="absolute bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))] left-4 right-20 space-y-2.5">
<div className="absolute bottom-[calc(1.5rem+var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px)))] left-4 right-20 space-y-2.5">
<Skeleton className="h-4 w-28 bg-white/20 rounded" />
<Skeleton className="h-3.5 w-48 bg-white/15 rounded" />
<div className="flex gap-1.5">
@@ -860,7 +860,7 @@ export function VinesFeedPage() {
</div>
</div>
{/* Right action buttons */}
<div className="absolute right-3 bottom-[calc(6rem+env(safe-area-inset-bottom,0px))] flex flex-col items-center gap-5">
<div className="absolute right-3 bottom-[calc(6rem+var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px)))] flex flex-col items-center gap-5">
<Skeleton className="size-11 rounded-full bg-white/15" />
<Skeleton className="size-11 rounded-full bg-white/15" />
<Skeleton className="size-11 rounded-full bg-white/15" />
@@ -868,7 +868,7 @@ export function VinesFeedPage() {
<Skeleton className="size-11 rounded-full bg-white/15" />
</div>
{/* Mute button */}
<Skeleton className="absolute bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] right-4 size-9 rounded-full bg-white/10" />
<Skeleton className="absolute bottom-[calc(1rem+var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px)))] right-4 size-9 rounded-full bg-white/10" />
</div>
</div>
</div>
+25
View File
@@ -0,0 +1,25 @@
/**
* Type declarations for the Credential Management API's PasswordCredential
* interface. This is an experimental Chromium-only API not included in
* TypeScript's default DOM lib.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/PasswordCredential
*/
interface PasswordCredentialInit {
id: string;
password: string;
name?: string;
iconURL?: string;
}
declare class PasswordCredential extends Credential {
constructor(init: PasswordCredentialInit);
readonly password: string;
readonly name: string;
readonly iconURL: string;
}
interface CredentialRequestOptions {
password?: boolean;
}
+3 -1
View File
@@ -32,6 +32,7 @@ export default {
},
fontFamily: {
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
emoji: ['Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', 'Twemoji Mozilla', 'Android Emoji', 'EmojiSymbols', 'sans-serif'],
},
colors: {
border: 'hsl(var(--border))',
@@ -75,7 +76,8 @@ export default {
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
sm: 'calc(var(--radius) - 4px)',
xs: 'calc(var(--radius) - 8px)'
},
keyframes: {
'accordion-down': {