Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59f68efdc7 | |||
| dc81585f9a | |||
| 54e6c964db | |||
| dceda199c3 | |||
| 8967012035 | |||
| 0b73d4aac5 | |||
| 6f53f7ad99 | |||
| 399df4da4d | |||
| c06a66ade4 | |||
| 1fca26ae2e | |||
| ccd8f213f6 | |||
| 1c25702453 | |||
| 357ba7d8c8 | |||
| 207ca6893a | |||
| 37df5d0bd1 | |||
| 19906cf918 | |||
| 874010c4fe | |||
| 126dce1dfc | |||
| 105da53e2e | |||
| 7bc4a632b0 | |||
| 0222248d76 | |||
| a542dd3b36 | |||
| fc292a8654 | |||
| 9214bd823b | |||
| 8f5b8264c9 | |||
| 94f821d064 | |||
| 6d73e6d06b | |||
| bd724de1e8 | |||
| 9d899cfe87 | |||
| 72268dfde6 | |||
| 7b63f6112c | |||
| ce61d8d1a6 | |||
| c4a10b1303 | |||
| 76c6846e91 | |||
| ac1e82b52d | |||
| 437b8de652 | |||
| adadb6ed53 | |||
| f7c90a4a23 | |||
| 82632bb76c | |||
| 3a70d34e6d | |||
| 221d3f4aff | |||
| 6a1a462ab0 | |||
| 5ee8bc1cc0 | |||
| 61c84ed137 | |||
| a24b755e08 | |||
| 46a970b900 |
@@ -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
|
||||
@@ -409,6 +409,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 +1403,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.
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.4] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
|
||||
|
||||
### Changed
|
||||
- Empty feeds show a friendlier state with a discover button to help you find people to follow
|
||||
- Signup flow simplified -- cleaner profile step with a single Continue button
|
||||
|
||||
### Fixed
|
||||
- Avatar fallback now shows the user's initial instead of a question mark
|
||||
- Android 16+ devices no longer have content hidden behind system bars
|
||||
- Signup dialog background clears properly when switching between light and dark themes
|
||||
- Sticky compose button stays anchored to the bottom even on empty feeds
|
||||
|
||||
## [2.6.3] - 2026-04-10
|
||||
|
||||
### Added
|
||||
- Lightning invoices embedded in posts now render as tappable payment cards
|
||||
- Blobbi companions in the feed reflect their current condition and projected health
|
||||
|
||||
### Changed
|
||||
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
|
||||
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
|
||||
- "Request to Vanish" renamed to "Delete Account" for clarity
|
||||
|
||||
### Fixed
|
||||
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
|
||||
- Security hardening for URLs and styles sourced from the network
|
||||
|
||||
## [2.6.2] - 2026-04-08
|
||||
|
||||
### Added
|
||||
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
|
||||
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
|
||||
- "Write a letter" option on profile menus for a more personal way to reach out
|
||||
- Push vs persistent notification delivery option on Android
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps always open fullscreen for a more immersive experience
|
||||
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
|
||||
- Profile fields now appear inline instead of in a separate right sidebar
|
||||
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
|
||||
|
||||
### Fixed
|
||||
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
|
||||
- File downloads now save directly to Documents on iOS and Android instead of silently failing
|
||||
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
|
||||
- iOS swipe-back navigation works correctly throughout the app
|
||||
- Blobbi companions appear reliably on profiles instead of sometimes going missing
|
||||
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
# Contributing to Ditto
|
||||
|
||||
We welcome contributions, but we have high standards. Ditto is a carefully designed product with a specific vision, and every merge request must meet that bar. This guide exists to help you succeed.
|
||||
|
||||
**Required reading before you start:**
|
||||
|
||||
- [Ditto Philosophy](https://about.ditto.pub/philosophy) -- the product vision. Your change must align with it.
|
||||
- [Contributing Guide](https://about.ditto.pub/contributing) -- the upstream contribution process.
|
||||
- `AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
|
||||
|
||||
## Understanding Ditto
|
||||
|
||||
Ditto is a carnival, not a platform. Before contributing, you need to understand what that means.
|
||||
|
||||
### The product decision filter
|
||||
|
||||
Every change to Ditto should pass this test:
|
||||
|
||||
> *Does this make Ditto more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
|
||||
|
||||
- **Magnetic** -- Ditto attracts through experience, not ideology. People don't need to understand Nostr to love it. They need to feel something they haven't felt online since the early web. Features should be odd, intriguing, and captivating -- not generic social media clones.
|
||||
- **Threatening to the status quo** -- Ditto threatens mainstream platforms when someone opens it and thinks: *"Why can't my platform do this?"* Theming, games, treasure hunts, interoperable micro-apps -- these are things walled gardens can't replicate.
|
||||
- **Peaceful to inhabit** -- Ditto displaces argument with creation, conformity with expression, and consumption with participation. No ads, no engagement-optimized algorithms, no outrage incentives.
|
||||
|
||||
If a change does all three, it belongs. If it only does one, think harder. If it does none, it doesn't belong here.
|
||||
|
||||
### What Ditto is NOT
|
||||
|
||||
- A Twitter/X clone with decentralization bolted on
|
||||
- A place to replicate features that mainstream platforms already do well
|
||||
- A showcase for generic UI components or boilerplate social features
|
||||
|
||||
### What Ditto IS
|
||||
|
||||
- A convergence point for interoperable Nostr experiences (games, treasure hunts, magic decks, themes, color moments, live streams, and things nobody has imagined yet)
|
||||
- A place where profiles feel like worlds, not business cards
|
||||
- The most fun you've had on the internet in years
|
||||
|
||||
Read the [full philosophy](https://about.ditto.pub/philosophy) for the complete vision.
|
||||
|
||||
## What we accept
|
||||
|
||||
### Bug fixes
|
||||
|
||||
One bug, one merge request. Fix exactly one thing. Don't bundle unrelated changes, don't sneak in refactors, don't "clean up while you're in there." Small, focused MRs get reviewed fast. Large ones sit.
|
||||
|
||||
### New features and significant changes
|
||||
|
||||
Every feature MR must link to an existing open issue and clearly align with the [Ditto Philosophy](https://about.ditto.pub/philosophy). The philosophy alignment section in the MR template is where you make the case for why your change belongs in Ditto. If you can't articulate that clearly, the change probably doesn't belong.
|
||||
|
||||
If you have an idea for a feature that doesn't have an issue yet:
|
||||
|
||||
1. Build it as a standalone Nostr app first (see [Contributing Guide](https://about.ditto.pub/contributing)).
|
||||
2. Prove it works and get user feedback.
|
||||
3. Open an issue to discuss integration.
|
||||
|
||||
**Feature MRs that don't link to an issue or don't align with the Ditto Philosophy will be closed.** Our open issues are our internal roadmap -- some require deep product context. If your implementation doesn't match the product vision, it will be closed regardless of code quality.
|
||||
|
||||
## Required tools
|
||||
|
||||
- **Claude Opus 4.6** (or the latest frontier model) -- not Sonnet, not GPT-4o, not local models. Quality depends on model quality.
|
||||
- **An AI coding agent with plan/research mode** -- [OpenCode](https://opencode.ai), [Shakespeare](https://shakespeare.diy), Cursor, or similar.
|
||||
- **Node.js 22+** and npm 10.9.4+.
|
||||
|
||||
## The contribution workflow
|
||||
|
||||
Follow these steps in order. Skipping steps is the most common reason MRs are rejected.
|
||||
|
||||
### 1. Ask: does anyone need this?
|
||||
|
||||
Before writing a single line of code, answer this honestly. For bug fixes this is straightforward -- someone hit the bug. For features, it requires more thought. Is there evidence of real user demand? Is the underlying technology mature enough? A beautifully written feature for a nonexistent user base is the wrong thing to build. If you can't point to a concrete user need, reconsider.
|
||||
|
||||
### 2. Understand the issue
|
||||
|
||||
Read the issue thoroughly. If anything is unclear, ask in the issue comments before writing code. Understand not just *what* to change, but *why* -- what problem does this solve for users?
|
||||
|
||||
### 3. Read the codebase conventions
|
||||
|
||||
Read `AGENTS.md` in the repo root. This is the single source of truth for how code should be written in this project. Your AI tool should load this file automatically. If it doesn't, paste it in or configure your tool to read it.
|
||||
|
||||
### 4. Read the philosophy
|
||||
|
||||
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy). Ditto is a carnival, not a platform. Your change should feel like it belongs in Ditto -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
|
||||
|
||||
### 5. Plan before you code
|
||||
|
||||
Start your AI tool in **plan mode** (or research/think mode). Spend the first few prompts:
|
||||
|
||||
- Exploring the existing codebase to understand how similar features are implemented
|
||||
- Reading the files you'll need to modify
|
||||
- Proposing an approach
|
||||
|
||||
Do not write code until you have a plan. The most expensive mistake is implementing the wrong approach.
|
||||
|
||||
### 6. Implement
|
||||
|
||||
Switch to code mode and implement your plan. Use Opus 4.6 or equivalent.
|
||||
|
||||
### 7. Run the test suite
|
||||
|
||||
```sh
|
||||
npm run test
|
||||
```
|
||||
|
||||
This runs type-checking, linting, unit tests, and a production build. All must pass. Do not submit an MR with a failing test suite.
|
||||
|
||||
### 8. Self-review
|
||||
|
||||
Run this prompt against your diff (copy the full `git diff` output and paste it to your AI tool along with this prompt):
|
||||
|
||||
```
|
||||
Review this diff as if you are a senior maintainer of this codebase who has to
|
||||
maintain it long-term. For each finding, state the file, line, and issue.
|
||||
|
||||
- [ ] Does the diff contain changes that weren't requested? Flag anything out of scope.
|
||||
- [ ] Is there dead code, commented-out blocks, or debug artifacts left in?
|
||||
- [ ] Are there placeholder comments like "// In a real app..." or "// TODO: implement"?
|
||||
- [ ] For every value displayed to a user, can you trace it from source to render without a gap?
|
||||
- [ ] Are error, loading, and empty states all handled -- and in the right order?
|
||||
- [ ] Does a mutation reflect in the UI without requiring a manual refresh?
|
||||
- [ ] Is there a new read/write path that assumes fresh data but could get a stale cache?
|
||||
- [ ] For replaceable/addressable Nostr events: is fetchFreshEvent used before mutation?
|
||||
- [ ] Does anything new block the critical render path or fire N+1 network requests?
|
||||
- [ ] Are Nostr queries efficient (combined kinds, relay-level filtering vs client-side)?
|
||||
- [ ] Are user inputs used in queries or rendered as content without sanitization?
|
||||
- [ ] Were existing patterns/conventions in AGENTS.md ignored in favor of something novel?
|
||||
- [ ] Are secrets, keys, or env-specific values hardcoded?
|
||||
- [ ] Does the code use the `any` type anywhere?
|
||||
- [ ] Is the code Capacitor-compatible (no `<a download>`, no `window.open()`)?
|
||||
- [ ] Are new Nostr event kinds documented in NIP.md with links to relevant specs?
|
||||
- [ ] Are there any new images >100KB or other large binary assets that should be hosted externally?
|
||||
- [ ] Is there any use of dangerouslySetInnerHTML, eval, innerHTML, or SVG string interpolation?
|
||||
- [ ] Is any data from a Nostr event (tags, content, pubkey, URLs) used in a security-sensitive context (href, src, query filter, trust decision) without validation?
|
||||
|
||||
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
|
||||
|
||||
Then answer: "If you were the people who have to maintain this codebase and deal
|
||||
with all long-term issues, what would be your biggest concerns about this
|
||||
implementation?"
|
||||
```
|
||||
|
||||
Address every finding before submitting.
|
||||
|
||||
### 9. Deploy a live preview
|
||||
|
||||
Deploy your branch so reviewers can test it without pulling your code:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npx surge dist your-branch-name.surge.sh
|
||||
```
|
||||
|
||||
Or use Netlify, Vercel, or any static hosting. Include the live preview URL in your MR description.
|
||||
|
||||
### 10. Take screenshots
|
||||
|
||||
Capture before and after screenshots of any UI changes. Include them directly in the MR description. If your change has no visual component, state that explicitly.
|
||||
|
||||
### 11. Submit
|
||||
|
||||
Fill out every field in the MR template. Incomplete MRs will not be reviewed.
|
||||
|
||||
## What gets your MR closed without review
|
||||
|
||||
- No linked issue
|
||||
- Feature MRs with no clear alignment with the [Ditto Philosophy](https://about.ditto.pub/philosophy)
|
||||
- Features that fail the product decision filter (not magnetic, not threatening to the status quo, not peaceful)
|
||||
- Incomplete MR template (missing checklist, screenshots, or preview URL)
|
||||
- Changes that go beyond what was asked for (scope creep)
|
||||
- Placeholder code, dead code, or debug artifacts
|
||||
- Evidence of low-quality AI generation ("In a real application..." comments, hallucinated APIs, generic template code)
|
||||
- Failing test suite
|
||||
- No evidence of planning (code-first, think-later approach produces recognizable patterns)
|
||||
- Undocumented Nostr event kinds (new kinds must be in NIP.md)
|
||||
- Large binary assets committed to git (images >100KB, fonts, videos)
|
||||
- Security issues (dangerouslySetInnerHTML, eval, innerHTML, unsanitized user input)
|
||||
|
||||
## MR review process
|
||||
|
||||
1. The CI pipeline validates your MR description automatically. If it fails, read the error message and fix your MR description.
|
||||
2. Maintainers will review your MR when all CI checks pass and the template is complete.
|
||||
3. If changes are requested, address them promptly. Stale MRs will be closed.
|
||||
|
||||
We appreciate your interest in contributing. These standards exist because reviewing a low-quality MR takes 3x longer than doing the work ourselves. Help us help you by following the process.
|
||||
@@ -138,6 +138,17 @@ src/
|
||||
public/ Static assets, icons, manifest
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions but have high standards. Please read the full [Contributing Guide](CONTRIBUTING.md) before submitting a merge request. The short version:
|
||||
|
||||
- **Bug fixes**: One bug, one MR. Keep it small and focused.
|
||||
- **New features**: Must link to an existing issue and align with the [Ditto Philosophy](https://about.ditto.pub/philosophy).
|
||||
- **Required**: Live preview URL, before/after screenshots, completed self-review checklist.
|
||||
- **Required tools**: Claude Opus 4.6 (or latest frontier model), an AI coding agent with plan mode.
|
||||
|
||||
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy) to understand what Ditto is and isn't.
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.6.1"
|
||||
versionName "2.6.4"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -11,9 +11,11 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-keyboard')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capgo-capacitor-autofill-save-password')
|
||||
implementation project(':capacitor-secure-storage-plugin')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.JavascriptInterface;
|
||||
@@ -13,6 +15,8 @@ import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ProgressBar;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
@@ -30,6 +34,8 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
|
||||
@@ -79,19 +85,41 @@ public class SandboxPlugin extends Plugin {
|
||||
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
|
||||
sandboxes.put(sandboxId, sandbox);
|
||||
|
||||
// Add the WebView on top of the Capacitor WebView.
|
||||
// The parent is a CoordinatorLayout — using the wrong LayoutParams
|
||||
// type causes a ClassCastException when it intercepts touch events.
|
||||
// Add the container (WebView + spinner overlay) on top of the
|
||||
// Capacitor WebView. The parent is a CoordinatorLayout — using
|
||||
// the wrong LayoutParams type causes a ClassCastException when
|
||||
// it intercepts touch events.
|
||||
View capWebView = getBridge().getWebView();
|
||||
ViewGroup parent = (ViewGroup) capWebView.getParent();
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
parent.addView(sandbox.webView, params);
|
||||
parent.addView(sandbox.container, params);
|
||||
|
||||
// The spinner is now visible. Navigation is deferred until the
|
||||
// JS layer calls navigate() — this allows the caller to
|
||||
// pre-fetch blobs while the spinner animates.
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void navigate(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the initial page.
|
||||
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
@@ -131,7 +159,7 @@ public class SandboxPlugin extends Plugin {
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
sandbox.webView.setLayoutParams(params);
|
||||
sandbox.container.setLayoutParams(params);
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
@@ -214,9 +242,9 @@ public class SandboxPlugin extends Plugin {
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.remove(sandboxId);
|
||||
if (sandbox != null) {
|
||||
ViewGroup parent = (ViewGroup) sandbox.webView.getParent();
|
||||
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
|
||||
if (parent != null) {
|
||||
parent.removeView(sandbox.webView);
|
||||
parent.removeView(sandbox.container);
|
||||
}
|
||||
sandbox.webView.destroy();
|
||||
}
|
||||
@@ -244,13 +272,19 @@ public class SandboxPlugin extends Plugin {
|
||||
*/
|
||||
private static class SandboxInstance {
|
||||
final String id;
|
||||
/** Wrapper layout that holds the WebView and the loading overlay. */
|
||||
final FrameLayout container;
|
||||
final WebView webView;
|
||||
final SandboxPlugin plugin;
|
||||
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
|
||||
/** Native spinner overlay, shown while the sandbox content loads. */
|
||||
private ProgressBar spinner;
|
||||
|
||||
SandboxInstance(String id, SandboxPlugin plugin) {
|
||||
this.id = id;
|
||||
this.plugin = plugin;
|
||||
|
||||
this.container = new FrameLayout(plugin.getActivity());
|
||||
this.webView = new WebView(plugin.getActivity());
|
||||
|
||||
WebSettings settings = webView.getSettings();
|
||||
@@ -260,13 +294,53 @@ public class SandboxPlugin extends Plugin {
|
||||
settings.setAllowContentAccess(false);
|
||||
settings.setDatabaseEnabled(true);
|
||||
|
||||
webView.setBackgroundColor(Color.WHITE);
|
||||
webView.setBackgroundColor(Color.parseColor("#14161f"));
|
||||
|
||||
// Add JavaScript interface for script->native communication.
|
||||
webView.addJavascriptInterface(new SandboxBridge(this), "__sandboxNative");
|
||||
|
||||
// Inject the bridge script and intercept requests.
|
||||
webView.setWebViewClient(new SandboxWebViewClient(this));
|
||||
|
||||
// Build the container: WebView fills it, spinner overlays on top.
|
||||
container.addView(webView, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
// Native spinner overlay — uses the Android indeterminate
|
||||
// ProgressBar which animates on the render thread, so it keeps
|
||||
// spinning even when the main/IO threads are busy.
|
||||
spinner = new ProgressBar(plugin.getActivity());
|
||||
spinner.setIndeterminate(true);
|
||||
spinner.getIndeterminateDrawable().setColorFilter(
|
||||
Color.parseColor("#7c5cdc"), PorterDuff.Mode.SRC_IN);
|
||||
FrameLayout.LayoutParams spinnerParams = new FrameLayout.LayoutParams(
|
||||
dpToPx(plugin, 32), dpToPx(plugin, 32), Gravity.CENTER);
|
||||
container.addView(spinner, spinnerParams);
|
||||
|
||||
// Dark background behind the spinner.
|
||||
View overlay = new View(plugin.getActivity());
|
||||
overlay.setBackgroundColor(Color.parseColor("#14161f"));
|
||||
// Insert the overlay between the WebView (index 0) and spinner (index 1)
|
||||
// so it covers the WebView but sits behind the spinner.
|
||||
container.addView(overlay, 1, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
}
|
||||
|
||||
/** Remove the native loading overlay. Safe to call multiple times. */
|
||||
void hideSpinner() {
|
||||
if (spinner != null) {
|
||||
// Remove spinner and overlay (indices 2 and 1 after WebView at 0).
|
||||
if (container.getChildCount() > 2) container.removeViewAt(2); // spinner
|
||||
if (container.getChildCount() > 1) container.removeViewAt(1); // overlay
|
||||
spinner = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int dpToPx(SandboxPlugin plugin, int dp) {
|
||||
float density = plugin.getActivity().getResources().getDisplayMetrics().density;
|
||||
return Math.round(dp * density);
|
||||
}
|
||||
|
||||
void postMessageToWebView(String jsonString) {
|
||||
@@ -353,8 +427,11 @@ public class SandboxPlugin extends Plugin {
|
||||
// Emit to JS.
|
||||
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
|
||||
|
||||
// Block this thread until JS responds (with a timeout).
|
||||
WebResourceResponse response = pending.awaitResponse(10000);
|
||||
// Block until JS responds. Each asset is fetched from a Blossom
|
||||
// server over the network, so we need a generous timeout. The
|
||||
// WebView IO thread pool has ~6 threads; if all are blocked,
|
||||
// subsequent requests queue until a thread frees up.
|
||||
WebResourceResponse response = pending.awaitResponse(60000);
|
||||
|
||||
if (response != null) {
|
||||
return response;
|
||||
@@ -377,6 +454,11 @@ public class SandboxPlugin extends Plugin {
|
||||
bridgeInjected = true;
|
||||
view.evaluateJavascript(getBridgeScript(), null);
|
||||
}
|
||||
|
||||
// Remove the native spinner once the first page has finished
|
||||
// loading (all initial resources resolved). This runs on the
|
||||
// main thread, so the removal is safe.
|
||||
sandbox.hideSpinner();
|
||||
}
|
||||
|
||||
private String getBridgeScript() {
|
||||
@@ -446,11 +528,12 @@ public class SandboxPlugin extends Plugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending request that blocks the WebViewClient thread until resolved.
|
||||
* A pending request that blocks the WebViewClient IO thread until JS
|
||||
* responds with the complete resource.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private WebResourceResponse response;
|
||||
private final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
|
||||
private volatile WebResourceResponse response;
|
||||
private final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
void resolve(WebResourceResponse response) {
|
||||
this.response = response;
|
||||
@@ -459,7 +542,7 @@ public class SandboxPlugin extends Plugin {
|
||||
|
||||
WebResourceResponse awaitResponse(long timeoutMs) {
|
||||
try {
|
||||
latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
@@ -8,11 +8,17 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-keyboard'
|
||||
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
include ':capgo-capacitor-autofill-save-password'
|
||||
project(':capgo-capacitor-autofill-save-password').projectDir = new File('../node_modules/@capgo/capacitor-autofill-save-password/android')
|
||||
|
||||
include ':capacitor-secure-storage-plugin'
|
||||
project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android')
|
||||
|
||||
+11
-1
@@ -19,7 +19,17 @@ const config: CapacitorConfig = {
|
||||
backgroundColor: '#14161f',
|
||||
contentInset: 'never',
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
|
||||
import customRules from "./eslint-rules/index.js";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "android"] },
|
||||
{ ignores: ["dist", "android", "ios"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -67,6 +68,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
B1A2C3D40004000100000002 /* App.entitlements */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
|
||||
@@ -303,15 +305,17 @@
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GZLTTH5DLM;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.1;
|
||||
MARKETING_VERSION = 2.6.4;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -325,15 +329,17 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GZLTTH5DLM;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.1;
|
||||
MARKETING_VERSION = 2.6.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>webcredentials:ditto.pub</string>
|
||||
<string>webcredentials:ditto.pub?mode=developer</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -49,6 +49,8 @@
|
||||
<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>
|
||||
</dict>
|
||||
|
||||
@@ -17,6 +17,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let jsName = "SandboxPlugin"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "navigate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "updateFrame", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "postMessage", returnType: CAPPluginReturnPromise),
|
||||
@@ -58,16 +59,33 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
)
|
||||
self.sandboxes[sandboxId] = sandbox
|
||||
|
||||
// Add the WebView on top of the Capacitor WebView.
|
||||
// Add the container (WebView + spinner overlay) on top of
|
||||
// the Capacitor WebView.
|
||||
if let bridge = self.bridge,
|
||||
let webView = bridge.webView {
|
||||
webView.superview?.addSubview(sandbox.webView)
|
||||
webView.superview?.addSubview(sandbox.containerView)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func navigate(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let sandbox = self?.sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.navigateToApp()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updateFrame(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
@@ -87,7 +105,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.webView.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
sandbox.containerView.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
@@ -153,7 +171,7 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
|
||||
sandbox.webView.removeFromSuperview()
|
||||
sandbox.containerView.removeFromSuperview()
|
||||
sandbox.schemeHandler.cancelAll()
|
||||
}
|
||||
call.resolve()
|
||||
@@ -183,13 +201,19 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// MARK: - SandboxInstance
|
||||
|
||||
/// Manages a single sandboxed WKWebView instance.
|
||||
private class SandboxInstance: NSObject, WKScriptMessageHandler {
|
||||
private class SandboxInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
|
||||
let id: String
|
||||
let webView: WKWebView
|
||||
let schemeHandler: SandboxSchemeHandler
|
||||
private weak var plugin: SandboxPlugin?
|
||||
private let customScheme: String
|
||||
|
||||
/// Container view that holds the WebView and spinner overlay.
|
||||
let containerView: UIView
|
||||
|
||||
/// Native spinner overlay, removed when the first page finishes loading.
|
||||
private var spinnerOverlay: UIView?
|
||||
|
||||
init(id: String, frame: CGRect, plugin: SandboxPlugin) {
|
||||
self.id = id
|
||||
self.plugin = plugin
|
||||
@@ -224,19 +248,54 @@ private class SandboxInstance: NSObject, WKScriptMessageHandler {
|
||||
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
|
||||
self.webView = WKWebView(frame: frame, configuration: config)
|
||||
// Container view that holds the WebView + spinner overlay.
|
||||
self.containerView = UIView(frame: frame)
|
||||
|
||||
self.webView = WKWebView(frame: containerView.bounds, configuration: config)
|
||||
self.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.webView.isOpaque = false
|
||||
self.webView.backgroundColor = .white
|
||||
self.webView.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
|
||||
self.webView.scrollView.backgroundColor = self.webView.backgroundColor
|
||||
self.webView.scrollView.bounces = false
|
||||
self.containerView.addSubview(self.webView)
|
||||
|
||||
// Dark overlay behind the spinner.
|
||||
let overlay = UIView(frame: containerView.bounds)
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
overlay.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
|
||||
self.containerView.addSubview(overlay)
|
||||
|
||||
// Native spinner — uses UIActivityIndicatorView which animates on
|
||||
// the render thread independently of JS/main-thread work.
|
||||
let spinner = UIActivityIndicatorView(style: .medium)
|
||||
spinner.color = UIColor(red: 124/255.0, green: 92/255.0, blue: 220/255.0, alpha: 1)
|
||||
spinner.translatesAutoresizingMaskIntoConstraints = false
|
||||
spinner.startAnimating()
|
||||
overlay.addSubview(spinner)
|
||||
NSLayoutConstraint.activate([
|
||||
spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
||||
spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
|
||||
])
|
||||
|
||||
self.spinnerOverlay = overlay
|
||||
|
||||
super.init()
|
||||
|
||||
// Register the message handler after super.init().
|
||||
// Register the message handler and navigation delegate after super.init().
|
||||
userContentController.add(self, name: "sandboxBridge")
|
||||
self.webView.navigationDelegate = self
|
||||
}
|
||||
|
||||
// Load the initial page via the custom scheme.
|
||||
let initialURL = URL(string: "\(self.customScheme)://app/index.html")!
|
||||
self.webView.load(URLRequest(url: initialURL))
|
||||
/// Navigate the WebView to the sandbox's entry point.
|
||||
func navigateToApp() {
|
||||
let initialURL = URL(string: "\(customScheme)://app/index.html")!
|
||||
webView.load(URLRequest(url: initialURL))
|
||||
}
|
||||
|
||||
/// Remove the native loading overlay. Safe to call multiple times.
|
||||
func hideSpinner() {
|
||||
spinnerOverlay?.removeFromSuperview()
|
||||
spinnerOverlay = nil
|
||||
}
|
||||
|
||||
/// Post a JSON-RPC message to injected scripts inside the WebView.
|
||||
@@ -270,6 +329,13 @@ private class SandboxInstance: NSObject, WKScriptMessageHandler {
|
||||
plugin?.emitScriptMessage(sandboxId: id, message: body)
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
/// Remove the spinner overlay once the first page finishes loading.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
hideSpinner()
|
||||
}
|
||||
|
||||
// MARK: - Bridge Script
|
||||
|
||||
/// JavaScript injected at document start that provides:
|
||||
|
||||
@@ -14,9 +14,11 @@ let package = Package(
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
|
||||
.package(name: "CapgoCapacitorAutofillSavePassword", path: "../../../node_modules/@capgo/capacitor-autofill-save-password"),
|
||||
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -26,9 +28,11 @@ let package = Package(
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorShare", package: "CapacitorShare"),
|
||||
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
|
||||
.product(name: "CapgoCapacitorAutofillSavePassword", package: "CapgoCapacitorAutofillSavePassword"),
|
||||
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
Generated
+362
-190
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.4",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/keyboard": "^8.0.2",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-autofill-save-password": "^8.0.22",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -59,7 +60,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/react": "^0.5.0",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -91,10 +92,11 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.0.10",
|
||||
"@unhead/react": "^2.0.10",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -216,19 +218,66 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.27.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz",
|
||||
"integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==",
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.5",
|
||||
"@babel/types": "^7.27.3",
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@babel/parser": "^8.0.0-rc.3",
|
||||
"@babel/types": "^8.0.0-rc.3",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"@types/jsesc": "^2.5.0",
|
||||
"jsesc": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/helper-string-parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^8.0.0-rc.3"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/types": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0-rc.3",
|
||||
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
@@ -379,6 +428,15 @@
|
||||
"@capacitor/core": "^8.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/keyboard": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.2.tgz",
|
||||
"integrity": "sha512-he6xKmTBp5AhVrWJeEi6RYkJ25FjLLdNruBU2wafpITk3Nb7UdzOj96x3K6etFuEj8/rtn9WXBTs1o2XA86A1A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/local-notifications": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.0.1.tgz",
|
||||
@@ -397,21 +455,21 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz",
|
||||
"integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/synapse": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@capgo/capacitor-autofill-save-password": {
|
||||
"version": "8.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@capgo/capacitor-autofill-save-password/-/capacitor-autofill-save-password-8.0.22.tgz",
|
||||
"integrity": "sha512-l6RvtTgdZWDx5fu74QcdV0NLioKmI4PwzCnscpl00ZjxHjecR/yVoB5ufsOYLAY2qyLP3jx9PUpFvEo2rPNHPA==",
|
||||
"license": "MPL-2.0",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
|
||||
@@ -1789,17 +1847,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.2.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
@@ -1811,15 +1875,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/set-array": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
||||
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -1827,9 +1882,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.25",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
||||
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -2389,20 +2444,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
@@ -2527,9 +2584,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.1.tgz",
|
||||
"integrity": "sha512-2JXxEl4e6FIFhbi96Dwv2knu5qAACYulo1a0oVell/aS8KCWsBTPd1+v0EUra0yqiUA3Q1nVLrk8mx7kQYH/yQ==",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.0.tgz",
|
||||
"integrity": "sha512-IQf74SSusSIyhI9FkUQSUTsX20yeww5xHIUeexvxcWXEpVhYJYCwduK2yRB75NvYgXjcqYeDUGA2RvzBhDc/eA==",
|
||||
"dependencies": {
|
||||
"@nostrify/nostrify": "0.51.1",
|
||||
"@nostrify/types": "0.36.9"
|
||||
@@ -2556,9 +2613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.122.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
||||
"version": "0.123.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz",
|
||||
"integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -5391,9 +5448,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5408,9 +5465,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5425,9 +5482,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5442,9 +5499,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5459,9 +5516,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -5476,9 +5533,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5493,9 +5550,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5510,9 +5567,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -5527,9 +5584,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -5544,9 +5601,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5561,9 +5618,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5578,9 +5635,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5595,9 +5652,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -5605,16 +5662,18 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/wasm-runtime": "^1.1.1"
|
||||
"@emnapi/core": "1.9.1",
|
||||
"@emnapi/runtime": "1.9.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5629,9 +5688,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5653,9 +5712,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
|
||||
"integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0",
|
||||
@@ -6504,6 +6563,12 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsesc": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
|
||||
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -6895,30 +6960,33 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@unhead/addons": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/addons/-/addons-2.0.10.tgz",
|
||||
"integrity": "sha512-9+w/m+X5e7CDKXKGTym1N4MpBjrRC89cfl95RDgKwBcFJfQ3pZu50llIjx/j462VqtrNMXddBKcUnfWvQyapuw==",
|
||||
"version": "2.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/addons/-/addons-2.1.13.tgz",
|
||||
"integrity": "sha512-xiM5ERU68FEuiBCCiPZ1EDkja+kH4hKKot/7dNJufneACtGoAFWnKUcmj/iB9BKjVwgBBF3sFYO3qXjkNFXWxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.1.4",
|
||||
"@rollup/pluginutils": "^5.3.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17",
|
||||
"mlly": "^1.7.4",
|
||||
"ufo": "^1.6.1",
|
||||
"unplugin": "^2.3.4",
|
||||
"unplugin-ast": "^0.15.0"
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.0",
|
||||
"ufo": "^1.6.3",
|
||||
"unplugin": "^3.0.0",
|
||||
"unplugin-ast": "^0.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/harlan-zw"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"unhead": "^2.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@unhead/react": {
|
||||
"version": "2.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.12.tgz",
|
||||
"integrity": "sha512-1xXFrxyw29f+kScXfEb0GxjlgtnHxoYau0qpW9k8sgWhQUNnE5gNaH3u+rNhd5IqhyvbdDRJpQ25zoz0HIyGaw==",
|
||||
"version": "2.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.13.tgz",
|
||||
"integrity": "sha512-gC48tNJ0UtbithkiKCc2WUlxbVVk5o171EtruS2w2hQUblfYFHzCPu2hljjT1e0tUHXXqN8EMv7mpxHddMB2sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unhead": "2.1.12"
|
||||
"unhead": "2.1.13"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/harlan-zw"
|
||||
@@ -7192,9 +7260,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -7329,21 +7397,68 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.1.0.tgz",
|
||||
"integrity": "sha512-ROM2LlXbZBZVk97crfw8PGDOBzzsJvN2uJCmwswvPUNyfH14eg90mSN3xNqsri1JS1G9cz0VzeDUhxJkTrr4Ew==",
|
||||
"version": "3.0.0-beta.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz",
|
||||
"integrity": "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.3",
|
||||
"@babel/parser": "^8.0.0-beta.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/helper-string-parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^8.0.0-rc.3"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/types": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0-rc.3",
|
||||
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/astral-regex": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||
@@ -7645,6 +7760,15 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/capacitor-secure-storage-plugin": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.13.0.tgz",
|
||||
"integrity": "sha512-+rLC/9Z0LTaRRt6L6HjBwcDh5gqgI3NPmDSwo4hk41XQOy3EBrRo81VleIqFsowsMA3oMT+E59Bl8/HiWk0nhQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
@@ -9991,15 +10115,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string-ast": {
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-0.9.1.tgz",
|
||||
"integrity": "sha512-18dv2ZlSSgJ/jDWlZGKfnDJx56ilNlYq9F7NnwuWTErsmYmqJ2TWE4l1o2zlUHBYUGBy3tIhPCC1gxq8M5HkMA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
|
||||
"integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"magic-string": "^0.30.17"
|
||||
"magic-string": "^0.30.19"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
@@ -10982,15 +11106,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
|
||||
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.0",
|
||||
"pathe": "^2.0.1",
|
||||
"pkg-types": "^1.3.0",
|
||||
"ufo": "^1.5.4"
|
||||
"acorn": "^8.16.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^1.3.1",
|
||||
"ufo": "^1.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
@@ -12564,14 +12688,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.122.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.12"
|
||||
"@oxc-project/types": "=0.123.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.13"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -12580,27 +12704,27 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.13",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.13",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -13654,9 +13778,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
|
||||
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
@@ -13667,9 +13791,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unhead": {
|
||||
"version": "2.1.12",
|
||||
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz",
|
||||
"integrity": "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==",
|
||||
"version": "2.1.13",
|
||||
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.13.tgz",
|
||||
"integrity": "sha512-jO9M1sI6b2h/1KpIu4Jeu+ptumLmUKboRRLxys5pYHFeT+lqTzfNHbYUX9bxVDhC1FBszAGuWcUVlmvIPsah8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hookable": "^6.0.1"
|
||||
@@ -13790,37 +13914,85 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz",
|
||||
"integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
|
||||
"integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-ast/-/unplugin-ast-0.15.0.tgz",
|
||||
"integrity": "sha512-3ReKQUmmYEcNhjoyiwfFuaJU0jkZNcNk8+iLdLVWk73iojVjJLiF/QhnpAFf3O7CJd6bqhWBzNyQ68Udp2fi5Q==",
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-ast/-/unplugin-ast-0.16.0.tgz",
|
||||
"integrity": "sha512-1ow2FlRznoSKE7Fjk2bSxqDsvHyj/O876RqsNlipsM6A+I91t7Mi+jG7tCNNcl3vZx14z4pGXBLSl8KOPrMuFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.27.1",
|
||||
"ast-kit": "^2.0.0",
|
||||
"magic-string-ast": "^0.9.1",
|
||||
"unplugin": "^2.3.2"
|
||||
"@babel/generator": "^8.0.0-beta.4",
|
||||
"@babel/parser": "^8.0.0-beta.4",
|
||||
"@babel/types": "^8.0.0-beta.4",
|
||||
"ast-kit": "^3.0.0-beta.1",
|
||||
"magic-string-ast": "^1.0.3",
|
||||
"unplugin": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/helper-string-parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^8.0.0-rc.3"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/types": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0-rc.3",
|
||||
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
@@ -14012,16 +14184,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
|
||||
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
|
||||
"integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.12",
|
||||
"rolldown": "1.0.0-rc.13",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@@ -14039,7 +14211,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
@@ -14628,9 +14800,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node/node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15329,9 +15501,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+7
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -18,9 +18,10 @@
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/keyboard": "^8.0.2",
|
||||
"@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",
|
||||
@@ -66,7 +67,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/react": "^0.5.0",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -98,10 +99,11 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.0.10",
|
||||
"@unhead/react": "^2.0.10",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"GZLTTH5DLM.pub.ditto.app"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.4] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
|
||||
|
||||
### Changed
|
||||
- Empty feeds show a friendlier state with a discover button to help you find people to follow
|
||||
- Signup flow simplified -- cleaner profile step with a single Continue button
|
||||
|
||||
### Fixed
|
||||
- Avatar fallback now shows the user's initial instead of a question mark
|
||||
- Android 16+ devices no longer have content hidden behind system bars
|
||||
- Signup dialog background clears properly when switching between light and dark themes
|
||||
- Sticky compose button stays anchored to the bottom even on empty feeds
|
||||
|
||||
## [2.6.3] - 2026-04-10
|
||||
|
||||
### Added
|
||||
- Lightning invoices embedded in posts now render as tappable payment cards
|
||||
- Blobbi companions in the feed reflect their current condition and projected health
|
||||
|
||||
### Changed
|
||||
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
|
||||
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
|
||||
- "Request to Vanish" renamed to "Delete Account" for clarity
|
||||
|
||||
### Fixed
|
||||
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
|
||||
- Security hardening for URLs and styles sourced from the network
|
||||
|
||||
## [2.6.2] - 2026-04-08
|
||||
|
||||
### Added
|
||||
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
|
||||
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
|
||||
- "Write a letter" option on profile menus for a more personal way to reach out
|
||||
- Push vs persistent notification delivery option on Android
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps always open fullscreen for a more immersive experience
|
||||
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
|
||||
- Profile fields now appear inline instead of in a separate right sidebar
|
||||
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
|
||||
|
||||
### Fixed
|
||||
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
|
||||
- File downloads now save directly to Documents on iOS and Android instead of silently failing
|
||||
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
|
||||
- iOS swipe-back navigation works correctly throughout the app
|
||||
- Blobbi companions appear reliably on profiles instead of sometimes going missing
|
||||
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
+9
-9
@@ -1,8 +1,7 @@
|
||||
// NOTE: This file should normally not be modified unless you are adding a new provider.
|
||||
// To add new routes, edit the AppRouter.tsx file.
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { StatusBar, Style } from "@capacitor/status-bar";
|
||||
import { Capacitor, SystemBars, SystemBarsStyle } from "@capacitor/core";
|
||||
import { NostrLoginProvider } from "@nostrify/react/login";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { InferSeoMetaPlugin } from "@unhead/addons";
|
||||
@@ -24,6 +23,7 @@ import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { PROTOCOL_MODE } from "@/lib/dmConstants";
|
||||
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
@@ -183,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
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
@@ -200,7 +200,7 @@ export function App() {
|
||||
<SentryProvider>
|
||||
<PlausibleProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NostrLoginProvider storageKey="nostr:login">
|
||||
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<NativeNotifications />
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -297,11 +297,10 @@ export function AdvancedSettings() {
|
||||
<div className="px-3 pt-3 pb-4 space-y-4">
|
||||
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Request to Vanish</h3>
|
||||
<h3 className="text-sm font-medium">Delete Account</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Permanently request all relays to delete your data, including your profile,
|
||||
posts, reactions, and direct messages. This action is irreversible and legally
|
||||
binding in some jurisdictions (NIP-62).
|
||||
Permanently delete your data from the network, including your profile,
|
||||
posts, reactions, and direct messages. This action is irreversible.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -310,7 +309,7 @@ export function AdvancedSettings() {
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setVanishDialogOpen(true)}
|
||||
>
|
||||
Request to Vanish
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { NsitePreviewDialog } from '@/components/NsitePreviewDialog';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAddrEvent } from '@/hooks/useEvent';
|
||||
import { NostrURI } from '@/lib/NostrURI';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Get a tag value by name. */
|
||||
@@ -106,7 +107,7 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
const about = metadata.about;
|
||||
const picture = metadata.picture;
|
||||
const banner = metadata.banner;
|
||||
const websiteUrl = getWebsiteUrl(event.tags, metadata);
|
||||
const websiteUrl = sanitizeUrl(getWebsiteUrl(event.tags, metadata));
|
||||
const hashtags = getAllTags(event.tags, 't');
|
||||
|
||||
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { usePublishRSVP } from '@/hooks/usePublishRSVP';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// --- Helpers ---
|
||||
@@ -159,7 +160,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
const location = locationRaw ? parseLocation(locationRaw) : undefined;
|
||||
const summary = getTag(event.tags, 'summary');
|
||||
const hashtags = getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean);
|
||||
const links = getAllTags(event.tags, 'r').map(([, v]) => v).filter(Boolean);
|
||||
const links = getAllTags(event.tags, 'r').map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
|
||||
|
||||
const eventCoord = useMemo(() => getEventCoord(event), [event]);
|
||||
const dateStr = useMemo(() => formatDetailDate(event), [event]);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -92,7 +93,7 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
// Extract website URL from description if present
|
||||
const descriptionUrl = useMemo(() => {
|
||||
const urlMatch = description.match(/https?:\/\/[^\s]+/);
|
||||
return urlMatch?.[0];
|
||||
return sanitizeUrl(urlMatch?.[0]);
|
||||
}, [description]);
|
||||
|
||||
// Description text without trailing URL (if the URL is the last thing)
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useInsertText } from '@/hooks/useInsertText';
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import { resizeImage } from '@/lib/resizeImage';
|
||||
|
||||
@@ -1071,7 +1072,7 @@ export function ComposeBox({
|
||||
<Avatar shape={avatarShape} className="size-12 shrink-0 mt-0.5">
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{(metadata?.name?.[0] || '?').toUpperCase()}
|
||||
{(metadata?.display_name || metadata?.name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
|
||||
@@ -292,6 +292,9 @@ export function CreateBadgeDialog({ open, onOpenChange }: CreateBadgeDialogProps
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recommended aspect ratio is 1:1 (max 1024x1024 px).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badge name */}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FeedEmptyStateProps {
|
||||
@@ -5,31 +8,45 @@ interface FeedEmptyStateProps {
|
||||
message: string;
|
||||
/** Called when the user clicks "Switch to Global". Omit to hide the button. */
|
||||
onSwitchToGlobal?: () => void;
|
||||
/** Show a "Discover people" link to /packs. */
|
||||
showDiscover?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent empty state for Follows/Global feed tabs across all feed pages.
|
||||
*
|
||||
* - Follows tab: pass `onSwitchToGlobal` to render a "Switch to Global" CTA.
|
||||
* - Global tab: omit `onSwitchToGlobal`; the message should guide the user
|
||||
* - Follows tab: pass `onSwitchToGlobal` and `showDiscover` to render CTAs.
|
||||
* - Global tab: omit both; the message should guide the user
|
||||
* to check their relay connections.
|
||||
*/
|
||||
export function FeedEmptyState({
|
||||
message,
|
||||
onSwitchToGlobal,
|
||||
showDiscover,
|
||||
className,
|
||||
}: FeedEmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('py-16 px-8 text-center space-y-3', className)}>
|
||||
<p className="text-muted-foreground break-all">{message}</p>
|
||||
{onSwitchToGlobal && (
|
||||
<button
|
||||
className="text-sm text-primary hover:underline"
|
||||
onClick={onSwitchToGlobal}
|
||||
>
|
||||
Switch to Global
|
||||
</button>
|
||||
<div className={cn('py-20 px-8 flex flex-col items-center text-center', className)}>
|
||||
<div className="size-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Users className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground max-w-xs">{message}</p>
|
||||
|
||||
{(showDiscover || onSwitchToGlobal) && (
|
||||
<div className="flex flex-col gap-2 mt-5 w-full max-w-xs">
|
||||
{showDiscover && (
|
||||
<Button asChild className="rounded-full">
|
||||
<Link to="/packs">Discover people to follow</Link>
|
||||
</Button>
|
||||
)}
|
||||
{onSwitchToGlobal && (
|
||||
<Button variant="ghost" className="rounded-full" onClick={onSwitchToGlobal}>
|
||||
Browse the Global feed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Extract the first value of a tag by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
@@ -75,7 +76,7 @@ interface FileMetadataContentProps {
|
||||
* rounded card below it (similar to YouTube's description box).
|
||||
*/
|
||||
export function FileMetadataContent({ event, compact }: FileMetadataContentProps) {
|
||||
const url = getTag(event.tags, 'url');
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
const mime = getTag(event.tags, 'm') ?? '';
|
||||
const alt = getTag(event.tags, 'alt');
|
||||
const webxdcId = getTag(event.tags, 'webxdc');
|
||||
|
||||
@@ -9,125 +9,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
|
||||
|
||||
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
|
||||
const MIN_QR_CONTRAST = 3;
|
||||
|
||||
/** Saturation threshold (%) above which a color is considered "colorful". */
|
||||
const COLORFUL_SAT_MIN = 15;
|
||||
/** Lightness range within which a color appears visually colorful. */
|
||||
const COLORFUL_L_MIN = 20;
|
||||
const COLORFUL_L_MAX = 80;
|
||||
|
||||
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
|
||||
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
|
||||
if (!raw) return null;
|
||||
const { h, s, l } = parseHsl(raw);
|
||||
if ([h, s, l].some(isNaN)) return null;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function darkenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l > 0 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.max(0, l - 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function lightenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l < 100 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.min(100, l + 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the best module color from primary and foreground.
|
||||
*
|
||||
* Strongly prefers primary since it carries the theme's brand identity.
|
||||
* Only picks foreground if it is colorful (saturation > threshold) AND
|
||||
* has significantly better contrast (> 1.5x) against the QR background.
|
||||
*/
|
||||
function pickModuleColor(
|
||||
primary: { h: number; s: number; l: number },
|
||||
foreground: { h: number; s: number; l: number } | null,
|
||||
bgRgb: [number, number, number],
|
||||
): { h: number; s: number; l: number } {
|
||||
const fgIsColorful = foreground
|
||||
&& foreground.s >= COLORFUL_SAT_MIN
|
||||
&& foreground.l >= COLORFUL_L_MIN
|
||||
&& foreground.l <= COLORFUL_L_MAX;
|
||||
|
||||
if (!fgIsColorful) return primary;
|
||||
|
||||
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
|
||||
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
|
||||
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
|
||||
const fgContrast = getContrastRatio(fgRgb, bgRgb);
|
||||
|
||||
// Foreground must be significantly better to override primary
|
||||
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive QR module and background hex colors from the active theme.
|
||||
*
|
||||
* Light themes: white background, best themed color as modules (darkened if needed).
|
||||
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
|
||||
*
|
||||
* "Best themed color" is --primary by default. If --foreground is colorful
|
||||
* (saturation > 15%) and offers better contrast, it wins instead.
|
||||
*/
|
||||
function getThemedQRColors(): { dark: string; light: string } {
|
||||
const primary = readCssHsl('--primary');
|
||||
const foreground = readCssHsl('--foreground');
|
||||
const background = readCssHsl('--background');
|
||||
|
||||
if (!primary) return { dark: '#000000', light: '#ffffff' };
|
||||
|
||||
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
|
||||
|
||||
if (!isDark) {
|
||||
const white: [number, number, number] = [255, 255, 255];
|
||||
const module = pickModuleColor(primary, foreground, white);
|
||||
return { dark: darkenToContrast(module, white), light: '#ffffff' };
|
||||
}
|
||||
|
||||
if (!background) return { dark: '#ffffff', light: '#000000' };
|
||||
const bgRgb = hslToRgb(background.h, background.s, background.l);
|
||||
const module = pickModuleColor(primary, foreground, bgRgb);
|
||||
return {
|
||||
dark: lightenToContrast(module, bgRgb),
|
||||
light: rgbToHex(...bgRgb),
|
||||
};
|
||||
}
|
||||
import { getThemedQRColors } from '@/lib/qrColors';
|
||||
|
||||
interface FollowQRDialogProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BookMarked, Copy, Check, ExternalLink, Globe, Wand2 } from "lucide-reac
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openUrl } from "@/lib/downloadFile";
|
||||
import { sanitizeUrl } from "@/lib/sanitizeUrl";
|
||||
import { NostrURI } from "@/lib/NostrURI";
|
||||
|
||||
interface GitRepoCardProps {
|
||||
@@ -23,7 +24,7 @@ function getFaviconUrl(webUrl: string): string | undefined {
|
||||
export function GitRepoCard({ event }: GitRepoCardProps) {
|
||||
const name = event.tags.find(([n]) => n === "name")?.[1];
|
||||
const description = event.tags.find(([n]) => n === "description")?.[1];
|
||||
const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => v);
|
||||
const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
|
||||
const isPersonalFork = event.tags.some(
|
||||
([n, v]) => n === "t" && v === "personal-fork",
|
||||
);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { NostrEvent, NostrMetadata } from "@nostrify/nostrify";
|
||||
import { useNostr } from "@nostrify/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Heart,
|
||||
@@ -14,7 +12,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,
|
||||
@@ -46,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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -289,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);
|
||||
@@ -297,31 +297,26 @@ function SetupQuestionnaire({
|
||||
next();
|
||||
}, [next]);
|
||||
|
||||
// Download + login handler
|
||||
const handleDownloadAndLogin = useCallback(async () => {
|
||||
// Continue handler for the download step — saves the key via the best
|
||||
// available method (native credential manager on iOS/Android, file download
|
||||
// on web), logs in, and advances to the next step.
|
||||
const handleDownloadContinue = useCallback(async () => {
|
||||
try {
|
||||
const decoded = nip19.decode(nsec);
|
||||
if (decoded.type !== "nsec") throw new Error("Invalid nsec");
|
||||
|
||||
const pubkey = getPublicKey(decoded.data);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const filename = `nostr-${location.hostname.replaceAll(/\./g, "-")}-${npub.slice(5, 9)}.nsec.txt`;
|
||||
|
||||
await downloadTextFile(filename, nsec);
|
||||
await saveNsec(npub, nsec);
|
||||
|
||||
// Let the user know where the file ended up on Android
|
||||
if (Capacitor.getPlatform() === "android") {
|
||||
toast({ title: "Key saved", description: `Saved to Download/${filename}` });
|
||||
}
|
||||
|
||||
// Log in with the new key
|
||||
login.nsec(nsec);
|
||||
next();
|
||||
} catch {
|
||||
toast({
|
||||
title: "Download failed",
|
||||
title: "Save failed",
|
||||
description:
|
||||
"Could not download the key file. Please copy it manually.",
|
||||
"Could not save the key. Please copy it manually.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
@@ -453,7 +448,7 @@ function SetupQuestionnaire({
|
||||
{step === "keygen" && <KeygenStep onGenerate={handleGenerate} />}
|
||||
|
||||
{step === "download" && (
|
||||
<DownloadStep nsec={nsec} onDownload={handleDownloadAndLogin} />
|
||||
<DownloadStep nsec={nsec} onContinue={handleDownloadContinue} />
|
||||
)}
|
||||
|
||||
{step === "profile" && (
|
||||
@@ -520,10 +515,10 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
|
||||
|
||||
function DownloadStep({
|
||||
nsec,
|
||||
onDownload,
|
||||
onContinue,
|
||||
}: {
|
||||
nsec: string;
|
||||
onDownload: () => void;
|
||||
onContinue: () => void;
|
||||
}) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
@@ -534,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>
|
||||
|
||||
@@ -567,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>
|
||||
);
|
||||
@@ -605,9 +599,6 @@ function ProfileStep({
|
||||
banner: "",
|
||||
website: "",
|
||||
});
|
||||
const [extraFields, setExtraFields] = useState<
|
||||
Array<{ label: string; value: string }>
|
||||
>([]);
|
||||
const [cropState, setCropState] = useState<{
|
||||
imageSrc: string;
|
||||
aspect: number;
|
||||
@@ -662,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 {
|
||||
@@ -685,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">
|
||||
@@ -731,8 +715,6 @@ function ProfileStep({
|
||||
}
|
||||
onPickImage={handlePickImage}
|
||||
showNip05={false}
|
||||
extraFields={extraFields}
|
||||
onExtraFieldsChange={setExtraFields}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -742,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>
|
||||
);
|
||||
}
|
||||
@@ -786,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 (
|
||||
<>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { DittoLogo } from '@/components/DittoLogo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTrendingTags } from '@/hooks/useTrending';
|
||||
import { themePresets, coreToTokens, type CoreThemeColors } from '@/themes';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -93,7 +92,6 @@ function ThemeSwatch({
|
||||
export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
|
||||
const { config } = useAppContext();
|
||||
const { theme, customTheme, applyCustomTheme, setTheme } = useTheme();
|
||||
const { data: trendingData } = useTrendingTags();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
@@ -116,8 +114,6 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
|
||||
return null;
|
||||
}, [theme, customTheme]);
|
||||
|
||||
const trendingTags = trendingData?.tags?.slice(0, 12) ?? [];
|
||||
|
||||
const updateScrollButtons = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -245,31 +241,6 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Trending Hashtags ── */}
|
||||
{trendingTags.length > 0 && (
|
||||
<div className="px-4 pb-4 landing-hero-fade" style={{ animationDelay: '320ms' }}>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2.5">
|
||||
Trending now
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{trendingTags.map(({ tag, accounts }) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${tag}`}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-secondary/60 hover:bg-secondary text-xs font-medium text-secondary-foreground transition-colors"
|
||||
>
|
||||
<span className="text-primary">#</span>{tag}
|
||||
{accounts > 1 && (
|
||||
<span className="text-muted-foreground text-[10px] ml-0.5">
|
||||
{accounts}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Divider into feed ── */}
|
||||
<div className="border-b border-border" />
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function LeftSidebar() {
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const getDisplayName = (account: Account) => account.metadata.name ?? genUserName(account.pubkey);
|
||||
const getDisplayName = (account: Account) => account.metadata.display_name || account.metadata.name || genUserName(account.pubkey);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setAccountPopoverOpen(false);
|
||||
@@ -151,7 +151,7 @@ export function LeftSidebar() {
|
||||
<Avatar shape={currentUserAvatarShape} className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{(metadata?.name?.[0] || '?').toUpperCase()}
|
||||
{(metadata?.display_name || metadata?.name || genUserName(user.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Zap, Copy, Check, ExternalLink } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { getThemedQRColors } from '@/lib/qrColors';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LightningInvoiceCardProps {
|
||||
invoice: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Parse the sats amount from a BOLT11 invoice's human-readable part. */
|
||||
function parseBolt11Amount(bolt11: string): number | null {
|
||||
const match = bolt11.toLowerCase().match(/^ln\w+?(\d+)([munp]?)1/);
|
||||
if (!match) return null;
|
||||
const value = parseInt(match[1], 10);
|
||||
if (isNaN(value)) return null;
|
||||
const multiplier = match[2];
|
||||
switch (multiplier) {
|
||||
case 'm': return value * 100_000; // milli-BTC → sats
|
||||
case 'u': return value * 100; // micro-BTC → sats
|
||||
case 'n': return value / 10; // nano-BTC → sats
|
||||
case 'p': return value / 10_000; // pico-BTC → sats
|
||||
default: return value * 100_000_000; // BTC → sats
|
||||
}
|
||||
}
|
||||
|
||||
/** Format sats with thousands separator. */
|
||||
function formatSats(sats: number): string {
|
||||
if (sats < 1) return '<1';
|
||||
const rounded = Math.round(sats);
|
||||
return rounded.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline card for rendering a BOLT11 lightning invoice found in note content.
|
||||
* Horizontal layout with theme-aware QR that expands on tap.
|
||||
* Amount text scales to fit via container query units.
|
||||
*/
|
||||
export function LightningInvoiceCard({ invoice, className }: LightningInvoiceCardProps) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [qrExpanded, setQrExpanded] = useState(false);
|
||||
|
||||
const amount = parseBolt11Amount(invoice);
|
||||
|
||||
// Generate theme-aware QR code
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const { dark, light } = getThemedQRColors();
|
||||
QRCode.toDataURL(invoice.toUpperCase(), {
|
||||
width: 400,
|
||||
margin: 2,
|
||||
color: { dark, light },
|
||||
errorCorrectionLevel: 'M',
|
||||
}).then((url) => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [invoice]);
|
||||
|
||||
const handleCopy = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoice);
|
||||
setCopied(true);
|
||||
toast({ title: 'Copied', description: 'Lightning invoice copied to clipboard' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to copy', variant: 'destructive' });
|
||||
}
|
||||
}, [invoice, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const t = setTimeout(() => setCopied(false), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [copied]);
|
||||
|
||||
const handleOpenWallet = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await openUrl(`lightning:${invoice}`);
|
||||
}, [invoice]);
|
||||
|
||||
const handlePayWebLN = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const webln = (globalThis as { webln?: { enable?: () => Promise<void>; sendPayment?: (invoice: string) => Promise<unknown> } }).webln;
|
||||
if (!webln?.sendPayment) return;
|
||||
try {
|
||||
setPaying(true);
|
||||
if (webln.enable) await webln.enable();
|
||||
await webln.sendPayment(invoice);
|
||||
toast({ title: 'Payment sent' });
|
||||
} catch {
|
||||
toast({ title: 'Payment failed', variant: 'destructive' });
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}, [invoice, toast]);
|
||||
|
||||
const toggleQr = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setQrExpanded((v) => !v);
|
||||
}, []);
|
||||
|
||||
const hasWebLN = typeof globalThis !== 'undefined' && !!(globalThis as { webln?: unknown }).webln;
|
||||
|
||||
const qrImage = qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Lightning Invoice QR"
|
||||
className="rounded-xl"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-square rounded-xl bg-muted animate-pulse" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'isolate my-2.5 relative rounded-2xl border border-border overflow-hidden @container',
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Subtle accent glow behind QR area */}
|
||||
<div className="absolute -z-10 top-0 left-0 w-44 h-44 bg-primary/[0.06] rounded-full blur-2xl" />
|
||||
|
||||
{/* Expanded QR -- square container that replaces the normal layout */}
|
||||
{qrExpanded ? (
|
||||
<button
|
||||
onClick={toggleQr}
|
||||
className="w-full aspect-square cursor-pointer p-5"
|
||||
>
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Lightning Invoice QR"
|
||||
className="w-full h-full rounded-xl"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full rounded-xl bg-muted animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
{/* QR code -- tappable thumbnail */}
|
||||
<button onClick={toggleQr} className="shrink-0 p-3 cursor-pointer">
|
||||
<div className="size-28 sm:size-40">{qrImage}</div>
|
||||
</button>
|
||||
|
||||
{/* Info column */}
|
||||
<div className="flex flex-col justify-between py-3.5 pr-3.5 min-w-0 flex-1 gap-2">
|
||||
{/* Label + amount */}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground font-medium whitespace-nowrap" style={{ fontSize: 'clamp(0.8rem, 3.5cqw, 1.05rem)' }}>
|
||||
<span className="flex items-center justify-center size-5 sm:size-6 rounded-full bg-primary/15 shrink-0">
|
||||
<Zap className="size-3 sm:size-3.5 text-primary fill-primary" />
|
||||
</span>
|
||||
Lightning Invoice
|
||||
</div>
|
||||
{amount !== null && (
|
||||
<div className="font-bold tracking-tight leading-none mt-1 whitespace-nowrap" style={{ fontSize: 'clamp(1.5rem, 8cqw, 2.5rem)' }}>
|
||||
{formatSats(amount)}
|
||||
<span className="font-normal text-muted-foreground ml-1" style={{ fontSize: 'clamp(0.75rem, 3.5cqw, 1.125rem)' }}>sats</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice string with copy */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 group max-w-full"
|
||||
>
|
||||
<span className="truncate text-xs font-mono text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{invoice}
|
||||
</span>
|
||||
{copied
|
||||
? <Check className="size-3.5 text-primary shrink-0" />
|
||||
: <Copy className="size-3.5 text-muted-foreground group-hover:text-foreground shrink-0 transition-colors" />}
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{hasWebLN && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePayWebLN}
|
||||
disabled={paying}
|
||||
className="gap-1.5 h-9 rounded-xl"
|
||||
>
|
||||
<Zap className="size-3.5" />
|
||||
{paying ? 'Paying...' : 'Pay'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={handleOpenWallet} className="gap-1.5 h-9 rounded-xl">
|
||||
<ExternalLink className="size-3.5" />
|
||||
Open in Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { LeftSidebar } from '@/components/LeftSidebar';
|
||||
import { RightSidebar } from '@/components/RightSidebar';
|
||||
import { MobileTopBar } from '@/components/MobileTopBar';
|
||||
import { MobileDrawer } from '@/components/MobileDrawer';
|
||||
import { MobileBottomNav } from '@/components/MobileBottomNav';
|
||||
@@ -42,61 +41,8 @@ function PageSkeleton() {
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
{/* Right sidebar skeleton — mirrors RightSidebar's container + widget card styling */}
|
||||
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3">
|
||||
{/* Trends widget skeleton */}
|
||||
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-4 w-14" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-[28px] w-[50px] rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* Hot Posts widget skeleton */}
|
||||
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* New Accounts widget skeleton */}
|
||||
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<Skeleton className="h-6 w-28 mb-3" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
{/* Right sidebar placeholder — preserves layout width */}
|
||||
<div className="w-[300px] shrink-0 hidden xl:block" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -158,7 +104,8 @@ function MainLayoutInner() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rightSidebar !== null && (rightSidebar ?? <RightSidebar />)}
|
||||
{/* Right sidebar — render page-provided sidebar, or an empty placeholder to preserve layout width */}
|
||||
{rightSidebar ?? <div className="w-[300px] shrink-0 hidden xl:block" />}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -171,7 +118,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} />
|
||||
|
||||
@@ -40,8 +40,8 @@ export function MobileBottomNav() {
|
||||
setSearchOpen((v) => !v);
|
||||
}, []);
|
||||
|
||||
// Keep the nav visible while search is open regardless of scroll
|
||||
const isHidden = hidden && !searchOpen;
|
||||
// Hide the nav when search sheet is open so it doesn't compete for space
|
||||
const isHidden = hidden || searchOpen;
|
||||
|
||||
const displayName = metadata?.name || metadata?.display_name;
|
||||
const isOnProfile = user && location.pathname === profileUrl;
|
||||
|
||||
@@ -140,7 +140,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
<button
|
||||
onClick={() => setAccountExpanded((v) => !v)}
|
||||
className="flex items-center gap-3 px-3 hover:bg-secondary/60 transition-colors w-full text-left"
|
||||
style={{ minHeight: `calc(3rem + env(safe-area-inset-top, 0px))`, paddingTop: `env(safe-area-inset-top, 0px)` }}
|
||||
style={{ minHeight: `calc(3rem + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))`, paddingTop: `var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` }}
|
||||
>
|
||||
<Avatar shape={currentUserAvatarShape} className="size-7 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
@@ -336,7 +336,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
{/* Login prompt */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 border-b border-border"
|
||||
style={{ minHeight: `calc(3rem + env(safe-area-inset-top, 0px))`, paddingTop: `env(safe-area-inset-top, 0px)` }}
|
||||
style={{ minHeight: `calc(3rem + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))`, paddingTop: `var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` }}
|
||||
>
|
||||
<LoginArea className="w-full flex" />
|
||||
</div>
|
||||
|
||||
@@ -101,6 +101,28 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
const wikipediaIndex = hasWikipedia ? nextMobileIdx++ : -1;
|
||||
const archiveIndex = hasArchive ? nextMobileIdx++ : -1;
|
||||
|
||||
// Lock body scroll while the search sheet is open.
|
||||
// overflow:hidden alone is unreliable on mobile Safari, so we also
|
||||
// block touchmove on the document (except inside the results scroller).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const preventScroll = (e: TouchEvent) => {
|
||||
// Allow scrolling inside the results list
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.('[data-mobile-search-results]')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener('touchmove', preventScroll, { passive: false });
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
document.removeEventListener('touchmove', preventScroll);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -224,8 +246,8 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Bottom sheet — sits above the bottom nav bar */}
|
||||
<div className="fixed left-0 right-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 bottom-mobile-nav">
|
||||
{/* Bottom sheet — sits at the bottom of the screen with safe area clearance */}
|
||||
<div className="fixed left-0 right-0 bottom-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 pb-6">
|
||||
|
||||
{/* Results list — reversed so closest to input = most relevant */}
|
||||
{hasResults && (
|
||||
@@ -293,7 +315,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
)}
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="flex items-center px-6 py-3">
|
||||
<div className="flex items-center px-6 py-3 safe-area-bottom">
|
||||
<div className="flex items-center gap-2 flex-1 bg-secondary rounded-full px-4 py-2.5">
|
||||
{isFetching ? (
|
||||
<svg
|
||||
@@ -321,14 +343,12 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<X strokeWidth={4} className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<X strokeWidth={4} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,12 +25,12 @@ export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps)
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-20 sidebar:hidden safe-area-top transition-transform duration-300 ease-in-out"
|
||||
style={navHidden ? { transform: 'translateY(calc(-100% - 20px - env(safe-area-inset-top, 0px)))' } : undefined}
|
||||
style={navHidden ? { transform: 'translateY(calc(-100% - 20px - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))))' } : undefined}
|
||||
>
|
||||
{/* Safe-area fill — only covers the padding zone above the content with a single layer of bg. */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 bg-background/85"
|
||||
style={{ height: 'env(safe-area-inset-top, 0px)' }}
|
||||
style={{ height: 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))' }}
|
||||
/>
|
||||
{/* Relative wrapper so ArcBackground only covers the content area, not the safe-area padding above it. */}
|
||||
<div className="relative">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { NoteContent } from './NoteContent';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
describe('NoteContent', () => {
|
||||
it('linkifies URLs in kind 1 events', () => {
|
||||
it('linkifies URLs in kind 1 events', async () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -22,13 +22,13 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'https://example.com' });
|
||||
const link = await screen.findByRole('link', { name: 'https://example.com' });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('linkifies URLs in kind 1111 events (comments)', () => {
|
||||
it('linkifies URLs in kind 1111 events (comments)', async () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-comment-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -49,13 +49,13 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
|
||||
const link = await screen.findByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', 'https://nostrbook.dev/kinds/1111');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('handles text without URLs correctly', () => {
|
||||
it('handles text without URLs correctly', async () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -72,11 +72,11 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
expect(screen.getByText('This is just plain text without any links.')).toBeInTheDocument();
|
||||
expect(await screen.findByText('This is just plain text without any links.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hashtags as links', () => {
|
||||
it('renders hashtags as links', async () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -93,7 +93,7 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
const nostrHashtag = screen.getByRole('link', { name: '#nostr' });
|
||||
const nostrHashtag = await screen.findByRole('link', { name: '#nostr' });
|
||||
const bitcoinHashtag = screen.getByRole('link', { name: '#bitcoin' });
|
||||
|
||||
expect(nostrHashtag).toBeInTheDocument();
|
||||
@@ -102,7 +102,7 @@ describe('NoteContent', () => {
|
||||
expect(bitcoinHashtag).toHaveAttribute('href', '/t/bitcoin');
|
||||
});
|
||||
|
||||
it('generates deterministic names for users without metadata and styles them differently', () => {
|
||||
it('generates deterministic names for users without metadata and styles them differently', async () => {
|
||||
// Use a valid npub for testing
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
@@ -121,7 +121,7 @@ describe('NoteContent', () => {
|
||||
);
|
||||
|
||||
// The mention should be rendered with a deterministic name
|
||||
const mention = screen.getByRole('link');
|
||||
const mention = await screen.findByRole('link');
|
||||
expect(mention).toBeInTheDocument();
|
||||
|
||||
// Should have muted styling for generated names (muted-foreground instead of primary)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
|
||||
import { Lightbox, ImageGallery } from '@/components/ImageGallery';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { EmojifiedText, CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
@@ -176,7 +177,8 @@ type ContentToken =
|
||||
| { type: 'naddr-embed'; addr: AddrCoords; url?: string }
|
||||
| { type: 'nostr-link'; id: string; raw: string }
|
||||
| { type: 'hashtag'; tag: string; raw: string }
|
||||
| { type: 'relay-link'; url: string };
|
||||
| { type: 'relay-link'; url: string }
|
||||
| { type: 'lightning-invoice'; invoice: string };
|
||||
|
||||
/**
|
||||
* Regex segment matching a single visual emoji unit, including:
|
||||
@@ -234,9 +236,10 @@ export function NoteContent({
|
||||
}: NoteContentProps) {
|
||||
const tokens = useMemo(() => {
|
||||
const text = event.content;
|
||||
// Match: URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
|
||||
// Match: BOLT11 invoices | URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
|
||||
// BOLT11: optional "lightning:" prefix + lnbc/lntb/lnbcrt/lntbs + bech32 data (case-insensitive)
|
||||
// NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed)
|
||||
const regex = /((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/gu;
|
||||
const regex = /(?:lightning:)?(ln(?:bc|tb|bcrt|tbs)\d*[munp]?1[023456789acdefghjklmnpqrstuvwxyz]+)|((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/giu;
|
||||
|
||||
const result: ContentToken[] = [];
|
||||
let lastIndex = 0;
|
||||
@@ -244,9 +247,11 @@ export function NoteContent({
|
||||
let hadMatches = false;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
let [fullMatch, url] = match;
|
||||
const hashtag = match[6];
|
||||
const { 2: nostrPrefix, 3: nostrData, 4: barePrefix, 5: bareData } = match;
|
||||
let [fullMatch] = match;
|
||||
const bolt11 = match[1];
|
||||
let url = match[2];
|
||||
const hashtag = match[7];
|
||||
const { 3: nostrPrefix, 4: nostrData, 5: barePrefix, 6: bareData } = match;
|
||||
const index = match.index;
|
||||
hadMatches = true;
|
||||
|
||||
@@ -255,7 +260,9 @@ export function NoteContent({
|
||||
result.push({ type: 'text', value: text.substring(lastIndex, index) });
|
||||
}
|
||||
|
||||
if (url) {
|
||||
if (bolt11) {
|
||||
result.push({ type: 'lightning-invoice', invoice: bolt11.toLowerCase() });
|
||||
} else if (url) {
|
||||
// Strip common trailing punctuation that's likely not part of the URL
|
||||
// This handles cases like "(https://example.com)" or "Check this: https://example.com."
|
||||
const trailingPunctMatch = url.match(/^(.*?)([.,;:!?)\]]+)$/);
|
||||
@@ -409,7 +416,7 @@ export function NoteContent({
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const token = result[i];
|
||||
const isBlock = token.type === 'image-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|
||||
|| (token.type === 'naddr-embed' && !token.url);
|
||||
|| (token.type === 'naddr-embed' && !token.url) || token.type === 'lightning-invoice';
|
||||
|
||||
if (isBlock) {
|
||||
// Strip all trailing whitespace from the preceding text token.
|
||||
@@ -668,6 +675,11 @@ export function NoteContent({
|
||||
{token.url}
|
||||
</Link>
|
||||
);
|
||||
case 'lightning-invoice':
|
||||
if (disableEmbeds) {
|
||||
return <span key={i} className="text-primary break-all">{token.invoice}</span>;
|
||||
}
|
||||
return <LightningInvoiceCard key={i} invoice={token.invoice} />;
|
||||
}
|
||||
})}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NsitePreviewDialog } from "@/components/NsitePreviewDialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLinkPreview } from "@/hooks/useLinkPreview";
|
||||
import { getNsiteSubdomain } from "@/lib/nsiteSubdomain";
|
||||
import { sanitizeUrl } from "@/lib/sanitizeUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NsiteCardProps {
|
||||
@@ -24,7 +25,7 @@ export function NsiteCard({ event }: 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");
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Package, X } from 'lucide-react';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SandboxFrame } from '@/components/SandboxFrame';
|
||||
@@ -68,25 +69,108 @@ function resolveServers(event: NostrEvent, appServers: string[]): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a blob from the given sha256 by trying each Blossom server in order.
|
||||
* Returns a Response from the first server that responds successfully, or
|
||||
* throws if all servers fail.
|
||||
* Module-level preferred server. Once a Blossom server successfully serves
|
||||
* a blob, it is promoted here so subsequent requests try it first — avoiding
|
||||
* the round-trip penalty of 404s on servers that don't have the content.
|
||||
*/
|
||||
let preferredServer: string | null = null;
|
||||
|
||||
/**
|
||||
* Fetch a blob from the given sha256 by trying Blossom servers.
|
||||
*
|
||||
* If a server previously succeeded (the "preferred" server), it is tried
|
||||
* first. On success the preferred server is reinforced; on failure we fall
|
||||
* through to the remaining servers in order. Whichever server ultimately
|
||||
* succeeds is promoted to preferred for the next call.
|
||||
*/
|
||||
async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Response> {
|
||||
let lastError: unknown;
|
||||
for (const server of servers) {
|
||||
|
||||
/** Try a single server. Returns the Response on success, or null. */
|
||||
async function tryServer(server: string): Promise<Response | null> {
|
||||
const base = server.replace(/\/+$/, '');
|
||||
const url = `${base}/${sha256}`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return res;
|
||||
if (res.ok) {
|
||||
preferredServer = server;
|
||||
return res;
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try the preferred server first if it's in the list.
|
||||
if (preferredServer && servers.includes(preferredServer)) {
|
||||
const res = await tryServer(preferredServer);
|
||||
if (res) return res;
|
||||
}
|
||||
|
||||
// Fall through to the full list, skipping the preferred (already tried).
|
||||
for (const server of servers) {
|
||||
if (server === preferredServer) continue;
|
||||
const res = await tryServer(server);
|
||||
if (res) return res;
|
||||
}
|
||||
|
||||
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
|
||||
}
|
||||
|
||||
/** Max concurrent Blossom fetches during pre-fetch. */
|
||||
const PREFETCH_CONCURRENCY = 12;
|
||||
|
||||
/**
|
||||
* Pre-fetch all unique blobs from the manifest into an in-memory cache.
|
||||
*
|
||||
* **Android only.** Android's WebView uses `shouldInterceptRequest` which
|
||||
* blocks a pool of ~6 IO threads via `CountDownLatch` until JS responds.
|
||||
* If each response requires a network round-trip to Blossom, the 6-at-a-time
|
||||
* serialisation makes loading 200+ files extremely slow. By downloading
|
||||
* every blob *before* the WebView starts loading, each bridge round-trip
|
||||
* drops from seconds (network) to ~1-5ms (memory).
|
||||
*
|
||||
* iOS does NOT need this — `WKURLSchemeHandler` is fully async and can
|
||||
* handle many concurrent requests without any thread pool bottleneck.
|
||||
*
|
||||
* Uses bounded concurrency to saturate the network without overwhelming it.
|
||||
*/
|
||||
async function prefetchAllBlobs(
|
||||
manifest: Map<string, string>,
|
||||
servers: string[],
|
||||
cache: Map<string, Uint8Array>,
|
||||
): Promise<void> {
|
||||
// Deduplicate — many paths may share the same hash (e.g. SPA fallbacks).
|
||||
const uniqueHashes = [...new Set(manifest.values())];
|
||||
// Skip hashes already in the cache (e.g. from a previous open).
|
||||
const toFetch = uniqueHashes.filter((h) => !cache.has(h));
|
||||
if (toFetch.length === 0) return;
|
||||
|
||||
let cursor = 0;
|
||||
const total = toFetch.length;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (cursor < total) {
|
||||
const idx = cursor++;
|
||||
const sha256 = toFetch[idx];
|
||||
try {
|
||||
const res = await fetchFromBlossom(sha256, servers);
|
||||
const buffer = await res.arrayBuffer();
|
||||
cache.set(sha256, new Uint8Array(buffer));
|
||||
} catch {
|
||||
// Non-fatal — resolveFile will fetch on demand for cache misses.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(PREFETCH_CONCURRENCY, total) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
interface NsitePreviewDialogProps {
|
||||
/** The nsite event (kind 15128 or 35128) containing path and server tags. */
|
||||
event: NostrEvent;
|
||||
@@ -124,6 +208,13 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
const manifest = useRef<Map<string, string>>(new Map());
|
||||
const servers = useRef<string[]>([]);
|
||||
|
||||
/**
|
||||
* In-memory blob cache: sha256 → raw bytes.
|
||||
* On Android, populated by a blocking pre-fetch in `onReady` so every
|
||||
* `resolveFile` call is an instant cache hit with no network wait.
|
||||
*/
|
||||
const blobCache = useRef<Map<string, Uint8Array>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
manifest.current = buildManifest(event);
|
||||
const appServers = getEffectiveBlossomServers(
|
||||
@@ -139,6 +230,26 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
content: getPreviewInjectedScript(),
|
||||
}], []);
|
||||
|
||||
/**
|
||||
* Called by SandboxFrame before the native WebView is created.
|
||||
*
|
||||
* On Android: blocks until all blobs are pre-fetched. Android's WebView
|
||||
* uses `shouldInterceptRequest` which blocks ~6 IO threads — if each
|
||||
* response requires a network fetch the whole thing is painfully slow.
|
||||
* The native ProgressBar spinner (render thread) stays visible and
|
||||
* animating during the download. Once the WebView starts, every
|
||||
* resolveFile call is an instant cache hit.
|
||||
*
|
||||
* On iOS: no-op. WKURLSchemeHandler is async and handles concurrent
|
||||
* requests without a thread pool bottleneck.
|
||||
*
|
||||
* On web: no-op. iframe.diy's service worker handles fetches efficiently.
|
||||
*/
|
||||
const onReady = useCallback(async () => {
|
||||
if (Capacitor.getPlatform() !== 'android') return;
|
||||
await prefetchAllBlobs(manifest.current, servers.current, blobCache.current);
|
||||
}, []);
|
||||
|
||||
/** Resolve a pathname to file content from the Blossom manifest. */
|
||||
const resolveFile = useCallback(async (pathname: string): Promise<FileResponse | null> => {
|
||||
// Look up the sha256 for this path in the manifest.
|
||||
@@ -153,11 +264,21 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
|
||||
if (!sha256) return null;
|
||||
|
||||
// Fetch the blob from Blossom, trying each server in order.
|
||||
// Serve from cache if available (pre-fetched on Android).
|
||||
const cached = blobCache.current.get(sha256);
|
||||
if (cached) {
|
||||
const contentType = getMimeType(servingPath);
|
||||
return { status: 200, contentType, body: cached };
|
||||
}
|
||||
|
||||
// Cache miss — fetch from Blossom (normal path on iOS/web).
|
||||
const res = await fetchFromBlossom(sha256, servers.current);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const body = new Uint8Array(buffer);
|
||||
|
||||
// Store in cache for future requests (e.g. SPA navigations).
|
||||
blobCache.current.set(sha256, body);
|
||||
|
||||
// Always determine content type from the file extension.
|
||||
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
|
||||
// files), which causes browsers to reject module scripts. The file path from
|
||||
@@ -221,6 +342,7 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
key={`${previewSubdomain}-${open}`}
|
||||
id={previewSubdomain}
|
||||
resolveFile={resolveFile}
|
||||
onReady={onReady}
|
||||
injectedScripts={injectedScripts}
|
||||
className="w-full h-full border-0"
|
||||
title={`${appName} preview`}
|
||||
|
||||
@@ -206,9 +206,11 @@ export function ProfileCard({
|
||||
<Pencil className="size-3.5" /> {metadata.banner ? 'Change banner' : 'Add banner'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
{metadata.banner && (
|
||||
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -240,9 +242,11 @@ export function ProfileCard({
|
||||
>
|
||||
<Pencil className="size-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow" />
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
{metadata.picture && (
|
||||
<div className="absolute bottom-0 right-0 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={6}>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
import { parseDimToAspectRatio } from '@/lib/mediaUtils';
|
||||
import { isWeatherFieldLabel } from '@/lib/weatherStation';
|
||||
import { WeatherStationCard } from '@/components/WeatherStationCard';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Media-native kinds shown in the sidebar (excludes kind 1 text notes and kind 1111 comments). */
|
||||
const SIDEBAR_MEDIA_KINDS = [20, 21, 22, 34236, 36787, 34139, 30054, 30055];
|
||||
@@ -400,24 +401,24 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
|
||||
}
|
||||
|
||||
// Media fields: render inline players/previews based on file extension
|
||||
const isUrl = field.value.startsWith('http://') || field.value.startsWith('https://');
|
||||
const safeUrl = sanitizeUrl(field.value);
|
||||
|
||||
if (isUrl && isAudioUrl(field.value)) {
|
||||
if (safeUrl && isAudioUrl(safeUrl)) {
|
||||
return (
|
||||
<div>
|
||||
<div className="font-semibold text-sm mb-1.5">{field.label}</div>
|
||||
<MiniAudioPlayer src={field.value} />
|
||||
<MiniAudioPlayer src={safeUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUrl && isImageUrl(field.value)) {
|
||||
if (safeUrl && isImageUrl(safeUrl)) {
|
||||
return (
|
||||
<div>
|
||||
{field.label && <div className="font-semibold text-sm mb-1.5">{field.label}</div>}
|
||||
<a href={field.value} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<a href={safeUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={field.value}
|
||||
src={safeUrl}
|
||||
alt={field.label || 'Profile image'}
|
||||
className="w-full rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
@@ -427,12 +428,12 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isUrl && isVideoUrl(field.value)) {
|
||||
if (safeUrl && isVideoUrl(safeUrl)) {
|
||||
return (
|
||||
<div>
|
||||
{field.label && <div className="font-semibold text-sm mb-1.5">{field.label}</div>}
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<VideoPlayer src={field.value} />
|
||||
<VideoPlayer src={safeUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -442,15 +443,15 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="font-semibold text-sm">{field.label}</div>
|
||||
{isUrl ? (
|
||||
{safeUrl ? (
|
||||
<a
|
||||
href={field.value}
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm text-primary hover:underline truncate mt-0.5"
|
||||
>
|
||||
<ExternalFavicon url={field.value} size={16} className="shrink-0" />
|
||||
<span className="truncate">{field.value.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalFavicon url={safeUrl} size={16} className="shrink-0" />
|
||||
<span className="truncate">{safeUrl.replace(/^https?:\/\//, '')}</span>
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground truncate">{field.value}</p>
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Globe, Radio, Loader2, X, ArrowRight, ArrowLeft, Flame } from 'lucide-react';
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useRequestToVanish } from '@/hooks/useRequestToVanish';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
@@ -22,30 +17,38 @@ interface RequestToVanishDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type VanishMode = 'global' | 'targeted';
|
||||
type Step = 0 | 1 | 2;
|
||||
const DELETION_ITEMS = [
|
||||
{ id: 'profile', label: 'Your profile and metadata' },
|
||||
{ id: 'posts', label: 'All posts, replies, and reactions' },
|
||||
{ id: 'messages', label: 'Direct messages' },
|
||||
{ id: 'settings', label: 'Follow lists and settings' },
|
||||
{ id: 'other', label: 'All other events submitted to the network' },
|
||||
] as const;
|
||||
|
||||
const STEPS = ['Scope', 'Details', 'Confirm'] as const;
|
||||
const CONFIRMATION_PHRASE = 'VANISH';
|
||||
type ItemId = (typeof DELETION_ITEMS)[number]['id'];
|
||||
|
||||
export function RequestToVanishDialog({ open, onOpenChange }: RequestToVanishDialogProps) {
|
||||
const { config } = useAppContext();
|
||||
const { mutateAsync: requestVanish, isPending } = useRequestToVanish();
|
||||
const { logout } = useLoginActions();
|
||||
|
||||
const [step, setStep] = useState<Step>(0);
|
||||
const [mode, setMode] = useState<VanishMode>('global');
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [checked, setChecked] = useState<Set<ItemId>>(new Set());
|
||||
|
||||
const userRelays = config.relayMetadata.relays.map((r) => r.url);
|
||||
const isConfirmed = confirmText === CONFIRMATION_PHRASE;
|
||||
const allChecked = DELETION_ITEMS.every((item) => checked.has(item.id));
|
||||
|
||||
const toggle = (id: ItemId) => {
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setStep(0);
|
||||
setMode('global');
|
||||
setReason('');
|
||||
setConfirmText('');
|
||||
setChecked(new Set());
|
||||
}, []);
|
||||
|
||||
// Reset when dialog closes.
|
||||
@@ -54,411 +57,90 @@ export function RequestToVanishDialog({ open, onOpenChange }: RequestToVanishDia
|
||||
}, [open, resetState]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isConfirmed) return;
|
||||
if (!allChecked) return;
|
||||
|
||||
try {
|
||||
const relayUrls = mode === 'global' ? ['ALL_RELAYS'] : userRelays;
|
||||
|
||||
await requestVanish({ relayUrls, content: reason.trim() });
|
||||
await requestVanish({ relayUrls: ['ALL_RELAYS'], content: '' });
|
||||
|
||||
toast({
|
||||
title: 'Request to vanish sent',
|
||||
description: mode === 'global'
|
||||
? 'Your request has been broadcast. Compliant relays will delete your data.'
|
||||
: `Your request was sent to ${userRelays.length} relay(s).`,
|
||||
title: 'Account deleted',
|
||||
description: 'Your deletion request has been broadcast. You have been logged out.',
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
await logout();
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Failed to send request',
|
||||
description: 'Some relays may not have received the request. You can try again.',
|
||||
title: 'Failed to delete account',
|
||||
description: 'Something went wrong. You can try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[440px] rounded-2xl p-0 gap-0 border-border overflow-hidden max-h-[90dvh] [&>button]:hidden">
|
||||
{/* ── Header ── */}
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Gradient backdrop */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-destructive/10 via-destructive/5 to-transparent" />
|
||||
|
||||
<div className="relative px-5 pt-5 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-destructive/15 ring-1 ring-destructive/20 shrink-0">
|
||||
<Flame className="size-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-base font-bold">Request to Vanish</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
||||
Permanently erase your data from relays
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 -mr-1 -mt-0.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center gap-1.5 mt-4">
|
||||
{STEPS.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-1.5 flex-1">
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full h-1 rounded-full overflow-hidden bg-muted/60">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500 ease-out',
|
||||
i <= step ? 'bg-destructive w-full' : 'w-0',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[10px] font-medium transition-colors',
|
||||
i <= step ? 'text-destructive' : 'text-muted-foreground/50',
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="max-w-[400px] rounded-2xl p-6 gap-0 border-destructive/40">
|
||||
{/* Title */}
|
||||
<div className="mb-4">
|
||||
<AlertDialogTitle className="text-base font-bold flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0" />
|
||||
Delete Account
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-sm text-muted-foreground mt-1">
|
||||
This will <span className="font-semibold text-destructive">permanently delete your data</span>. Check each box to confirm you understand what will be removed:
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Step Content ── */}
|
||||
<div className="overflow-y-auto min-h-0 flex-1">
|
||||
{step === 0 && <StepScope mode={mode} setMode={setMode} userRelays={userRelays} />}
|
||||
{step === 1 && <StepDetails reason={reason} setReason={setReason} mode={mode} userRelays={userRelays} />}
|
||||
{step === 2 && (
|
||||
<StepConfirm
|
||||
confirmText={confirmText}
|
||||
setConfirmText={setConfirmText}
|
||||
mode={mode}
|
||||
relayCount={userRelays.length}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5">
|
||||
{step > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setStep((s) => (s - 1) as Step)}
|
||||
disabled={isPending}
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
{/* Checkbox list */}
|
||||
<div className="space-y-3 mb-5">
|
||||
{DELETION_ITEMS.map((item) => (
|
||||
<label
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 cursor-pointer select-none"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step < 2 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setStep((s) => (s + 1) as Step)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!isConfirmed || isPending}
|
||||
className="gap-1.5 bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-40"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Flame className="size-3.5" />
|
||||
Vanish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Step 0: Scope ───────────────────────── */
|
||||
|
||||
function StepScope({
|
||||
mode,
|
||||
setMode,
|
||||
userRelays,
|
||||
}: {
|
||||
mode: VanishMode;
|
||||
setMode: (m: VanishMode) => void;
|
||||
userRelays: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Choose scope</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Select which relays should delete your data. This determines the reach of your vanish request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ScopeCard
|
||||
selected={mode === 'global'}
|
||||
onClick={() => setMode('global')}
|
||||
icon={<Globe className="size-5" />}
|
||||
title="All relays"
|
||||
description="Request every relay on the network to delete your data. The event is broadcast as widely as possible."
|
||||
badge="Recommended"
|
||||
/>
|
||||
<ScopeCard
|
||||
selected={mode === 'targeted'}
|
||||
onClick={() => setMode('targeted')}
|
||||
icon={<Radio className="size-5" />}
|
||||
title={`My relays only (${userRelays.length})`}
|
||||
description="Request only your currently configured relays to delete your data."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Relay list preview for targeted mode */}
|
||||
{mode === 'targeted' && userRelays.length > 0 && (
|
||||
<div className="rounded-lg bg-muted/40 border border-border/50 px-3 py-2.5 space-y-1.5 animate-in fade-in-0 slide-in-from-top-1 duration-200">
|
||||
<p className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Target relays</p>
|
||||
<ul className="space-y-0.5">
|
||||
{userRelays.map((url) => (
|
||||
<li key={url} className="text-xs font-mono text-muted-foreground truncate">{url}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeCard({
|
||||
selected,
|
||||
onClick,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full text-left rounded-xl border-2 p-3.5 transition-all duration-200',
|
||||
'hover:bg-secondary/30',
|
||||
selected
|
||||
? 'border-destructive/60 bg-destructive/[0.03] shadow-sm shadow-destructive/5'
|
||||
: 'border-border/60 bg-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'flex size-9 items-center justify-center rounded-lg shrink-0 transition-colors',
|
||||
selected ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground',
|
||||
)}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
{badge && (
|
||||
<span className="text-[10px] font-medium bg-destructive/10 text-destructive rounded-full px-2 py-0.5">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
{/* Selection indicator */}
|
||||
<div className={cn(
|
||||
'size-4 rounded-full border-2 shrink-0 mt-0.5 transition-all duration-200 flex items-center justify-center',
|
||||
selected ? 'border-destructive bg-destructive' : 'border-muted-foreground/30',
|
||||
)}>
|
||||
{selected && <div className="size-1.5 rounded-full bg-white" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Step 1: Details ───────────────────────── */
|
||||
|
||||
function StepDetails({
|
||||
reason,
|
||||
setReason,
|
||||
mode,
|
||||
userRelays,
|
||||
}: {
|
||||
reason: string;
|
||||
setReason: (r: string) => void;
|
||||
mode: VanishMode;
|
||||
userRelays: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-5">
|
||||
{/* Summary of what will happen */}
|
||||
<div className="rounded-xl bg-destructive/[0.04] border border-destructive/15 p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-destructive flex items-center gap-2">
|
||||
<Flame className="size-4" />
|
||||
What will be deleted
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
'Your profile (kind 0) and metadata',
|
||||
'All posts, replies, and reactions',
|
||||
'Direct messages and gift wraps',
|
||||
'Contact lists, relay lists, and settings',
|
||||
'All other events published by your key',
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="text-destructive/60 mt-0.5 shrink-0">—</span>
|
||||
{item}
|
||||
</li>
|
||||
<Checkbox
|
||||
checked={checked.has(item.id)}
|
||||
onCheckedChange={() => toggle(item.id)}
|
||||
className="border-destructive/60 data-[state=checked]:bg-destructive data-[state=checked]:border-destructive"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-[11px] text-destructive/70 pt-1 border-t border-destructive/10">
|
||||
{mode === 'global'
|
||||
? 'This request will be sent to all relays on the network.'
|
||||
: `This request will be sent to ${userRelays.length} relay(s).`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vanish-reason" className="text-sm font-medium">
|
||||
Reason or legal notice
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Optionally include a message for the relay operator. This is included in the event's content field.
|
||||
</p>
|
||||
<Textarea
|
||||
id="vanish-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. GDPR Article 17 — Right to erasure"
|
||||
className="resize-none text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Step 2: Confirm ───────────────────────── */
|
||||
|
||||
function StepConfirm({
|
||||
confirmText,
|
||||
setConfirmText,
|
||||
mode,
|
||||
relayCount,
|
||||
}: {
|
||||
confirmText: string;
|
||||
setConfirmText: (t: string) => void;
|
||||
mode: VanishMode;
|
||||
relayCount: number;
|
||||
}) {
|
||||
const isMatch = confirmText === CONFIRMATION_PHRASE;
|
||||
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-5">
|
||||
{/* Final warning */}
|
||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-4 text-center space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<div className="size-12 rounded-full bg-destructive/15 flex items-center justify-center">
|
||||
<Flame className="size-6 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-destructive">This action is irreversible</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed max-w-[280px] mx-auto">
|
||||
Once sent, compliant relays will permanently delete your events.
|
||||
Deletion requests (kind 5) against this event have no effect.
|
||||
You will be logged out immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scope summary */}
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/40 px-3.5 py-2.5">
|
||||
{mode === 'global' ? (
|
||||
<Globe className="size-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Radio className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{mode === 'global'
|
||||
? 'Targeting all relays on the network'
|
||||
: `Targeting ${relayCount} configured relay(s)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Confirmation input */}
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="vanish-confirm" className="text-sm font-medium">
|
||||
Type{' '}
|
||||
<span className="font-mono bg-destructive/10 text-destructive px-1.5 py-0.5 rounded text-xs">
|
||||
{CONFIRMATION_PHRASE}
|
||||
</span>{' '}
|
||||
to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="vanish-confirm"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value.toUpperCase())}
|
||||
placeholder={CONFIRMATION_PHRASE}
|
||||
className={cn(
|
||||
'font-mono text-center text-lg tracking-widest transition-colors',
|
||||
isMatch && 'border-destructive/50 ring-1 ring-destructive/20',
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className={cn(
|
||||
'text-center text-xs transition-opacity duration-300',
|
||||
isMatch ? 'text-destructive opacity-100' : 'text-muted-foreground/40 opacity-0',
|
||||
)}>
|
||||
Confirmation accepted
|
||||
{/* Warning */}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-5">
|
||||
This action is <span className="font-semibold text-destructive">irreversible</span>.
|
||||
Your account cannot be recovered after deletion. You will be logged out immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-1.5 bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-40"
|
||||
onClick={handleSubmit}
|
||||
disabled={!allChecked || isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Delete Account'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,18 +395,6 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
let cancelled = false;
|
||||
|
||||
async function setup() {
|
||||
// Run onReady first so the consumer can prepare (e.g. download and
|
||||
// unzip a .xdc archive) before the native WebView starts loading
|
||||
// resources. This mirrors the web behaviour where onReady runs
|
||||
// before `init` is sent.
|
||||
try {
|
||||
await onReadyRef.current?.();
|
||||
} catch (err) {
|
||||
console.error('[SandboxFrame] onReady failed:', err);
|
||||
}
|
||||
|
||||
if (cancelled || destroyedRef.current) return;
|
||||
|
||||
// Measure the placeholder position.
|
||||
const el = placeholderRef.current;
|
||||
if (!el) return;
|
||||
@@ -439,8 +427,8 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
|
||||
if (cancelled || destroyedRef.current) return;
|
||||
|
||||
// Create the native WebView. Fetch events from the initial load
|
||||
// will be handled by the listeners registered above.
|
||||
// Create the native WebView with a loading spinner — does NOT
|
||||
// navigate yet, so no fetch events fire at this point.
|
||||
await SandboxPlugin.create({
|
||||
id,
|
||||
frame: {
|
||||
@@ -452,12 +440,27 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
});
|
||||
|
||||
if (cancelled || destroyedRef.current) {
|
||||
// Component unmounted while we were awaiting — clean up immediately.
|
||||
SandboxPlugin.destroy({ id }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
createdRef.current = true;
|
||||
|
||||
// Run onReady while the spinner is visible and animating.
|
||||
// On Android this pre-fetches all blobs so every resolveFile call
|
||||
// after navigation is an instant cache hit.
|
||||
// On iOS/web this is typically a no-op or instant.
|
||||
try {
|
||||
await onReadyRef.current?.();
|
||||
} catch (err) {
|
||||
console.error('[SandboxFrame] onReady failed:', err);
|
||||
}
|
||||
|
||||
if (cancelled || destroyedRef.current) return;
|
||||
|
||||
// Start loading the sandbox content — fetch events will now fire
|
||||
// and be handled by the listeners registered above.
|
||||
await SandboxPlugin.navigate({ id });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronLeft, ChevronRight, ExternalLink, GitFork, Globe, Package, Shiel
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
@@ -243,8 +244,8 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps)
|
||||
const platforms = getAllTags(event.tags, 'f');
|
||||
const uniquePlatforms = useMemo(() => getUniquePlatforms(platforms), [platforms]);
|
||||
const hashtags = getAllTags(event.tags, 't');
|
||||
const websiteUrl = getTag(event.tags, 'url');
|
||||
const repoUrl = getTag(event.tags, 'repository');
|
||||
const websiteUrl = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
const repoUrl = sanitizeUrl(getTag(event.tags, 'repository'));
|
||||
const license = getTag(event.tags, 'license');
|
||||
const appId = getTag(event.tags, 'd');
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ZAPSTORE_RELAY } from '@/lib/appRelays';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Sanitize schema allowing only the subset needed for a CHANGELOG. */
|
||||
const CHANGELOG_SANITIZE_SCHEMA = {
|
||||
@@ -203,7 +204,7 @@ function useReleaseApp(appIdentifier: string | undefined, releasePubkey: string)
|
||||
/** Single asset download row. */
|
||||
function AssetRow({ event }: { event: NostrEvent }) {
|
||||
const mime = getTag(event.tags, 'm') ?? '';
|
||||
const url = getTag(event.tags, 'url');
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
const version = getTag(event.tags, 'version');
|
||||
const size = formatSize(getTag(event.tags, 'size'));
|
||||
const platforms = getAllTags(event.tags, 'f');
|
||||
@@ -561,7 +562,7 @@ interface ZapstoreAssetContentProps {
|
||||
/** Renders a kind 3063 Zapstore software asset event. */
|
||||
export function ZapstoreAssetContent({ event, compact }: ZapstoreAssetContentProps) {
|
||||
const mime = getTag(event.tags, 'm') ?? '';
|
||||
const url = getTag(event.tags, 'url');
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
const version = getTag(event.tags, 'version');
|
||||
const size = formatSize(getTag(event.tags, 'size'));
|
||||
const appIdentifier = getTag(event.tags, 'i');
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
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';
|
||||
@@ -299,6 +300,24 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
|
||||
const [isMoreOptionsOpen, setIsMoreOptionsOpen] = useState(false);
|
||||
|
||||
// Progressive enhancement: attempt to retrieve a stored credential from the
|
||||
// platform's password manager when the dialog opens.
|
||||
// On Capacitor iOS this shows the iCloud Keychain credential picker.
|
||||
// On Chromium browsers this shows the native credential chooser.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let cancelled = false;
|
||||
|
||||
getNsecCredential().then((cred) => {
|
||||
if (cancelled || !cred) return;
|
||||
if (validateNsec(cred.nsec)) {
|
||||
executeLogin(cred.nsec);
|
||||
}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const renderTabs = () => (
|
||||
<Tabs
|
||||
defaultValue="key"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// NOTE: This file is stable and usually should not be modified.
|
||||
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
|
||||
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Download, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -12,7 +11,7 @@ import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { downloadTextFile } from '@/lib/downloadFile';
|
||||
import { saveNsec } from '@/lib/credentialManager';
|
||||
import { ProfileCard } from '@/components/ProfileCard';
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||
@@ -39,14 +38,19 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
|
||||
// Generate a proper nsec key using nostr-tools
|
||||
// Generate a proper nsec key using nostr-tools.
|
||||
// The credential manager / file download is deferred until the user clicks "Continue".
|
||||
const generateKey = () => {
|
||||
const sk = generateSecretKey();
|
||||
setNsec(nip19.nsecEncode(sk));
|
||||
const encoded = nip19.nsecEncode(sk);
|
||||
setNsec(encoded);
|
||||
setStep('download');
|
||||
};
|
||||
|
||||
const downloadKey = async () => {
|
||||
// Continue handler for the save-key step — saves the key via the best
|
||||
// available method (native credential manager on iOS/Android, file download
|
||||
// on web), logs in, and advances to the profile step.
|
||||
const handleContinue = async () => {
|
||||
try {
|
||||
const decoded = nip19.decode(nsec);
|
||||
if (decoded.type !== 'nsec') {
|
||||
@@ -55,21 +59,15 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
|
||||
const pubkey = getPublicKey(decoded.data);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
|
||||
|
||||
await downloadTextFile(filename, nsec);
|
||||
await saveNsec(npub, nsec);
|
||||
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
toast({ title: 'Key saved', description: `Saved to Download/${filename}` });
|
||||
}
|
||||
|
||||
// Continue to profile step
|
||||
login.nsec(nsec);
|
||||
setStep('profile');
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: 'Could not download the key file. Please copy it manually.',
|
||||
title: 'Save failed',
|
||||
description: 'Could not save the key. Please copy it manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -166,7 +164,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Step */}
|
||||
{/* Save Key Step */}
|
||||
{step === 'download' && (
|
||||
<div className='space-y-4'>
|
||||
<div className="flex size-16 text-4xl bg-primary/10 rounded-full items-center justify-center justify-self-center">
|
||||
@@ -197,10 +195,9 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
|
||||
<Button
|
||||
className="w-full h-12 px-9"
|
||||
onClick={downloadKey}
|
||||
onClick={handleContinue}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Download key
|
||||
Continue
|
||||
</Button>
|
||||
|
||||
<div className='mx-auto max-w-sm'>
|
||||
@@ -211,7 +208,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-xs text-amber-900 dark:text-amber-300'>
|
||||
This key is your primary and only means of accessing your account. Store it safely and securely. Please download your key to continue.
|
||||
This key is your primary and only means of accessing your account. Store it safely and securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-[18px] w-[18px] shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
"peer h-[18px] w-[18px] shrink-0 rounded-xs border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -70,7 +70,7 @@ const SheetContent = React.forwardRef<
|
||||
? "left-full ml-3 top-4"
|
||||
: "right-4 top-4 rounded-sm ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2 data-[state=open]:bg-secondary"
|
||||
)}
|
||||
style={{ top: `calc(env(safe-area-inset-top, 0px) + 0.85rem)` }}
|
||||
style={{ top: `calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 0.85rem)` }}
|
||||
>
|
||||
<X className={side === "left" ? "h-5 w-5 text-white" : "h-4 w-4"} strokeWidth={side === "left" ? 2.5 : 2} />
|
||||
<span className="sr-only">Close</span>
|
||||
|
||||
@@ -14,7 +14,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[300] flex max-h-screen w-full flex-col-reverse p-4 pt-[max(1rem,env(safe-area-inset-top))] md:bottom-0 md:right-0 md:top-auto md:flex-col md:pt-4 md:max-w-[420px]",
|
||||
"fixed top-0 z-[300] flex max-h-screen w-full flex-col-reverse p-4 pt-[max(1rem,var(--safe-area-inset-top,env(safe-area-inset-top)))] md:bottom-0 md:right-0 md:top-auto md:flex-col md:pt-4 md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
export interface UserStatus {
|
||||
/** The status text, or null if no status / expired / cleared. */
|
||||
status: string | null;
|
||||
@@ -44,7 +46,7 @@ export function useUserStatus(pubkey: string | undefined): UserStatus & { isLoad
|
||||
const content = event.content.trim();
|
||||
if (!content) return { status: null, url: null };
|
||||
|
||||
const url = event.tags.find(([n]) => n === 'r')?.[1] ?? null;
|
||||
const url = sanitizeUrl(event.tags.find(([n]) => n === 'r')?.[1]) ?? null;
|
||||
|
||||
return { status: content, url };
|
||||
},
|
||||
|
||||
+22
-16
@@ -34,37 +34,43 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* ── Safe-area inset utilities ────────────────────────────────────────────
|
||||
Use var(--safe-area-inset-*, …) as the outer wrapper so that
|
||||
Capacitor's SystemBars plugin (which injects --safe-area-inset-* CSS
|
||||
variables on Android) takes precedence when available. The inner
|
||||
env(safe-area-inset-*, 0px) is the standard fallback for iOS / web. */
|
||||
|
||||
.safe-area-top {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.safe-area-inset-top {
|
||||
top: env(safe-area-inset-top, 0px);
|
||||
top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
.safe-area-inset-bottom {
|
||||
bottom: env(safe-area-inset-bottom, 0px);
|
||||
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* FAB bottom offset: clears bottom nav + safe area inset on mobile */
|
||||
.bottom-fab {
|
||||
bottom: calc(1.5rem + var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
bottom: calc(1.5rem + var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
/* Position above mobile bottom nav + safe area + arc overhang (28px) */
|
||||
.bottom-mobile-nav {
|
||||
bottom: calc(var(--bottom-nav-height) + 28px + env(safe-area-inset-bottom, 0px));
|
||||
bottom: calc(var(--bottom-nav-height) + 28px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
/* Bottom overscroll padding for the center column:
|
||||
clears the mobile bottom nav + safe area + generous extra space
|
||||
so content can be scrolled well past the bottom bar */
|
||||
.pb-overscroll {
|
||||
padding-bottom: calc(10vh + var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: calc(10vh + var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
@@ -75,12 +81,12 @@
|
||||
|
||||
/* Mobile top bar height + safe area inset for sticky elements */
|
||||
.top-mobile-bar {
|
||||
top: calc(var(--top-bar-height) + env(safe-area-inset-top, 0px));
|
||||
top: calc(var(--top-bar-height) + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
|
||||
}
|
||||
|
||||
/* New-posts pill: just below the SubHeaderBar on both mobile and desktop */
|
||||
.new-posts-pill {
|
||||
top: calc(var(--top-bar-height) + env(safe-area-inset-top, 0px) + 3.5rem);
|
||||
top: calc(var(--top-bar-height) + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 3.5rem);
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.new-posts-pill {
|
||||
@@ -94,29 +100,29 @@
|
||||
Must clear its own height (100%) + top bar + safe area + arc overhang (20px). */
|
||||
@media (max-width: 899px) {
|
||||
.nav-hidden-slide {
|
||||
transform: translateY(calc(-100% - var(--top-bar-height) - 20px - env(safe-area-inset-top, 0px)));
|
||||
transform: translateY(calc(-100% - var(--top-bar-height) - 20px - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))));
|
||||
}
|
||||
}
|
||||
|
||||
/* Negative margin to pull content area up behind the mobile top bar (only when it's visible) */
|
||||
@media (max-width: 899px) {
|
||||
.-mt-mobile-bar {
|
||||
margin-top: calc(-1 * var(--top-bar-height) - env(safe-area-inset-top, 0px));
|
||||
padding-top: calc(var(--top-bar-height) + env(safe-area-inset-top, 0px));
|
||||
margin-top: calc(-1 * var(--top-bar-height) - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
|
||||
padding-top: calc(var(--top-bar-height) + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
|
||||
}
|
||||
}
|
||||
|
||||
/* AI chat height on mobile: full viewport minus top bar, extends behind bottom nav.
|
||||
Padding-bottom keeps input above the nav. */
|
||||
.ai-chat-height {
|
||||
height: calc(100dvh - var(--top-bar-height) - env(safe-area-inset-top, 0px));
|
||||
padding-bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
height: calc(100dvh - var(--top-bar-height) - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
|
||||
padding-bottom: calc(var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
/* Live stream page height on mobile: full viewport minus top bar, bottom nav, and safe-area insets */
|
||||
.livestream-height {
|
||||
height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - env(safe-area-inset-bottom, 0px));
|
||||
max-height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - env(safe-area-inset-bottom, 0px));
|
||||
height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
max-height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
/* Vine feed slide height: full viewport on mobile (top bar + bottom nav are
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
+11
-26
@@ -4,39 +4,24 @@ import { Capacitor } from '@capacitor/core';
|
||||
* Download a text file to the user's device.
|
||||
*
|
||||
* On the web this uses the classic `<a download>` trick.
|
||||
* On Android it writes to the public Download folder via ExternalStorage.
|
||||
* On iOS it writes to a temp file and presents the native share sheet.
|
||||
* On native (Android & iOS) the file is saved to the app's Documents
|
||||
* directory, which is visible in the iOS Files app and Android's
|
||||
* app-scoped documents. No permissions are required.
|
||||
*/
|
||||
export async function downloadTextFile(filename: string, content: string): Promise<void> {
|
||||
const platform = Capacitor.getPlatform();
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const { Filesystem, Directory, Encoding } = await import('@capacitor/filesystem');
|
||||
|
||||
if (platform === 'android') {
|
||||
const { Filesystem, Directory } = await import('@capacitor/filesystem');
|
||||
|
||||
// Write to the public Download folder. On Android 11+ no storage
|
||||
// permissions are required for app-created files in shared directories.
|
||||
// Write straight to Documents — visible in the iOS Files app and
|
||||
// Android's app-scoped documents. No storage permissions needed.
|
||||
// NOTE: encoding is required — without it Capacitor expects base64 data
|
||||
// and will throw for plain-text strings.
|
||||
await Filesystem.writeFile({
|
||||
path: `Download/${filename}`,
|
||||
data: content,
|
||||
directory: Directory.ExternalStorage,
|
||||
});
|
||||
} else if (platform === 'ios') {
|
||||
const { Filesystem, Directory } = await import('@capacitor/filesystem');
|
||||
const { Share } = await import('@capacitor/share');
|
||||
|
||||
const result = await Filesystem.writeFile({
|
||||
path: filename,
|
||||
data: content,
|
||||
directory: Directory.Cache,
|
||||
directory: Directory.Documents,
|
||||
encoding: Encoding.UTF8,
|
||||
});
|
||||
|
||||
// On iOS there is no user-visible Downloads folder, so present the
|
||||
// share sheet and let the user choose where to save / send the file.
|
||||
try {
|
||||
await Share.share({ title: filename, url: result.uri });
|
||||
} catch {
|
||||
// User dismissed the share sheet — not a real failure
|
||||
}
|
||||
} else {
|
||||
// Web: use the anchor-click download pattern
|
||||
const blob = new Blob([content], { type: 'text/plain; charset=utf-8' });
|
||||
|
||||
+15
-3
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
|
||||
|
||||
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
|
||||
const MIN_QR_CONTRAST = 3;
|
||||
|
||||
/** Saturation threshold (%) above which a color is considered "colorful". */
|
||||
const COLORFUL_SAT_MIN = 15;
|
||||
/** Lightness range within which a color appears visually colorful. */
|
||||
const COLORFUL_L_MIN = 20;
|
||||
const COLORFUL_L_MAX = 80;
|
||||
|
||||
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
|
||||
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
|
||||
if (!raw) return null;
|
||||
const { h, s, l } = parseHsl(raw);
|
||||
if ([h, s, l].some(isNaN)) return null;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function darkenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l > 0 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.max(0, l - 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function lightenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l < 100 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.min(100, l + 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the best module color from primary and foreground.
|
||||
*
|
||||
* Strongly prefers primary since it carries the theme's brand identity.
|
||||
* Only picks foreground if it is colorful (saturation > threshold) AND
|
||||
* has significantly better contrast (> 1.5x) against the QR background.
|
||||
*/
|
||||
function pickModuleColor(
|
||||
primary: { h: number; s: number; l: number },
|
||||
foreground: { h: number; s: number; l: number } | null,
|
||||
bgRgb: [number, number, number],
|
||||
): { h: number; s: number; l: number } {
|
||||
const fgIsColorful = foreground
|
||||
&& foreground.s >= COLORFUL_SAT_MIN
|
||||
&& foreground.l >= COLORFUL_L_MIN
|
||||
&& foreground.l <= COLORFUL_L_MAX;
|
||||
|
||||
if (!fgIsColorful) return primary;
|
||||
|
||||
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
|
||||
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
|
||||
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
|
||||
const fgContrast = getContrastRatio(fgRgb, bgRgb);
|
||||
|
||||
// Foreground must be significantly better to override primary
|
||||
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive QR module and background hex colors from the active theme.
|
||||
*
|
||||
* Light themes: white background, best themed color as modules (darkened if needed).
|
||||
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
|
||||
*
|
||||
* "Best themed color" is --primary by default. If --foreground is colorful
|
||||
* (saturation > 15%) and offers better contrast, it wins instead.
|
||||
*/
|
||||
export function getThemedQRColors(): { dark: string; light: string } {
|
||||
const primary = readCssHsl('--primary');
|
||||
const foreground = readCssHsl('--foreground');
|
||||
const background = readCssHsl('--background');
|
||||
|
||||
if (!primary) return { dark: '#000000', light: '#ffffff' };
|
||||
|
||||
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
|
||||
|
||||
if (!isDark) {
|
||||
const white: [number, number, number] = [255, 255, 255];
|
||||
const module = pickModuleColor(primary, foreground, white);
|
||||
return { dark: darkenToContrast(module, white), light: '#ffffff' };
|
||||
}
|
||||
|
||||
if (!background) return { dark: '#ffffff', light: '#000000' };
|
||||
const bgRgb = hslToRgb(background.h, background.s, background.l);
|
||||
const module = pickModuleColor(primary, foreground, bgRgb);
|
||||
return {
|
||||
dark: lightenToContrast(module, bgRgb),
|
||||
light: rgbToHex(...bgRgb),
|
||||
};
|
||||
}
|
||||
@@ -85,10 +85,18 @@ export interface SandboxScriptMessageEvent {
|
||||
// Plugin interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Options for navigating the sandbox WebView to its entry point. */
|
||||
export interface SandboxNavigateOptions {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SandboxPluginInterface {
|
||||
/** Create a new sandbox WebView with a unique custom URL scheme. */
|
||||
/** Create a new sandbox WebView with a loading spinner (does not navigate). */
|
||||
create(options: SandboxCreateOptions): Promise<void>;
|
||||
|
||||
/** Navigate the sandbox WebView to its entry point (triggers resource loading). */
|
||||
navigate(options: SandboxNavigateOptions): Promise<void>;
|
||||
|
||||
/** Update the position/size of an existing sandbox WebView. */
|
||||
updateFrame(options: SandboxUpdateFrameOptions): Promise<void>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';
|
||||
|
||||
/**
|
||||
* Storage adapter that uses native secure storage (iOS Keychain / Android KeyStore)
|
||||
* on Capacitor builds and falls back to localStorage on web.
|
||||
*
|
||||
* Implements the `NLoginStorage` interface from @nostrify/react.
|
||||
*
|
||||
* On the first native read, if the key is not found in secure storage but exists
|
||||
* in localStorage, it is automatically migrated to secure storage and the
|
||||
* plaintext localStorage copy is removed.
|
||||
*/
|
||||
export const secureStorage = {
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
try {
|
||||
const { value } = await SecureStoragePlugin.get({ key });
|
||||
return value;
|
||||
} catch {
|
||||
// Key not found in secure storage — check localStorage for migration.
|
||||
const legacy = localStorage.getItem(key);
|
||||
if (legacy !== null) {
|
||||
// Migrate to secure storage and remove the plaintext copy.
|
||||
await SecureStoragePlugin.set({ key, value: legacy });
|
||||
localStorage.removeItem(key);
|
||||
return legacy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
localStorage.setItem(key, value);
|
||||
return;
|
||||
}
|
||||
|
||||
await SecureStoragePlugin.set({ key, value });
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
+13
-13
@@ -20,26 +20,26 @@ 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 { getBackgroundThemeMode, getBackgroundHex } from '@/lib/colorUtils';
|
||||
import { Capacitor, SystemBars, SystemBarsStyle } from '@capacitor/core';
|
||||
import { Keyboard } from '@capacitor/keyboard';
|
||||
import { getBackgroundThemeMode } from '@/lib/colorUtils';
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// Hide the iOS keyboard accessory bar (prev/next/done toolbar above the keyboard)
|
||||
Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => {});
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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">
|
||||
|
||||
+21
-42
@@ -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
|
||||
@@ -2185,24 +2170,18 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
) : displayName}
|
||||
</h2>
|
||||
{metadata?.nip05 && (
|
||||
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey ?? ''} className="text-sm text-muted-foreground" showCheck />
|
||||
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey ?? ''} className="text-sm text-muted-foreground" />
|
||||
)}
|
||||
{(metadata?.lud16 || metadata?.lud06) && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-0.5">
|
||||
<Zap className="size-3.5 text-amber-500 shrink-0" />
|
||||
<span className="truncate">{metadata.lud16 || metadata.lud06}</span>
|
||||
</div>
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
{metadata.website.replace(/^https?:\/\//, '')}
|
||||
{metadata.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+25
@@ -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;
|
||||
}
|
||||
+2
-1
@@ -75,7 +75,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': {
|
||||
|
||||
@@ -176,6 +176,7 @@ export default defineConfig(({ mode }) => {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
dedupe: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user