Compare commits

..

8 Commits

Author SHA1 Message Date
sam af822e7c63 messaging++ 2026-04-15 11:36:05 +05:45
sam 4ac4f32b45 fix build 2026-04-12 08:44:17 +05:45
sam 286a0fcadc turnoffable chats 2026-04-11 11:18:36 +05:45
sam 013584da06 Merge branch 'main' into dms-rebased 2026-04-10 09:50:27 +05:45
sam 19df70baed updated dm route 2026-04-09 20:13:35 +05:45
sam cf7384523a reinstate messages settings 2026-04-09 18:10:57 +05:45
sam 64729b9804 change route/menu 2026-04-05 14:47:04 +05:45
sam 954339c3b9 rework dms pr 2026-04-05 13:13:53 +05:45
66 changed files with 1548 additions and 1204 deletions
+3
View File
@@ -37,6 +37,9 @@ deploy.sh
# Build-time configuration
ditto.json
# DM message sounds (copied from node_modules by postinstall)
public/sounds/
# Android build outputs and sensitive files
*.aab
resources/
@@ -1,68 +0,0 @@
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
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
-72
View File
@@ -409,74 +409,6 @@ 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.
@@ -1403,10 +1335,6 @@ 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.
-15
View File
@@ -1,20 +1,5 @@
# Changelog
## [2.6.3] - 2026-04-10
### Added
- Lightning invoices embedded in posts now render as tappable payment cards
- Blobbi companions in the feed reflect their current condition and projected health
### Changed
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
- "Request to Vanish" renamed to "Delete Account" for clarity
### Fixed
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
- Security hardening for URLs and styles sourced from the network
## [2.6.2] - 2026-04-08
### Added
-184
View File
@@ -1,184 +0,0 @@
# 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.
+15
View File
@@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY package*.json ./
COPY .npmrc ./
COPY scripts/ ./scripts/
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-11
View File
@@ -138,17 +138,6 @@ src/
public/ Static assets, icons, manifest
```
## Contributing
We welcome contributions but have high standards. Please read the full [Contributing Guide](CONTRIBUTING.md) before submitting a merge request. The short version:
- **Bug fixes**: One bug, one MR. Keep it small and focused.
- **New features**: Must link to an existing issue and align with the [Ditto Philosophy](https://about.ditto.pub/philosophy).
- **Required**: Live preview URL, before/after screenshots, completed self-review checklist.
- **Required tools**: Claude Opus 4.6 (or latest frontier model), an AI coding agent with plan mode.
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy) to understand what Ditto is and isn't.
## License
[AGPL-3.0](LICENSE)
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.6.3"
versionName "2.6.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
-1
View File
@@ -15,7 +15,6 @@ dependencies {
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')
}
-3
View File
@@ -20,8 +20,5 @@ project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/sh
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')
+6
View File
@@ -0,0 +1,6 @@
services:
web:
build: .
restart: unless-stopped
expose:
- "80"
+31
View File
@@ -0,0 +1,31 @@
services:
web:
image: nginx:alpine
ports:
- "8082:80"
volumes:
- ./nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
- ./dist:/usr/share/nginx/html:ro
restart: unless-stopped
depends_on:
- vite
networks:
- ditto-network
vite:
image: node:22-alpine
working_dir: /app
# Use host node_modules (no anonymous volume) so new deps added after merge
# are picked up after a plain "npm install" on the host and container restart.
command: sh -c "npm install && npm run dev"
volumes:
- .:/app
environment:
- NODE_ENV=development
networks:
- ditto-network
restart: unless-stopped
networks:
ditto-network:
driver: bridge
+2 -8
View File
@@ -32,7 +32,6 @@
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 */
@@ -68,7 +67,6 @@
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
B1A2C3D40004000100000002 /* App.entitlements */,
504EC3071FED79650016851F /* AppDelegate.swift */,
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
@@ -305,17 +303,15 @@
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.3;
MARKETING_VERSION = 2.6.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -329,17 +325,15 @@
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.3;
MARKETING_VERSION = 2.6.2;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
-11
View File
@@ -1,11 +0,0 @@
<?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>
-2
View File
@@ -49,8 +49,6 @@
<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>
-2
View File
@@ -18,7 +18,6 @@ let package = Package(
.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: [
@@ -33,7 +32,6 @@ let package = Package(
.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")
]
)
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
resolver 127.0.0.11 valid=10s;
set $vite_backend http://vite:8080;
proxy_pass $vite_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
}
+230 -249
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.6.3",
"version": "2.6.2",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -22,7 +22,6 @@
"@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",
@@ -98,10 +97,11 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@samthomson/nostr-messaging": "^0.14.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"@unhead/addons": "^2.0.10",
"@unhead/react": "^2.0.10",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
@@ -1,7 +0,0 @@
{
"webcredentials": {
"apps": [
"GZLTTH5DLM.pub.ditto.app"
]
}
}
-15
View File
@@ -1,20 +1,5 @@
# Changelog
## [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
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Copy default message sounds from @samthomson/nostr-messaging package
if [ -d "node_modules/@samthomson/nostr-messaging/assets/sounds" ]; then
mkdir -p public/sounds
cp node_modules/@samthomson/nostr-messaging/assets/sounds/*.mp3 public/sounds/
echo "Copied message sounds to public/sounds/"
fi
+4 -1
View File
@@ -16,7 +16,7 @@ import NostrProvider from "@/components/NostrProvider";
import { NostrSync } from "@/components/NostrSync";
import { PlausibleProvider } from "@/components/PlausibleProvider";
import { SentryProvider } from "@/components/SentryProvider";
import { DMProviderWrapper } from "@/components/DMProviderWrapper";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
@@ -124,6 +124,7 @@ const hardcodedConfig: AppConfig = {
sidebarOrder: [
"feed",
"notifications",
"dms",
"search",
"blobbi",
"badges",
@@ -207,6 +208,7 @@ export function App() {
<NativeNotifications />
<NWCProvider>
<DMProviderWrapper>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
@@ -216,6 +218,7 @@ export function App() {
</TooltipProvider>
</EmotionDevProvider>
</DMProvider>
</DMProviderWrapper>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
+4
View File
@@ -79,6 +79,8 @@ const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ de
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const MessagesPage = lazy(() => import("./pages/MessagesPage").then(m => ({ default: m.MessagesPage })));
const MessagingSettings = lazy(() => import("./pages/MessagingSettings"));
const pollsDef = getExtraKindDef("polls")!;
const colorsDef = getExtraKindDef("colors")!;
@@ -160,6 +162,8 @@ export function AppRouter() {
<Route path="/" element={<HomePage />} />
<Route path="/feed" element={<Index />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/chats" element={<MessagesPage />} />
<Route path="/settings/messaging" element={<MessagingSettings />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/trends" element={<TrendsPage />} />
<Route path="/profile" element={<ProfileRedirect />} />
-48
View File
@@ -876,51 +876,3 @@ 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 };
}
+5 -4
View File
@@ -297,10 +297,11 @@ 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">Delete Account</h3>
<h3 className="text-sm font-medium">Request to Vanish</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Permanently delete your data from the network, including your profile,
posts, reactions, and direct messages. This action is irreversible.
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).
</p>
</div>
<Button
@@ -309,7 +310,7 @@ export function AdvancedSettings() {
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setVanishDialogOpen(true)}
>
Delete Account
Request to Vanish
</Button>
</div>
</div>
+1 -2
View File
@@ -9,7 +9,6 @@ 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. */
@@ -107,7 +106,7 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
const about = metadata.about;
const picture = metadata.picture;
const banner = metadata.banner;
const websiteUrl = sanitizeUrl(getWebsiteUrl(event.tags, metadata));
const websiteUrl = getWebsiteUrl(event.tags, metadata);
const hashtags = getAllTags(event.tags, 't');
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
+3 -29
View File
@@ -3,41 +3,17 @@ 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]);
const isSleeping = companion?.state === 'sleeping';
const isEgg = companion?.stage === 'egg';
// ── 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;
const isSleeping = companion.state === 'sleeping';
return (
<div className="flex flex-col items-center py-4">
{/* Blobbi visual — reflects current condition */}
{/* Blobbi visual — same as /blobbi hero */}
<div className="relative">
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
@@ -45,8 +21,6 @@ export function BlobbiStateCard({ event }: { event: NostrEvent }) {
size="lg"
animated={!isSleeping}
lookMode="forward"
recipe={feedRecipe}
recipeLabel={feedRecipeLabel}
className="size-48 sm:size-56"
/>
</div>
+1 -2
View File
@@ -34,7 +34,6 @@ 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 ---
@@ -160,7 +159,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]) => sanitizeUrl(v)).filter((v): v is string => !!v);
const links = getAllTags(event.tags, 'r').map(([, v]) => v).filter(Boolean);
const eventCoord = useMemo(() => getEventCoord(event), [event]);
const dateStr = useMemo(() => formatDetailDate(event), [event]);
+1 -2
View File
@@ -15,7 +15,6 @@ 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 ---
@@ -93,7 +92,7 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
// Extract website URL from description if present
const descriptionUrl = useMemo(() => {
const urlMatch = description.match(/https?:\/\/[^\s]+/);
return sanitizeUrl(urlMatch?.[0]);
return urlMatch?.[0];
}, [description]);
// Description text without trailing URL (if the URL is the last thing)
-3
View File
@@ -292,9 +292,6 @@ 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 */}
+110
View File
@@ -0,0 +1,110 @@
import { type ReactNode, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { DMProvider } from '@samthomson/nostr-messaging/core';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useAuthorsBatch } from '@/hooks/useAuthorsBatch';
import { useProfileSupplementary } from '@/hooks/useProfileData';
import { useIsMobile } from '@/hooks/useIsMobile';
import { toast } from '@/hooks/useToast';
import { getDisplayName } from '@/lib/getDisplayName';
import { APP_NEW_MESSAGE_SOUNDS } from '@/lib/messagingSounds';
interface DMProviderWrapperProps {
children: ReactNode;
}
export function DMProviderWrapper({ children }: DMProviderWrapperProps) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { mutateAsync: publishEvent } = useNostrPublish();
const { mutateAsync: uploadFileMutation } = useUploadFile();
const isMobile = useIsMobile();
// Get the current user's follows
const { data: profileData } = useProfileSupplementary(user?.pubkey);
const follows = useMemo(() => profileData?.following ?? [], [profileData]);
// Wrap publishEvent to match the expected signature
const handlePublishEvent = async (event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>): Promise<void> => {
await publishEvent(event);
};
// Wrap uploadFile to return just the URL string
const handleUploadFile = async (file: File): Promise<string> => {
const tags = await uploadFileMutation(file);
return tags[0][1]; // Return the URL from the first tag
};
// Wrap getDisplayName to match the expected signature
const handleGetDisplayName = (pubkey: string, metadata?: Parameters<typeof getDisplayName>[0]) => {
return getDisplayName(metadata, pubkey);
};
// Wrap toast to match the expected signature
const handleNotify = (options: { title?: string; description?: string; variant?: 'default' | 'destructive' }) => {
toast({
title: options.title,
description: options.description,
variant: options.variant,
});
};
const messaging = useMemo(() => config.messaging ?? {}, [config.messaging]);
// Discovery relays for DM inbox discovery
const discoveryRelays = useMemo(() => {
if (messaging.discoveryRelays?.length) {
return messaging.discoveryRelays;
}
return config.relayMetadata.relays
.filter(r => r.read)
.map(r => r.url);
}, [messaging.discoveryRelays, config.relayMetadata.relays]);
const relayMode = messaging.relayMode ?? 'hybrid';
const messagingEnabled = messaging.enabled ?? false;
const renderInlineMedia = messaging.renderInlineMedia ?? true;
const soundEnabled = messaging.soundEnabled ?? false;
const soundId = messaging.soundId ?? APP_NEW_MESSAGE_SOUNDS[0]?.id ?? '';
const devMode = messaging.devMode ?? false;
return (
<DMProvider
nostr={nostr}
user={user ?? null}
messagingConfig={{
enabled: messagingEnabled,
discoveryRelays,
relayMode,
renderInlineMedia,
devMode,
appName: config.appName,
appDescription: `Direct messages on ${config.appName}`,
soundPref: {
options: APP_NEW_MESSAGE_SOUNDS,
value: { enabled: soundEnabled, soundId },
onChange: () => {},
},
}}
onNotify={handleNotify}
getDisplayName={handleGetDisplayName}
fetchAuthorsBatch={useAuthorsBatch}
publishEvent={handlePublishEvent}
uploadFile={handleUploadFile}
follows={follows}
ui={{
showShorts: false,
showSearch: true,
isMobile,
}}
>
{children}
</DMProvider>
);
}
+1 -2
View File
@@ -10,7 +10,6 @@ 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 {
@@ -76,7 +75,7 @@ interface FileMetadataContentProps {
* rounded card below it (similar to YouTube's description box).
*/
export function FileMetadataContent({ event, compact }: FileMetadataContentProps) {
const url = sanitizeUrl(getTag(event.tags, 'url'));
const url = getTag(event.tags, 'url');
const mime = getTag(event.tags, 'm') ?? '';
const alt = getTag(event.tags, 'alt');
const webxdcId = getTag(event.tags, 'webxdc');
+1 -2
View File
@@ -3,7 +3,6 @@ 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 {
@@ -24,7 +23,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]) => sanitizeUrl(v)).filter((v): v is string => !!v);
const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => v);
const isPersonalFork = event.tags.some(
([n, v]) => n === "t" && v === "personal-fork",
);
+19 -18
View File
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import {
Check,
ChevronRight,
Download,
Eye,
EyeOff,
Heart,
@@ -12,7 +13,7 @@ import {
Users,
} from "lucide-react";
import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
import { saveNsec } from "@/lib/credentialManager";
import { downloadTextFile } from "@/lib/downloadFile";
import { fetchFreshEvent } from "@/lib/fetchFreshEvent";
import {
type ReactNode,
@@ -287,8 +288,7 @@ function SetupQuestionnaire({
}
}, [step, steps]);
// Keygen handler — generates the key and advances to the save step.
// The credential manager prompt is deferred until the user clicks "Continue".
// Keygen handler
const handleGenerate = useCallback(() => {
const sk = generateSecretKey();
const encoded = nip19.nsecEncode(sk);
@@ -296,26 +296,26 @@ function SetupQuestionnaire({
next();
}, [next]);
// 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 () => {
// Download + login handler
const handleDownloadAndLogin = 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 saveNsec(npub, nsec);
await downloadTextFile(filename, nsec);
// Log in with the new key
login.nsec(nsec);
next();
} catch {
toast({
title: "Save failed",
title: "Download failed",
description:
"Could not save the key. Please copy it manually.",
"Could not download the key file. Please copy it manually.",
variant: "destructive",
});
}
@@ -447,7 +447,7 @@ function SetupQuestionnaire({
{step === "keygen" && <KeygenStep onGenerate={handleGenerate} />}
{step === "download" && (
<DownloadStep nsec={nsec} onContinue={handleDownloadContinue} />
<DownloadStep nsec={nsec} onDownload={handleDownloadAndLogin} />
)}
{step === "profile" && (
@@ -514,10 +514,10 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
function DownloadStep({
nsec,
onContinue,
onDownload,
}: {
nsec: string;
onContinue: () => void;
onDownload: () => void;
}) {
const [showKey, setShowKey] = useState(false);
@@ -528,7 +528,8 @@ function DownloadStep({
Save your secret key
</h2>
<p className="text-sm text-muted-foreground">
This is your only way to access your account. Keep it somewhere safe.
This is your only way to access your account. Download it and keep it
somewhere safe.
</p>
</div>
@@ -560,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.
there is no way to recover it. Download it now to continue.
</p>
</div>
<Button
size="lg"
className="w-full gap-2 rounded-full h-12"
onClick={onContinue}
onClick={onDownload}
>
Continue
<ChevronRight className="w-4 h-4" />
<Download className="w-4 h-4" />
Download and continue
</Button>
</div>
);
+1 -2
View File
@@ -8,7 +8,6 @@ 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 {
@@ -25,7 +24,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 = sanitizeUrl(event.tags.find(([n]) => n === "source")?.[1]);
const sourceUrl = event.tags.find(([n]) => n === "source")?.[1];
const pathTags = event.tags.filter(([n]) => n === "path");
const serverTags = event.tags.filter(([n]) => n === "server");
+12 -13
View File
@@ -25,7 +25,6 @@ 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];
@@ -401,24 +400,24 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
}
// Media fields: render inline players/previews based on file extension
const safeUrl = sanitizeUrl(field.value);
const isUrl = field.value.startsWith('http://') || field.value.startsWith('https://');
if (safeUrl && isAudioUrl(safeUrl)) {
if (isUrl && isAudioUrl(field.value)) {
return (
<div>
<div className="font-semibold text-sm mb-1.5">{field.label}</div>
<MiniAudioPlayer src={safeUrl} />
<MiniAudioPlayer src={field.value} />
</div>
);
}
if (safeUrl && isImageUrl(safeUrl)) {
if (isUrl && isImageUrl(field.value)) {
return (
<div>
{field.label && <div className="font-semibold text-sm mb-1.5">{field.label}</div>}
<a href={safeUrl} target="_blank" rel="noopener noreferrer" className="block">
<a href={field.value} target="_blank" rel="noopener noreferrer" className="block">
<img
src={safeUrl}
src={field.value}
alt={field.label || 'Profile image'}
className="w-full rounded-lg object-cover"
loading="lazy"
@@ -428,12 +427,12 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
);
}
if (safeUrl && isVideoUrl(safeUrl)) {
if (isUrl && isVideoUrl(field.value)) {
return (
<div>
{field.label && <div className="font-semibold text-sm mb-1.5">{field.label}</div>}
<div className="rounded-lg overflow-hidden">
<VideoPlayer src={safeUrl} />
<VideoPlayer src={field.value} />
</div>
</div>
);
@@ -443,15 +442,15 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
return (
<div>
<div className="font-semibold text-sm">{field.label}</div>
{safeUrl ? (
{isUrl ? (
<a
href={safeUrl}
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-primary hover:underline truncate mt-0.5"
>
<ExternalFavicon url={safeUrl} size={16} className="shrink-0" />
<span className="truncate">{safeUrl.replace(/^https?:\/\//, '')}</span>
<ExternalFavicon url={field.value} size={16} className="shrink-0" />
<span className="truncate">{field.value.replace(/^https?:\/\//, '')}</span>
</a>
) : (
<p className="text-sm text-muted-foreground truncate">{field.value}</p>
+412 -94
View File
@@ -1,14 +1,19 @@
import { useState, useCallback, useEffect } from 'react';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { Globe, Radio, Loader2, X, ArrowRight, ArrowLeft, Flame } from 'lucide-react';
import {
AlertDialog,
AlertDialogContent,
AlertDialogTitle,
AlertDialogDescription,
} from '@/components/ui/alert-dialog';
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
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 { useRequestToVanish } from '@/hooks/useRequestToVanish';
import { useAppContext } from '@/hooks/useAppContext';
import { useLoginActions } from '@/hooks/useLoginActions';
import { toast } from '@/hooks/useToast';
@@ -17,38 +22,30 @@ interface RequestToVanishDialogProps {
onOpenChange: (open: boolean) => void;
}
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;
type VanishMode = 'global' | 'targeted';
type Step = 0 | 1 | 2;
type ItemId = (typeof DELETION_ITEMS)[number]['id'];
const STEPS = ['Scope', 'Details', 'Confirm'] as const;
const CONFIRMATION_PHRASE = 'VANISH';
export function RequestToVanishDialog({ open, onOpenChange }: RequestToVanishDialogProps) {
const { config } = useAppContext();
const { mutateAsync: requestVanish, isPending } = useRequestToVanish();
const { logout } = useLoginActions();
const [checked, setChecked] = useState<Set<ItemId>>(new Set());
const [step, setStep] = useState<Step>(0);
const [mode, setMode] = useState<VanishMode>('global');
const [reason, setReason] = useState('');
const [confirmText, setConfirmText] = useState('');
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 userRelays = config.relayMetadata.relays.map((r) => r.url);
const isConfirmed = confirmText === CONFIRMATION_PHRASE;
const resetState = useCallback(() => {
setChecked(new Set());
setStep(0);
setMode('global');
setReason('');
setConfirmText('');
}, []);
// Reset when dialog closes.
@@ -57,90 +54,411 @@ export function RequestToVanishDialog({ open, onOpenChange }: RequestToVanishDia
}, [open, resetState]);
const handleSubmit = async () => {
if (!allChecked) return;
if (!isConfirmed) return;
try {
await requestVanish({ relayUrls: ['ALL_RELAYS'], content: '' });
const relayUrls = mode === 'global' ? ['ALL_RELAYS'] : userRelays;
await requestVanish({ relayUrls, content: reason.trim() });
toast({
title: 'Account deleted',
description: 'Your deletion request has been broadcast. You have been logged out.',
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).`,
});
onOpenChange(false);
await logout();
} catch {
toast({
title: 'Failed to delete account',
description: 'Something went wrong. You can try again.',
title: 'Failed to send request',
description: 'Some relays may not have received the request. You can try again.',
variant: 'destructive',
});
}
};
return (
<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>
<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>
</div>
{/* 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"
<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
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>
))}
</div>
<ArrowLeft className="size-3.5" />
Back
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isPending}
className="text-muted-foreground"
>
Cancel
</Button>
)}
{/* 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>
{/* 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>
{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>
</AlertDialogContent>
</AlertDialog>
</DialogContent>
</Dialog>
);
}
/* ───────────────────────── Step 0: Scope ───────────────────────── */
function StepScope({
mode,
setMode,
userRelays,
}: {
mode: VanishMode;
setMode: (m: VanishMode) => void;
userRelays: string[];
}) {
return (
<div className="px-5 py-5 space-y-4">
<div>
<h3 className="text-sm font-semibold">Choose scope</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Select which relays should delete your data. This determines the reach of your vanish request.
</p>
</div>
<div className="space-y-2">
<ScopeCard
selected={mode === 'global'}
onClick={() => setMode('global')}
icon={<Globe className="size-5" />}
title="All relays"
description="Request every relay on the network to delete your data. The event is broadcast as widely as possible."
badge="Recommended"
/>
<ScopeCard
selected={mode === 'targeted'}
onClick={() => setMode('targeted')}
icon={<Radio className="size-5" />}
title={`My relays only (${userRelays.length})`}
description="Request only your currently configured relays to delete your data."
/>
</div>
{/* Relay list preview for targeted mode */}
{mode === 'targeted' && userRelays.length > 0 && (
<div className="rounded-lg bg-muted/40 border border-border/50 px-3 py-2.5 space-y-1.5 animate-in fade-in-0 slide-in-from-top-1 duration-200">
<p className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Target relays</p>
<ul className="space-y-0.5">
{userRelays.map((url) => (
<li key={url} className="text-xs font-mono text-muted-foreground truncate">{url}</li>
))}
</ul>
</div>
)}
</div>
);
}
function ScopeCard({
selected,
onClick,
icon,
title,
description,
badge,
}: {
selected: boolean;
onClick: () => void;
icon: React.ReactNode;
title: string;
description: string;
badge?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'w-full text-left rounded-xl border-2 p-3.5 transition-all duration-200',
'hover:bg-secondary/30',
selected
? 'border-destructive/60 bg-destructive/[0.03] shadow-sm shadow-destructive/5'
: 'border-border/60 bg-transparent',
)}
>
<div className="flex items-start gap-3">
<div className={cn(
'flex size-9 items-center justify-center rounded-lg shrink-0 transition-colors',
selected ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground',
)}>
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{title}</span>
{badge && (
<span className="text-[10px] font-medium bg-destructive/10 text-destructive rounded-full px-2 py-0.5">
{badge}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{description}</p>
</div>
{/* Selection indicator */}
<div className={cn(
'size-4 rounded-full border-2 shrink-0 mt-0.5 transition-all duration-200 flex items-center justify-center',
selected ? 'border-destructive bg-destructive' : 'border-muted-foreground/30',
)}>
{selected && <div className="size-1.5 rounded-full bg-white" />}
</div>
</div>
</button>
);
}
/* ───────────────────────── Step 1: Details ───────────────────────── */
function StepDetails({
reason,
setReason,
mode,
userRelays,
}: {
reason: string;
setReason: (r: string) => void;
mode: VanishMode;
userRelays: string[];
}) {
return (
<div className="px-5 py-5 space-y-5">
{/* Summary of what will happen */}
<div className="rounded-xl bg-destructive/[0.04] border border-destructive/15 p-4 space-y-3">
<h3 className="text-sm font-semibold text-destructive flex items-center gap-2">
<Flame className="size-4" />
What will be deleted
</h3>
<ul className="space-y-2">
{[
'Your profile (kind 0) and metadata',
'All posts, replies, and reactions',
'Direct messages and gift wraps',
'Contact lists, relay lists, and settings',
'All other events published by your key',
].map((item) => (
<li key={item} className="flex items-start gap-2 text-xs text-muted-foreground leading-relaxed">
<span className="text-destructive/60 mt-0.5 shrink-0">&mdash;</span>
{item}
</li>
))}
</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
</p>
</div>
</div>
);
}
+2 -3
View File
@@ -5,7 +5,6 @@ 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';
@@ -244,8 +243,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 = sanitizeUrl(getTag(event.tags, 'url'));
const repoUrl = sanitizeUrl(getTag(event.tags, 'repository'));
const websiteUrl = getTag(event.tags, 'url');
const repoUrl = getTag(event.tags, 'repository');
const license = getTag(event.tags, 'license');
const appId = getTag(event.tags, 'd');
+2 -3
View File
@@ -25,7 +25,6 @@ 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 = {
@@ -204,7 +203,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 = sanitizeUrl(getTag(event.tags, 'url'));
const url = getTag(event.tags, 'url');
const version = getTag(event.tags, 'version');
const size = formatSize(getTag(event.tags, 'size'));
const platforms = getAllTags(event.tags, 'f');
@@ -562,7 +561,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 = sanitizeUrl(getTag(event.tags, 'url'));
const url = getTag(event.tags, 'url');
const version = getTag(event.tags, 'version');
const size = formatSize(getTag(event.tags, 'size'));
const appIdentifier = getTag(event.tags, 'i');
-19
View File
@@ -17,7 +17,6 @@ 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';
@@ -300,24 +299,6 @@ 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"
+15 -17
View File
@@ -2,7 +2,7 @@
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import React, { useState, useEffect, useRef } from 'react';
import { Eye, EyeOff, Loader2 } from 'lucide-react';
import { Download, Eye, EyeOff, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -11,7 +11,7 @@ import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { saveNsec } from '@/lib/credentialManager';
import { downloadTextFile } from '@/lib/downloadFile';
import { ProfileCard } from '@/components/ProfileCard';
import { ImageCropDialog } from '@/components/ImageCropDialog';
import type { NostrMetadata } from '@nostrify/nostrify';
@@ -38,19 +38,14 @@ 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.
// The credential manager / file download is deferred until the user clicks "Continue".
// Generate a proper nsec key using nostr-tools
const generateKey = () => {
const sk = generateSecretKey();
const encoded = nip19.nsecEncode(sk);
setNsec(encoded);
setNsec(nip19.nsecEncode(sk));
setStep('download');
};
// 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 () => {
const downloadKey = async () => {
try {
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') {
@@ -59,15 +54,17 @@ 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 saveNsec(npub, nsec);
await downloadTextFile(filename, nsec);
// Continue to profile step
login.nsec(nsec);
setStep('profile');
} catch {
toast({
title: 'Save failed',
description: 'Could not save the key. Please copy it manually.',
title: 'Download failed',
description: 'Could not download the key file. Please copy it manually.',
variant: 'destructive',
});
}
@@ -164,7 +161,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
</div>
)}
{/* Save Key Step */}
{/* Download 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">
@@ -195,9 +192,10 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
<Button
className="w-full h-12 px-9"
onClick={handleContinue}
onClick={downloadKey}
>
Continue
<Download className="size-4" />
Download key
</Button>
<div className='mx-auto max-w-sm'>
@@ -208,7 +206,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.
This key is your primary and only means of accessing your account. Store it safely and securely. Please download your key to continue.
</p>
</div>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-[18px] w-[18px] shrink-0 rounded-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",
"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",
className
)}
{...props}
+12
View File
@@ -241,6 +241,18 @@ export interface AppConfig {
savedFeeds: SavedFeed[];
/** Image upload quality: "compressed" resizes/optimizes, "original" uploads as-is. Default: "compressed". */
imageQuality: 'compressed' | 'original';
/** Messaging configuration (custom sounds, discovery relays, etc.) */
messaging?: {
/** Whether direct messaging is enabled for this account/session. Default: false. */
enabled?: boolean;
customSoundUrl?: string;
discoveryRelays?: string[];
relayMode?: 'discovery' | 'hybrid' | 'strict_outbox';
renderInlineMedia?: boolean;
soundEnabled?: boolean;
soundId?: string;
devMode?: boolean;
};
/** Hex pubkey of the curator whose follow list defines the Ditto feed. */
curatorPubkey?: string;
/** Wildcard domain used for iframe sandboxing (e.g. "iframe.diy"). Default: "iframe.diy". */
+14
View File
@@ -0,0 +1,14 @@
import { useAuthors } from './useAuthors';
/**
* Batch fetch author profiles for DM messaging integration.
*
* This hook wraps useAuthors to match the interface expected by
* @samthomson/nostr-messaging's DMProvider.
*
* @param pubkeys - Array of pubkeys to fetch profiles for
* @returns Query result with map of pubkey -> AuthorData
*/
export function useAuthorsBatch(pubkeys: string[]) {
return useAuthors(pubkeys);
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Re-exports DM hooks from the @samthomson/nostr-messaging package.
* Separated from DMProviderWrapper to avoid Fast Refresh warnings.
*/
export {
useDMContext,
useConversationMessages,
} from '@samthomson/nostr-messaging/core';
+1 -3
View File
@@ -1,8 +1,6 @@
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;
@@ -46,7 +44,7 @@ export function useUserStatus(pubkey: string | undefined): UserStatus & { isLoad
const content = event.content.trim();
if (!content) return { status: null, url: null };
const url = sanitizeUrl(event.tags.find(([n]) => n === 'r')?.[1]) ?? null;
const url = event.tags.find(([n]) => n === 'r')?.[1] ?? null;
return { status: content, url };
},
-164
View File
@@ -1,164 +0,0 @@
/**
* 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(() => {});
}
+3 -15
View File
@@ -11,17 +11,6 @@
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. */
@@ -44,10 +33,9 @@ function injectFontFace(family: string, url: string): void {
document.head.appendChild(style);
}
const safeFamily = sanitizeCssString(family);
const rule = `
@font-face {
font-family: "${safeFamily}";
font-family: "${family}";
src: url("${url}");
font-display: swap;
}`;
@@ -85,7 +73,7 @@ export function applyFontOverride(font: ThemeFont | undefined): void {
document.head.appendChild(style);
}
const cssFamily = sanitizeCssString(resolveCssFamily(font.family));
const cssFamily = resolveCssFamily(font.family);
style.textContent = `html { font-family: "${cssFamily}", ${DEFAULT_FONT_STACK} !important; }\n`;
}
@@ -145,7 +133,7 @@ export function applyTitleFontOverride(font: ThemeFont | undefined): void {
document.head.appendChild(style);
}
const cssFamily = sanitizeCssString(resolveCssFamily(font.family));
const cssFamily = resolveCssFamily(font.family);
style.textContent = `:root { --title-font-family: "${cssFamily}", ${DEFAULT_FONT_STACK}; }\n`;
}
+10
View File
@@ -0,0 +1,10 @@
import { DEFAULT_NEW_MESSAGE_SOUNDS, type NewMessageSoundOption } from '@samthomson/nostr-messaging/core';
export const APP_NEW_MESSAGE_SOUNDS: NewMessageSoundOption[] = [
...DEFAULT_NEW_MESSAGE_SOUNDS,
{
id: 'ditto',
label: 'Ditto',
url: '/custom-sounds/ditto.mp3',
},
];
+2 -7
View File
@@ -1,7 +1,5 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
/** Parsed NIP-58 badge definition data. */
export interface BadgeData {
identifier: string;
@@ -22,16 +20,13 @@ 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 = sanitizeUrl(imageTag?.[1]);
const image = 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]) {
const url = sanitizeUrl(tag[1]);
if (url) {
thumbs.push({ url, dimensions: tag[2] });
}
thumbs.push({ url: tag[1], dimensions: tag[2] });
}
}
-21
View File
@@ -1,21 +0,0 @@
/**
* 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;
}
+10
View File
@@ -245,6 +245,16 @@ export const AppConfigSchema = z.object({
})
).optional().default([]),
imageQuality: z.enum(['compressed', 'original']),
messaging: z.object({
enabled: z.boolean().optional(),
customSoundUrl: z.string().optional(),
discoveryRelays: z.array(z.string().url()).optional(),
relayMode: z.enum(['discovery', 'hybrid', 'strict_outbox']).optional(),
renderInlineMedia: z.boolean().optional(),
soundEnabled: z.boolean().optional(),
soundId: z.string().optional(),
devMode: z.boolean().optional(),
}).optional(),
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
sandboxDomain: z.string().optional(),
});
+8 -1
View File
@@ -15,7 +15,7 @@ import {
Earth,
Film,
HelpCircle,
Mail,
MessageSquare,
MessageSquareMore,
Mic,
@@ -110,6 +110,13 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
icon: Bell,
requiresAuth: true,
},
{
id: "dms",
label: "Chats",
path: "/chats",
icon: Mail,
requiresAuth: true,
},
{ id: "search", label: "Search", path: "/search", icon: Search },
{ id: "trends", label: "Trends", path: "/trends", icon: TrendingUp },
{
+2 -5
View File
@@ -1,7 +1,6 @@
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 ───────────────────────────────────────────────────
@@ -76,8 +75,7 @@ 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] };
const fontUrl = sanitizeUrl(tag[2]);
if (fontUrl) parsed.url = fontUrl;
if (tag[2]) parsed.url = tag[2];
if (role === 'title') {
if (!titleFont) titleFont = parsed;
@@ -118,8 +116,7 @@ function parseBackgroundTag(tags: string[][]): ThemeBackground | undefined {
kv.set(entry.slice(0, spaceIdx), entry.slice(spaceIdx + 1));
}
const rawUrl = kv.get('url');
const url = sanitizeUrl(rawUrl);
const url = kv.get('url');
if (!url) return undefined;
const bg: ThemeBackground = { url };
-3
View File
@@ -1017,9 +1017,6 @@ 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">
+38
View File
@@ -0,0 +1,38 @@
import { DMMessagingInterface } from '@samthomson/nostr-messaging/ui';
import { Link } from 'react-router-dom';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
export function MessagesPage() {
const { config } = useAppContext();
const messagingEnabled = config.messaging?.enabled ?? false;
// Hide the right sidebar and expand the main content area for messaging.
// noOverscroll: avoid pb-overscroll on the main column so this fixed-height layout doesn't get extra scroll.
useLayoutOptions({
rightSidebar: null,
noMaxWidth: true,
noOverscroll: true,
wrapperClassName: 'max-w-full',
});
return (
<div className="h-dvh flex flex-col">
{messagingEnabled ? (
<DMMessagingInterface />
) : (
<div className="flex-1 flex items-center justify-center p-6">
<div className="max-w-md text-center space-y-3">
<h2 className="text-xl font-semibold">Chats are turned off</h2>
<p className="text-sm text-muted-foreground">
Enable messaging in Settings to start using chats.
</p>
<Link to="/settings/messaging" className="inline-block text-sm text-primary hover:underline">
Open Messaging Settings
</Link>
</div>
</div>
)}
</div>
);
}
+453
View File
@@ -0,0 +1,453 @@
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/hooks/useAppContext';
import { useDMContext } from '@/hooks/useDMHooks';
import { RelayListManager } from '@/components/RelayListManager';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, AlertCircle, Play } from 'lucide-react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { getMediaCacheStats, type RelayMode } from '@samthomson/nostr-messaging/core';
import { IntroImage } from '@/components/IntroImage';
import { APP_NEW_MESSAGE_SOUNDS } from '@/lib/messagingSounds';
export default function MessagingSettings() {
const { config, updateConfig } = useAppContext();
const {
subscriptions,
messagingState,
isLoading: dmIsLoading,
clearCacheAndRefetch,
} = useDMContext();
const messaging = config.messaging ?? {};
const [mediaCacheStats, setMediaCacheStats] = useState<{ count: number; size: number } | null>(null);
useSeoMeta({
title: 'Messages | Settings | Ditto',
description: 'Configure your direct messaging settings.',
});
useEffect(() => {
getMediaCacheStats().then(setMediaCacheStats).catch(() => {
setMediaCacheStats({ count: 0, size: 0 });
});
}, []);
const preloadedSoundsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
useEffect(() => {
const map = new Map<string, HTMLAudioElement>();
APP_NEW_MESSAGE_SOUNDS.forEach((sound) => {
const audio = new Audio(sound.url);
audio.volume = 0.5;
audio.preload = 'auto';
map.set(sound.url, audio);
});
preloadedSoundsRef.current = map;
return () => {
map.clear();
};
}, []);
const relayMode = messaging.relayMode ?? 'hybrid';
const messagingEnabled = messaging.enabled ?? false;
const renderInlineMedia = messaging.renderInlineMedia ?? true;
const soundEnabled = messaging.soundEnabled ?? false;
const soundId = messaging.soundId ?? APP_NEW_MESSAGE_SOUNDS[0]?.id ?? '';
const devMode = messaging.devMode ?? false;
const handleMessagingEnabledChange = (checked: boolean) => {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, enabled: checked },
}));
};
const handleRelayModeChange = (mode: string) => {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, relayMode: mode as RelayMode },
}));
};
const handleRenderInlineMediaChange = (checked: boolean) => {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, renderInlineMedia: checked },
}));
};
const handleSoundIdChange = (id: string) => {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, soundEnabled: true, soundId: id },
}));
};
const handleDevModeChange = (checked: boolean) => {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, devMode: checked },
}));
};
const handlePlaySound = useCallback((soundUrl: string) => {
try {
const preloaded = preloadedSoundsRef.current.get(soundUrl);
if (preloaded) {
preloaded.currentTime = 0;
preloaded.play().catch(() => {});
} else {
const audio = new Audio(soundUrl);
audio.volume = 0.5;
audio.play().catch(() => {});
}
} catch {
// Ignore errors
}
}, []);
const handleClearCache = async () => {
if (confirm('This will clear all cached messages and re-fetch from relays. Continue?')) {
await clearCacheAndRefetch();
const stats = await getMediaCacheStats();
setMediaCacheStats(stats);
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
const conversationCount = messagingState ? Object.keys(messagingState.conversationMetadata).length : 0;
const totalMessages = messagingState
? Object.values(messagingState.conversationMessages).reduce((sum, msgs) => sum + msgs.length, 0)
: 0;
const lastSync = messagingState?.syncState?.lastCacheTime
? new Date(messagingState.syncState.lastCacheTime).toLocaleString()
: 'Never';
return (
<main className="">
<div className="px-4 pt-4 pb-3">
<div className="flex items-center gap-4">
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-xl font-bold">Messages</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Configure direct messaging settings, relays, and cache
</p>
</div>
</div>
</div>
<div className="p-4">
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
<IntroImage src="/messaging-intro.png" />
<div className="min-w-0">
<h2 className="text-sm font-semibold">Direct Messaging</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Manage your encrypted messaging settings and relay connections
</p>
</div>
</div>
<div className="space-y-6">
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>Messaging</CardTitle>
<CardDescription>
Enable or disable chats in Ditto
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="messaging-enabled">Enable Messaging</Label>
<p className="text-sm text-muted-foreground">
Turn chats on to use inbox, sync, and messaging features
</p>
</div>
<Switch
id="messaging-enabled"
checked={messagingEnabled}
onCheckedChange={handleMessagingEnabledChange}
/>
</div>
</CardContent>
</Card>
{!messagingEnabled && (
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">
Messaging is currently off. Enable it above to reveal relay, cache, and advanced chat settings.
</p>
</CardContent>
</Card>
)}
{messagingEnabled && (
<>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>General</CardTitle>
<CardDescription>
Configure how messages are displayed and notified
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="render-inline-media">Render Inline Media</Label>
<p className="text-sm text-muted-foreground">
Show images and videos directly in messages
</p>
</div>
<Switch
id="render-inline-media"
checked={renderInlineMedia}
onCheckedChange={handleRenderInlineMediaChange}
/>
</div>
<div className="border-t border-border/50 pt-6">
<div className="space-y-3">
<Label>Sound</Label>
<p className="text-sm text-muted-foreground mb-3">
Play a sound when a DM arrives
</p>
<RadioGroup
className="space-y-2"
value={soundEnabled ? soundId : 'none'}
onValueChange={(val) => {
if (val === 'none') {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, soundEnabled: false },
}));
} else {
handleSoundIdChange(val);
}
}}
>
<div className="flex min-h-9 items-center justify-between space-x-3">
<div className="flex items-center space-x-3">
<RadioGroupItem value="none" id="sound-none" />
<Label htmlFor="sound-none" className="font-normal cursor-pointer">
None
</Label>
</div>
</div>
{APP_NEW_MESSAGE_SOUNDS.map((sound) => (
<div
key={sound.id}
className="group flex min-h-9 items-center justify-between space-x-3"
>
<div className="flex items-center space-x-3">
<RadioGroupItem value={sound.id} id={`sound-${sound.id}`} />
<Label
htmlFor={`sound-${sound.id}`}
className="font-normal cursor-pointer"
>
{sound.label}
</Label>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handlePlaySound(sound.url)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<Play className="h-4 w-4" />
</Button>
</div>
))}
</RadioGroup>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>Relay Mode</CardTitle>
<CardDescription>
Control how relays are chosen for direct messages
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<Label>Connection Mode</Label>
<RadioGroup value={relayMode} onValueChange={handleRelayModeChange}>
<div className="flex items-start space-x-3 space-y-0">
<RadioGroupItem value="discovery" id="mode-discovery" />
<div className="space-y-1">
<Label htmlFor="mode-discovery" className="font-normal cursor-pointer">
Discovery Only
</Label>
<p className="text-sm text-muted-foreground">
Only relays from the discovery list; fastest, may miss messages
</p>
</div>
</div>
<div className="flex items-start space-x-3 space-y-0">
<RadioGroupItem value="hybrid" id="mode-hybrid" />
<div className="space-y-1">
<Label htmlFor="mode-hybrid" className="font-normal cursor-pointer">
Hybrid <Badge variant="secondary" className="ml-2">Recommended</Badge>
</Label>
<p className="text-sm text-muted-foreground">
Discovery relays + user inbox relays
</p>
</div>
</div>
<div className="flex items-start space-x-3 space-y-0">
<RadioGroupItem value="strict_outbox" id="mode-strict" />
<div className="space-y-1">
<Label htmlFor="mode-strict" className="font-normal cursor-pointer">
Strict Outbox
</Label>
<p className="text-sm text-muted-foreground">
Only each user's published inbox relays (NIP-65/NIP-17). More private, but not everyone publishes relay lists yet - you may miss DMs to or from people who don't.
</p>
</div>
</div>
</RadioGroup>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>Relays</CardTitle>
<CardDescription>
Discovery relays, NIP-65 inbox/outbox, and DM inbox
</CardDescription>
</CardHeader>
<CardContent>
<RelayListManager />
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>Cache & Storage</CardTitle>
<CardDescription>
View cache status and manage stored messages
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label>Connection Status</Label>
<div className="grid grid-cols-2 gap-3">
<div className="bg-secondary/20 p-3 rounded-lg">
<div className="text-sm font-medium mb-1">NIP-04 (Legacy)</div>
<Badge variant={subscriptions.isNIP4Connected ? 'default' : 'secondary'}>
{subscriptions.isNIP4Connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
<div className="bg-secondary/20 p-3 rounded-lg">
<div className="text-sm font-medium mb-1">NIP-17 (Private)</div>
<Badge variant={subscriptions.isNIP17Connected ? 'default' : 'secondary'}>
{subscriptions.isNIP17Connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
</div>
</div>
<div className="border-t border-border/50 pt-6">
<div className="space-y-3">
<Label>Cache Statistics</Label>
<div className="bg-secondary/20 p-4 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Conversations:</span>
<span className="font-medium">{conversationCount}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Messages:</span>
<span className="font-medium">{totalMessages}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Last Sync:</span>
<span className="font-medium">{lastSync}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Media Files Cached:</span>
<span className="font-medium">{mediaCacheStats?.count ?? '...'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Media Cache Size:</span>
<span className="font-medium">
{mediaCacheStats ? formatBytes(mediaCacheStats.size) : '...'}
</span>
</div>
</div>
</div>
</div>
<div className="border-t border-border/50 pt-6">
<Button
variant="destructive"
onClick={handleClearCache}
disabled={dmIsLoading}
className="w-full"
>
<RefreshCw className="h-4 w-4 mr-2" />
Clear Cache & Refetch
</Button>
<p className="text-sm text-muted-foreground mt-2">
This will clear all cached messages and re-fetch from relays. Use this if messages are missing or out of sync.
</p>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>Advanced</CardTitle>
<CardDescription>
Developer and debugging options
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-start justify-between">
<div className="space-y-0.5">
<Label htmlFor="dev-mode">Developer Mode</Label>
<p className="text-sm text-muted-foreground">
Show extra debug UI (seal payload, decryption details)
</p>
<div className="flex items-center gap-2 mt-2">
<AlertCircle className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Only enable if you need to debug message encryption
</span>
</div>
</div>
<Switch
id="dev-mode"
checked={devMode}
onCheckedChange={handleDevModeChange}
/>
</div>
</CardContent>
</Card>
</>
)}
</div>
</div>
</main>
);
}
+15 -18
View File
@@ -102,10 +102,8 @@ import { SubHeaderBar } from '@/components/SubHeaderBar';
import { useActiveTabIndicator } from '@/components/SubHeaderBarContext';
import { TabButton } from '@/components/TabButton';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import type { AddrCoords } from '@/hooks/useEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import type { AddrCoords } from '@/hooks/useEvent';
import type { FeedItem } from '@/lib/feedUtils';
import type { NostrEvent } from '@nostrify/nostrify';
import QRCode from 'qrcode';
@@ -671,8 +669,7 @@ function ProfileFieldInline({ field }: { field: { label: string; value: string }
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const isBtc = field.label === '$BTC';
const safeUrl = sanitizeUrl(field.value);
const isUrl = !!safeUrl;
const isUrl = field.value.startsWith('http://') || field.value.startsWith('https://');
const handleCopy = async () => {
await navigator.clipboard.writeText(field.value);
@@ -761,17 +758,17 @@ function ProfileFieldInline({ field }: { field: { label: string; value: string }
);
}
if (isUrl && safeUrl && isAudioUrl(safeUrl)) {
return <MiniAudioPlayer src={safeUrl} label={field.label || undefined} />;
if (isUrl && isAudioUrl(field.value)) {
return <MiniAudioPlayer src={field.value} label={field.label || undefined} />;
}
if (isUrl && safeUrl && isImageUrl(safeUrl)) {
if (isUrl && isImageUrl(field.value)) {
return (
<div className="min-w-0">
{field.label && <div className="text-sm text-muted-foreground mb-1">{field.label}</div>}
<a href={safeUrl} target="_blank" rel="noopener noreferrer" className="block">
<a href={field.value} target="_blank" rel="noopener noreferrer" className="block">
<img
src={safeUrl}
src={field.value}
alt={field.label || 'Profile image'}
className="w-full max-w-sm rounded-lg object-cover"
loading="lazy"
@@ -781,29 +778,29 @@ function ProfileFieldInline({ field }: { field: { label: string; value: string }
);
}
if (isUrl && safeUrl && isVideoUrl(safeUrl)) {
if (isUrl && isVideoUrl(field.value)) {
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={safeUrl} />
<VideoPlayer src={field.value} />
</div>
</div>
);
}
if (isUrl && safeUrl) {
if (isUrl) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<ExternalFavicon url={safeUrl} size={16} className="shrink-0" />
<ExternalFavicon url={field.value} size={16} className="shrink-0" />
<span className="text-sm text-muted-foreground shrink-0">{field.label}</span>
<a
href={safeUrl}
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline truncate"
>
{safeUrl.replace(/^https?:\/\//, '')}
{field.value.replace(/^https?:\/\//, '')}
</a>
</div>
);
@@ -2190,11 +2187,11 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
{metadata?.nip05 && (
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey ?? ''} className="text-sm text-muted-foreground" />
)}
{metadata?.website && sanitizeUrl(metadata.website.startsWith('http') ? metadata.website : `https://${metadata.website}`) && (
{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={sanitizeUrl(metadata.website.startsWith('http') ? metadata.website : `https://${metadata.website}`)}
href={metadata.website.startsWith('http') ? metadata.website : `https://${metadata.website}`}
target="_blank"
rel="noopener noreferrer"
className="truncate text-primary hover:underline"
+9 -22
View File
@@ -1,5 +1,5 @@
import { useSeoMeta } from '@unhead/react';
import { lazy, Suspense, useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef } from 'react';
import { ChevronRight, Settings } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader';
@@ -9,8 +9,6 @@ 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;
@@ -59,6 +57,14 @@ const settingsSections: SettingsSection[] = [
path: '/settings/notifications',
requiresAuth: true,
},
{
id: 'messaging',
label: 'Messages',
description: 'Direct messaging settings, relays, and cache',
illustration: '/messaging-intro.png',
path: '/settings/messaging',
requiresAuth: true,
},
{
id: 'advanced',
label: 'Advanced',
@@ -81,7 +87,6 @@ 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(() => {
@@ -167,24 +172,6 @@ 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" />
-25
View File
@@ -1,25 +0,0 @@
/**
* 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 -2
View File
@@ -8,6 +8,7 @@ export default {
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
"./node_modules/@samthomson/nostr-messaging/dist/**/*.js",
],
prefix: "",
theme: {
@@ -75,8 +76,7 @@ export default {
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
xs: 'calc(var(--radius) - 8px)'
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {