diff --git a/.claude/skills/ai-chat/SKILL.md b/.agents/skills/ai-chat/SKILL.md similarity index 100% rename from .claude/skills/ai-chat/SKILL.md rename to .agents/skills/ai-chat/SKILL.md diff --git a/.claude/skills/nostr-comments/SKILL.md b/.agents/skills/nostr-comments/SKILL.md similarity index 100% rename from .claude/skills/nostr-comments/SKILL.md rename to .agents/skills/nostr-comments/SKILL.md diff --git a/.claude/skills/nostr-direct-messages/SKILL.md b/.agents/skills/nostr-direct-messages/SKILL.md similarity index 100% rename from .claude/skills/nostr-direct-messages/SKILL.md rename to .agents/skills/nostr-direct-messages/SKILL.md diff --git a/.claude/skills/nostr-infinite-scroll/SKILL.md b/.agents/skills/nostr-infinite-scroll/SKILL.md similarity index 100% rename from .claude/skills/nostr-infinite-scroll/SKILL.md rename to .agents/skills/nostr-infinite-scroll/SKILL.md diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 00000000..fca7f03b --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,237 @@ +--- +name: release +description: Publish a new app release with versioning, changelog, native build files, and git tagging. Triggered by "publish a new release" or similar requests. +--- + +# Release Skill + +This skill guides you through publishing a new release of the app. It handles version bumping, changelog generation, native build file updates, and git tagging/pushing. + +## Overview + +- **Version format**: Marketing version (X.Y.Z), starting from 2.0.0. **This is NOT semver.** Version numbers are chosen based on how the release looks to end users, not based on API compatibility or breaking changes. Think of it like an app store version -- the number reflects the perceived significance of the update to a regular user. +- **Version source of truth**: `package.json` `version` field +- **Changelog**: `CHANGELOG.md` in repo root, using [Keep a Changelog](https://keepachangelog.com/) format +- **Version bumping**: + - **Patch (Z)**: Most releases. Bug fixes, tweaks, internal improvements, anything a user wouldn't specifically notice or seek out. + - **Minor (Y)**: Releases with headline features -- things worth announcing. A user should be able to look at the minor bump and think "oh, something new happened." + - **Major (X)**: Only when the user explicitly requests it (milestones, rebrands, major redesigns) +- **CI trigger**: Pushing a version tag (`v2.1.0`) triggers the CI pipeline to build APKs, create a GitLab release, and publish to Zapstore + +## Release Procedure + +Follow these steps in order. Do NOT skip any step. + +### Step 1: Required Reading + +Before writing any release notes, you MUST read these pages to understand the product context, voice, and values: + +1. **https://soapbox.pub/** -- Soapbox company overview and product suite +2. **https://soapbox.pub/ditto** -- Ditto product page with feature descriptions and positioning +3. **https://about.ditto.pub/** -- Ditto documentation landing page +4. **https://about.ditto.pub/philosophy** -- Ditto's design philosophy, core symbolism, and manifesto + +These pages define what Ditto is, how it's positioned, and the tone of voice to use. Changelog entries should reflect this identity: fun, rebellious, user-focused, emphasizing freedom and self-expression. Avoid dry technical jargon -- write for people who use the app, not developers. + +### Step 2: Pre-flight Checks + +```bash +# Ensure working directory is clean +git status + +# Ensure we're on main branch +git branch --show-current + +# Run the full test suite +npm run test +``` + +- If the working directory has uncommitted changes, ask the user whether to commit them first or abort. +- If not on `main`, warn the user and ask whether to proceed. +- If tests fail, stop and fix the issues before continuing. + +### Step 3: Determine What Changed + +```bash +# Get the current version from package.json +node -p "require('./package.json').version" + +# Get commits since the last version tag +git log v$(node -p "require('./package.json').version")..HEAD --oneline +``` + +- If there are no commits since the last tag, inform the user there is nothing to release and stop. +- Review the commit list to understand the scope of changes. + +### Step 4: Decide the Version Bump + +Analyze the commits from Step 3 and determine the appropriate bump level: + +| Bump | When to use | Example | +|------|-------------|---------| +| **Patch** | Bug fixes, minor tweaks, dependency updates, small UI polish, internal tooling, developer-facing pages, CI/build changes, settings/admin screens | 2.0.0 -> 2.0.1 | +| **Minor** | Significant new product features that change how users interact with the app -- the kind of thing you'd highlight in an app store update or announce on social media (e.g., new content type support, DM redesign, new social features, theme system overhaul) | 2.0.1 -> 2.1.0 | +| **Major** | ONLY when the user explicitly instructs a major bump | 2.1.0 -> 3.0.0 | + +**Default to patch** when in doubt. The bar for a minor bump is high -- ask yourself: "Would a regular user notice and care about this change?" If the answer is no, it's a patch. Internal pages (changelog, settings, about screens), infrastructure improvements, CI fixes, and developer tooling are always patch-level regardless of whether they technically add a new page or screen. + +When bumping minor, reset patch to 0 (e.g., 2.0.3 -> 2.1.0). +When bumping major, reset minor and patch to 0 (e.g., 2.3.1 -> 3.0.0). + +### Step 5: Write the Changelog Entry + +Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading. + +**Format:** + +```markdown +## [X.Y.Z] - YYYY-MM-DD + +### Added +- Description of new features + +### Changed +- Description of changes to existing features + +### Fixed +- Description of bug fixes + +### Removed +- Description of removed features +``` + +**Rules:** +- Only include categories that have entries (omit empty categories) +- Write **user-facing descriptions**, not raw commit messages +- Keep descriptions concise -- one line per change +- Group related commits into single entries where appropriate +- Use present tense ("Add dark mode toggle", not "Added dark mode toggle") +- Focus on what the user sees/experiences, not internal implementation details +- Use the current date in YYYY-MM-DD format +- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history. +- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers. +- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes. + +### Step 6: Update Version in All Files + +Update the version string in these files: + +#### 6a. `package.json` + +Update the `version` field: + +```json +"version": "X.Y.Z" +``` + +#### 6b. `android/app/build.gradle` + +Update `versionName` (line 17). Do NOT change `versionCode` -- that is managed by CI: + +```groovy +versionName "X.Y.Z" +``` + +#### 6c. `ios/App/App.xcodeproj/project.pbxproj` + +Update `MARKETING_VERSION` in all occurrences (Debug + Release configs): + +``` +MARKETING_VERSION = X.Y.Z; +``` + +**Important:** All lines containing `MARKETING_VERSION` must be updated to the same value. Use a replaceAll operation. + +Do NOT change `CURRENT_PROJECT_VERSION` -- it stays at `1` (may be managed separately for App Store submissions in the future). + +### Step 7: Copy Changelog to Public Directory + +The changelog is served at runtime by the app from the `public/` directory. After updating `CHANGELOG.md`, copy it: + +```bash +cp CHANGELOG.md public/CHANGELOG.md +``` + +### Step 8: Pull Latest Changes + +Before committing the release, pull the latest changes from the remote to ensure the release commit sits on top of the latest code. This **must** happen before committing and tagging. + +```bash +git pull origin main +``` + +**CRITICAL**: Always use `git pull` (merge), NEVER `git pull --rebase`. Rebasing rewrites commit hashes, which would orphan any tag pointing to the original commit. Since version tags are often protected on the remote and cannot be deleted or updated, a broken tag cannot be easily fixed. + +If there are merge conflicts with the pulled changes, resolve them before proceeding. + +### Step 9: Commit the Release + +```bash +git add package.json CHANGELOG.md public/CHANGELOG.md android/app/build.gradle ios/App/App.xcodeproj/project.pbxproj +git commit -m "release: vX.Y.Z" +``` + +### Step 10: Tag the Release + +```bash +git tag vX.Y.Z +``` + +The tag format is `v` followed by the semver version with no suffix. Examples: `v2.0.0`, `v2.1.0`, `v2.1.1`. + +### Step 11: Push + +```bash +git push origin main vX.Y.Z +``` + +**CRITICAL**: Push only the specific tag being released. NEVER use `--tags` -- that pushes ALL local tags, including stale or deleted ones. + +This triggers the GitLab CI pipeline which will: +1. Build a signed Android APK and AAB +2. Create a GitLab Release with download links +3. Publish the APK to Zapstore + +### Step 12: Confirm + +After pushing, inform the user: +- The new version number +- A brief summary of what was released +- That CI will handle building and publishing the artifacts + +## File Reference + +| File | What to update | Notes | +|------|---------------|-------| +| `package.json` | `version` field | Source of truth for the version | +| `CHANGELOG.md` | Prepend new section | User-facing changelog | +| `public/CHANGELOG.md` | Copy from `CHANGELOG.md` | Served at runtime by the app | +| `android/app/build.gradle` | `versionName` on line 17 | `versionCode` is managed by CI | +| `ios/App/App.xcodeproj/project.pbxproj` | `MARKETING_VERSION` (all occurrences) | `CURRENT_PROJECT_VERSION` stays at 1 | + +## CI Pipeline + +The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs three jobs: + +1. **build-apk**: Builds signed Android APK and AAB, stamps `versionName` and `versionCode` into the build +2. **release**: Creates a GitLab Release with the changelog content and download links +3. **publish-zapstore**: Publishes the APK to Zapstore + +## Troubleshooting + +### "Nothing to release" +If `git log` shows no commits since the last tag, there genuinely is nothing to release. + +### Tests fail +Fix the failing tests before proceeding. The release must not contain broken code. + +### Wrong version bumped +If you tagged the wrong version and haven't pushed yet: +```bash +git tag -d vX.Y.Z # delete the local tag +git reset --soft HEAD~1 # undo the commit but keep changes staged +``` +Then redo steps 4-10 with the correct version. + +### Already pushed a bad release +This requires manual intervention. Inform the user and suggest they delete the tag and release from GitLab manually, then re-run the release process. diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4438ad1d..3a4d99be 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -46,7 +46,7 @@ build-apk: timeout: 15 minutes needs: [] rules: - - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+-/ + - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ variables: ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_HOME: /opt/android-sdk @@ -97,8 +97,7 @@ build-apk: storeFile=my-upload-key.keystore EOF script: - # Extract version from git tag (e.g., v2026.03.16+974041a) - # versionName: full calver+sha string (e.g., 2026.03.16+974041a) + # Extract semver version from git tag (e.g., v2.1.0 -> 2.1.0) - TAG="${CI_COMMIT_TAG#v}" - VERSION_NAME="${TAG}" - VERSION_CODE="${CI_PIPELINE_IID}" @@ -152,21 +151,28 @@ release: needs: - build-apk rules: - - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+-/ + - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ + variables: + GLAB_TOKEN: $CI_JOB_TOKEN script: - echo "Creating release for $CI_COMMIT_TAG" - release: - tag_name: $CI_COMMIT_TAG - name: $CI_COMMIT_TAG - description: "Ditto $CI_COMMIT_TAG" - assets: - links: - - name: "Ditto-${CI_COMMIT_TAG}.apk" - url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.apk" - link_type: package - - name: "Ditto-${CI_COMMIT_TAG}.aab" - url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab" - link_type: package + # Extract the latest changelog section for the release description. + # Reads from "## [version]" to the next "## [" or end of file. + - | + VERSION="${CI_COMMIT_TAG#v}" + RELEASE_NOTES=$(awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md) + if [ -z "$RELEASE_NOTES" ]; then + RELEASE_NOTES="Ditto ${CI_COMMIT_TAG}" + fi + # Create the release with glab (included in the release-cli image). + # Uses a temp file to safely pass multi-line release notes. + - echo "$RELEASE_NOTES" > /tmp/release-notes.md + - | + glab release create "$CI_COMMIT_TAG" \ + --name "$CI_COMMIT_TAG" \ + --notes-file /tmp/release-notes.md \ + "artifacts/Ditto.apk#Ditto-${CI_COMMIT_TAG}.apk" \ + "artifacts/Ditto.aab#Ditto-${CI_COMMIT_TAG}.aab" publish-zapstore: stage: publish @@ -174,7 +180,7 @@ publish-zapstore: needs: - build-apk rules: - - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+-/ + - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ variables: SIGN_WITH: $ZAPSTORE_BUNKER_URL script: diff --git a/AGENTS.md b/AGENTS.md index b038a74a..7409667d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,6 +264,44 @@ When designing new event kinds, the `content` field should be used for semantica } ``` +### Implementing New Event Kinds in the UI + +When adding support for a new Nostr event kind to the application, the kind must be registered in **multiple locations** across the codebase. Missing any of these will cause the event to render incorrectly in certain views (e.g. showing blank content in quote posts, or "Kind 12345" as a label). + +#### Checklist for adding a new event kind + +1. **Content card component** (`src/components/`): Create a dedicated `` component that renders the event's tags/content appropriately. + +2. **Feed rendering** (`src/components/NoteCard.tsx`): + - Add a `const isMyKind = event.kind === XXXX;` detection flag + - Include it in the appropriate group flag (e.g. `isDevKind`) or add it to the `isTextNote` exclusion list + - Add the content dispatch: `isMyKind ? : ...` + - Add an entry to `KIND_HEADER_MAP` for the action header (e.g. "deployed an nsite") + - Import the new component and any new icons (e.g. `Globe` from lucide-react) + +3. **Detail page** (`src/pages/PostDetailPage.tsx`): + - Add the same `isMyKind` detection flag and include it in the group/exclusion flags (mirrors NoteCard) + - Add the content dispatch for the detail view + - Add an entry in `shellTitleForKind()` for the loading state title + - Import the new component + +4. **Feed registration** (`src/lib/extraKinds.ts`): + - Add the kind number to an existing feed definition's `extraFeedKinds` array, or create a new `ExtraKindDef` entry + +5. **Kind label registries** -- these are separate maps that resolve kind numbers to human-readable strings. All must be updated: + - `KIND_LABELS` and `KIND_ICONS` in `src/components/CommentContext.tsx` -- used for "Commenting on an nsite" text and inline icons + - `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` -- used in addressable event preview headers + - The icon fallback in `AddressableEventPreview` in the same file + +6. **Inline embeds / quote posts** -- events can be quoted inline via `nostr:nevent1...` or `nostr:naddr1...` URIs in note content. Both `EmbeddedNote` and `EmbeddedNaddr` render a compact card (author + title/content preview) for all kinds automatically — no per-kind registration needed. The same components are reused by CommentContext hover cards and the reply composer. + +7. **Reply composer** (`src/components/ReplyComposeModal.tsx`): + - The `EmbeddedPost` component delegates to the shared `EmbeddedNote`/`EmbeddedNaddr` components — no per-kind registration needed + +#### Why so many places? + +These are genuinely different UI contexts (feed cards, detail pages, inline embeds, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points. + ### NIP.md The file `NIP.md` is used by this project to define a custom Nostr protocol document. If the file doesn't exist, it means this project doesn't have any custom kinds associated with it. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..abf771b1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## [2.1.0] - 2026-03-26 + +### Added +- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal +- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly +- Stationery picker pulls from your color moments, followed users' themes, and built-in presets +- Freehand drawing canvas for creating one-of-a-kind sticker doodles +- Letters page added to the sidebar with a custom mailbox icon + +## [2.0.1] - 2026-03-26 + +### Added +- Tap the version number in settings to see what's new + +## [2.0.0] - 2026-03-26 + +Initial release of Ditto 2.0 -- a complete rewrite of Ditto. diff --git a/NIP.md b/NIP.md index d8e027be..62e4b5ab 100644 --- a/NIP.md +++ b/NIP.md @@ -7,6 +7,7 @@ | 36767 | Theme Definition | Shareable, named custom UI theme | | 16767 | Active Profile Theme | The user's currently active theme (one per user) | | 16769 | Profile Tabs | The user's custom profile page tabs (one per user) | +| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery | --- @@ -29,7 +30,8 @@ A theme consists of colors, optional fonts, and an optional background. Colors a ["c", "#1a1a2e", "background"], ["c", "#e0e0e0", "text"], ["c", "#6c3ce0", "primary"], - ["f", "Inter", "https://example.com/inter.woff2"], + ["f", "Inter", "https://example.com/inter.woff2", "body"], + ["f", "Playfair Display", "https://example.com/playfair.woff2", "title"], ["bg", "url https://example.com/bg.jpg", "mode cover", "m image/jpeg", "dim 1920x1080"], ["title", "MK Dark Theme"], ["alt", "Custom theme: MK Dark Theme"] @@ -74,7 +76,8 @@ Replaceable event that represents the user's currently active profile theme. Onl ["c", "#1a1a2e", "background"], ["c", "#e0e0e0", "text"], ["c", "#6c3ce0", "primary"], - ["f", "Inter", "https://example.com/inter.woff2"], + ["f", "Inter", "https://example.com/inter.woff2", "body"], + ["f", "Playfair Display", "https://example.com/playfair.woff2", "title"], ["bg", "url https://example.com/bg.jpg", "mode cover", "m image/jpeg"], ["title", "MK Dark Theme"], ["alt", "Active profile theme"] @@ -124,18 +127,30 @@ Format: `["c", "#rrggbb", ""]` ### Font Tag -Format: `["f", "", ""]` +Format: `["f", "", "", ""]` | Index | Required | Description | |-------|----------|-----------------------------------------------------------------------------------------------| | 0 | Yes | Tag name: `"f"` | | 1 | Yes | CSS `font-family` name (e.g. `"Inter"`) | | 2 | Yes | Direct URL to a font file (`.woff2`, `.ttf`, `.otf`) | +| 3 | Yes | Font role: `"body"` or `"title"` | + +**Roles:** + +| Role | Applies to | +|-----------|--------------------------------------------------| +| `"body"` | All text globally (body, headings, UI elements) | +| `"title"` | The user's profile display name | + +**Rules:** - The `f` tag is optional on the event. -- At most one `f` tag per event is allowed. -- The font applies globally to all text (body, headings, UI elements). +- At most one `f` tag per role is allowed (i.e. one body font and one title font). +- The `"body"` font tag MUST be ordered before the `"title"` font tag. This ensures backward-compatible clients that only read the first `f` tag will pick up the body font. - If the URL fails to load, the client SHOULD fall back to a default font gracefully. +- Clients that do not recognize a role SHOULD ignore that `f` tag. +- Legacy events with an `f` tag that has no role marker (only 3 elements) SHOULD be treated as `"body"`. - Variable font files (covering multiple weights in a single file) are preferred. ### Background Tag diff --git a/android/app/build.gradle b/android/app/build.gradle index a1d88ef3..646857c2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "pub.ditto.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 20260226 - versionName "2026.02.26" + versionCode 1 + versionName "2.1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 9b0498cc..0b6ea6ac 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -303,7 +303,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.1.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -325,7 +325,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/package-lock.json b/package-lock.json index 19d5b24a..f26be50f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mkstack", - "version": "0.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mkstack", - "version": "0.0.0", + "version": "2.1.0", "dependencies": { "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", @@ -19,6 +19,7 @@ "@emoji-mart/react": "^1.1.1", "@fontsource-variable/comfortaa": "^5.2.8", "@fontsource-variable/dm-sans": "^5.2.8", + "@fontsource-variable/fredoka": "^5.2.10", "@fontsource-variable/inter": "^5.2.6", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/lora": "^5.2.8", @@ -28,19 +29,23 @@ "@fontsource-variable/outfit": "^5.2.8", "@fontsource-variable/playfair-display": "^5.2.8", "@fontsource/bungee-shade": "^5.2.7", + "@fontsource/caveat": "^5.2.8", "@fontsource/cherry-bomb-one": "^5.2.7", "@fontsource/comic-neue": "^5.2.7", "@fontsource/comic-relief": "^5.2.2", "@fontsource/courier-prime": "^5.2.8", "@fontsource/creepster": "^5.2.7", "@fontsource/luckiest-guy": "^5.2.8", + "@fontsource/pacifico": "^5.2.7", "@fontsource/permanent-marker": "^5.2.7", + "@fontsource/pirata-one": "^5.2.8", "@fontsource/press-start-2p": "^5.2.7", "@fontsource/silkscreen": "^5.2.8", + "@fontsource/special-elite": "^5.2.8", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^5.2.2", - "@nostrify/nostrify": "^0.50.5", - "@nostrify/react": "^0.3.0", + "@nostrify/nostrify": "^0.51.0", + "@nostrify/react": "^0.3.1", "@nostrify/types": "^0.36.9", "@plausible-analytics/tracker": "^0.4.4", "@radix-ui/react-accordion": "^1.2.0", @@ -80,6 +85,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", + "dompurify": "^3.3.3", "embla-carousel-react": "^8.3.0", "emoji-mart": "^5.6.0", "fflate": "^0.8.2", @@ -119,6 +125,7 @@ "@tailwindcss/typography": "^0.5.15", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@types/dompurify": "^3.0.5", "@types/node": "^22.5.5", "@types/qrcode": "^1.5.5", "@types/react": "^18.3.1", @@ -618,15 +625,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -672,20 +679,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -709,9 +716,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -801,6 +808,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource-variable/fredoka": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource-variable/fredoka/-/fredoka-5.2.10.tgz", + "integrity": "sha512-W25a41EivYh8111C9x34ShRg+2kKGXnefg1Z9Zax5E0jOgOtGXn7UhoqVBvNLrJnWGv3hy8New0UiKDc/cYzPg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource-variable/inter": { "version": "5.2.6", "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.6.tgz", @@ -880,6 +896,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/caveat": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.2.8.tgz", + "integrity": "sha512-9fUUfFE2IQFKbx+xOcaeQxxmh8iJguEb8z+j1PeueO4UUx+XfT4pRm/B04ZDvFA794/iRxY/IibmP8ZKtIf4rw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/cherry-bomb-one": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@fontsource/cherry-bomb-one/-/cherry-bomb-one-5.2.7.tgz", @@ -929,6 +954,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/pacifico": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/pacifico/-/pacifico-5.2.7.tgz", + "integrity": "sha512-L+YiZn3Lb8rVN15zcLlti62doVxQeFpS+dJmulKkFrUo3dZIAxZLHwXoKaaHm+VVcOTrT/mmKCZqGOIWVmFV2w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/permanent-marker": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@fontsource/permanent-marker/-/permanent-marker-5.2.7.tgz", @@ -937,6 +971,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/pirata-one": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/pirata-one/-/pirata-one-5.2.8.tgz", + "integrity": "sha512-hl3Zqt6mgP+jstHnv2qYc2xQdYM1DSYrpSU9bpS1fYExoGFgJJNbXTPBQ9XivNk9RCKkibhou3iqoOZQOXM48w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/press-start-2p": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@fontsource/press-start-2p/-/press-start-2p-5.2.7.tgz", @@ -953,6 +996,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/special-elite": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/special-elite/-/special-elite-5.2.8.tgz", + "integrity": "sha512-tJRYZ6dZK3wiGvMS5NMOg6fqctWimNUUrYtLfPGJYqidaFeI2QqFUE0rbn16DjqID0DrnZSHYfqz1jGdctd5ew==", + "license": "Apache-2.0", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@getalby/lightning-tools": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz", @@ -1415,9 +1467,9 @@ } }, "node_modules/@nostrify/nostrify": { - "version": "0.50.5", - "resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.50.5.tgz", - "integrity": "sha512-0XlNz/aVKl3ODxAZYRbNsa9IzwLsQd0ACxq4EvPBCGZLvpSr3GW14GK13lX0FLx1UAy94xuvsbYGqkiVigBYnQ==", + "version": "0.51.0", + "resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.0.tgz", + "integrity": "sha512-GLka8FHu7o04kpz/NB69JppQy3rbwkadr8Au2fLmYbbB478kkGuthF+U5JS2qKaAI137n1p5BN1eFsCk2JyuXQ==", "dependencies": { "@nostrify/types": "0.36.9", "@scure/base": "^2.0.0", @@ -1456,11 +1508,11 @@ "license": "MIT" }, "node_modules/@nostrify/react": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.3.0.tgz", - "integrity": "sha512-Omoxiz3/u0Ab7uLo1Icx9FL8XDx8wiBpaPgdfHarxndwVg7LuAvTNLBVfKUj5n6iFFCZx3yorgg1jSBwi3k3NA==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.3.1.tgz", + "integrity": "sha512-AuB7mxK5vsczSqZzIfY/A+nJ9u4ycboiDYtXG2rRSI+4EPaj6BogmTiZp0gNz7vvIFSI/EP9ndR0j2leA1cyKA==", "dependencies": { - "@nostrify/nostrify": "0.50.5", + "@nostrify/nostrify": "0.51.0", "@nostrify/types": "0.36.9", "@tanstack/react-query": "^5.69.0", "nostr-tools": "^2.13.0", @@ -3153,9 +3205,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -3950,6 +4002,16 @@ "@types/ms": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4059,6 +4121,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4066,17 +4135,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/type-utils": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -4089,7 +4158,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.0", + "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -4105,16 +4174,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "engines": { @@ -4130,14 +4199,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.0", - "@typescript-eslint/types": "^8.56.0", + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", "debug": "^4.4.3" }, "engines": { @@ -4152,14 +4221,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4170,9 +4239,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", "dev": true, "license": "MIT", "engines": { @@ -4187,15 +4256,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -4212,9 +4281,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", "dev": true, "license": "MIT", "engines": { @@ -4226,18 +4295,18 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.0", - "@typescript-eslint/tsconfig-utils": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" @@ -4253,43 +4322,56 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0" + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4304,13 +4386,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5555,6 +5637,15 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.149", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz", @@ -5675,25 +5766,25 @@ } }, "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -5712,7 +5803,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -8174,9 +8265,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -9025,9 +9116,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9353,9 +9444,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", - "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -9726,9 +9817,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -9856,9 +9947,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -9908,16 +9999,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0" + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10086,9 +10177,9 @@ } }, "node_modules/unplugin/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -10880,9 +10971,9 @@ } }, "node_modules/vite-node/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -10968,9 +11059,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -11581,9 +11672,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -11896,15 +11987,18 @@ } }, "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index b638e2ac..d6b9c8a1 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,14 @@ { "name": "mkstack", "private": true, - "version": "0.0.0", + "version": "2.1.0", "type": "module", "scripts": { "dev": "npm i --silent && vite", "build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'", "test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'", "keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload", - "icons": "bash scripts/generate-icons.sh", - "release": "git tag \"v$(date +%Y.%m.%d)-$(git rev-parse --short HEAD)\" && git push origin --tags" + "icons": "bash scripts/generate-icons.sh" }, "engines": { "npm": "10.9.4", @@ -27,6 +26,7 @@ "@emoji-mart/react": "^1.1.1", "@fontsource-variable/comfortaa": "^5.2.8", "@fontsource-variable/dm-sans": "^5.2.8", + "@fontsource-variable/fredoka": "^5.2.10", "@fontsource-variable/inter": "^5.2.6", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/lora": "^5.2.8", @@ -36,19 +36,23 @@ "@fontsource-variable/outfit": "^5.2.8", "@fontsource-variable/playfair-display": "^5.2.8", "@fontsource/bungee-shade": "^5.2.7", + "@fontsource/caveat": "^5.2.8", "@fontsource/cherry-bomb-one": "^5.2.7", "@fontsource/comic-neue": "^5.2.7", "@fontsource/comic-relief": "^5.2.2", "@fontsource/courier-prime": "^5.2.8", "@fontsource/creepster": "^5.2.7", "@fontsource/luckiest-guy": "^5.2.8", + "@fontsource/pacifico": "^5.2.7", "@fontsource/permanent-marker": "^5.2.7", + "@fontsource/pirata-one": "^5.2.8", "@fontsource/press-start-2p": "^5.2.7", "@fontsource/silkscreen": "^5.2.8", + "@fontsource/special-elite": "^5.2.8", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^5.2.2", - "@nostrify/nostrify": "^0.50.5", - "@nostrify/react": "^0.3.0", + "@nostrify/nostrify": "^0.51.0", + "@nostrify/react": "^0.3.1", "@nostrify/types": "^0.36.9", "@plausible-analytics/tracker": "^0.4.4", "@radix-ui/react-accordion": "^1.2.0", @@ -88,6 +92,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", + "dompurify": "^3.3.3", "embla-carousel-react": "^8.3.0", "emoji-mart": "^5.6.0", "fflate": "^0.8.2", @@ -127,6 +132,7 @@ "@tailwindcss/typography": "^0.5.15", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@types/dompurify": "^3.0.5", "@types/node": "^22.5.5", "@types/qrcode": "^1.5.5", "@types/react": "^18.3.1", diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md new file mode 100644 index 00000000..abf771b1 --- /dev/null +++ b/public/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## [2.1.0] - 2026-03-26 + +### Added +- Letters -- a Wii Mail-inspired inbox for sending decorated letters to friends, complete with custom stationery, hand-drawn stickers, emoji stickers, fonts, and a send animation with envelope and wax seal +- Attach a color moment or theme to your letter as a gift -- recipients can tap to apply it instantly +- Stationery picker pulls from your color moments, followed users' themes, and built-in presets +- Freehand drawing canvas for creating one-of-a-kind sticker doodles +- Letters page added to the sidebar with a custom mailbox icon + +## [2.0.1] - 2026-03-26 + +### Added +- Tap the version number in settings to see what's new + +## [2.0.0] - 2026-03-26 + +Initial release of Ditto 2.0 -- a complete rewrite of Ditto. diff --git a/src/App.tsx b/src/App.tsx index 36bd6633..bc713d9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -103,8 +103,8 @@ const hardcodedConfig: AppConfig = { showPodcasts: true, feedIncludePodcastEpisodes: true, feedIncludePodcastTrailers: true, - showDevelopment: false, - feedIncludeDevelopment: false, + showDevelopment: true, + feedIncludeDevelopment: true, showBadges: true, showBadgeDefinitions: true, showProfileBadges: true, @@ -116,7 +116,6 @@ const hardcodedConfig: AppConfig = { sidebarOrder: [ "feed", "notifications", - "messages", "search", "bookmarks", "profile", diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 52dce889..2e9d4a45 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -31,7 +31,6 @@ import { HomePage } from "./pages/HomePage"; import Index from "./pages/Index"; import { KindFeedPage } from "./pages/KindFeedPage"; import { MagicSettingsPage } from "./pages/MagicSettingsPage"; -import Messages from "./pages/Messages"; import { MusicFeedPage } from "./pages/MusicFeedPage"; import { NetworkSettingsPage } from "./pages/NetworkSettingsPage"; import { NIP19Page } from "./pages/NIP19Page"; @@ -57,7 +56,10 @@ import { WebxdcFeedPage } from "./pages/WebxdcFeedPage"; import { WorldPage } from "./pages/WorldPage"; import { ArchivePage } from "./pages/ArchivePage"; import { BlueskyPage } from "./pages/BlueskyPage"; +import { ChangelogPage } from "./pages/ChangelogPage"; import { WikipediaPage } from "./pages/WikipediaPage"; +import { LettersPage } from "./pages/LettersPage"; +import { LetterPreferencesPage } from "./pages/LetterPreferencesPage"; const pollsDef = getExtraKindDef("polls")!; const colorsDef = getExtraKindDef("colors")!; @@ -108,7 +110,6 @@ export function AppRouter() { } /> } /> } /> - } /> } /> } /> } /> @@ -219,9 +220,12 @@ export function AppRouter() { } /> } /> } /> + } /> + } /> } /> } /> } /> + } /> } /> { if (fontFamily) { @@ -167,6 +169,15 @@ function useApplyFonts(theme: Theme, customTheme: ThemeConfig | undefined, theme loadAndApplyFont(undefined); } }, [theme, fontFamily, fontUrl]); + + useEffect(() => { + if (titleFontFamily) { + loadAndApplyTitleFont({ family: titleFontFamily, url: titleFontUrl }); + } else { + // Clear any custom title font overrides when no title font is configured + loadAndApplyTitleFont(undefined); + } + }, [theme, titleFontFamily, titleFontUrl]); } /** Style element ID for background image CSS. */ diff --git a/src/components/ArticleContent.tsx b/src/components/ArticleContent.tsx index b39e0ba4..13cbc920 100644 --- a/src/components/ArticleContent.tsx +++ b/src/components/ArticleContent.tsx @@ -58,7 +58,7 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp className="w-full rounded-xl object-cover max-h-96 mb-6" /> )} -
+
{event.content} diff --git a/src/components/BadgeDetailContent.tsx b/src/components/BadgeDetailContent.tsx index 4d4ee1ab..feb0566e 100644 --- a/src/components/BadgeDetailContent.tsx +++ b/src/components/BadgeDetailContent.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useCallback } from 'react'; +import { useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import { Award, Copy, Check, Users, Gift, Loader2, MessageCircle, Newspaper, MoreHorizontal } from 'lucide-react'; import { nip19 } from 'nostr-tools'; @@ -33,6 +33,7 @@ import { genUserName } from '@/lib/genUserName'; import { formatNumber } from '@/lib/formatNumber'; import { VerifiedNip05Text } from '@/components/Nip05Badge'; import { parseBadgeDefinition } from '@/components/BadgeContent'; +import { useCardTilt } from '@/hooks/useCardTilt'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { AwardBadgeDialog } from '@/components/AwardBadgeDialog'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; @@ -146,40 +147,9 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) { return (
- {/* Hero badge image */} + {/* Hero badge image with 3D tilt */} {heroImage ? ( -
- {/* Rotating light rays */} - + ) : (
@@ -575,6 +545,122 @@ function AwardeeCardSkeleton() { ); } +/** + * Badge hero with interactive 3D tilt. Hovering moves the badge in + * perspective space while a specular glare overlay tracks the pointer, + * making the badge feel like a tangible, glossy object. + */ +/** Extra padding (px) around the badge that expands the pointer hit-area. */ +const INTERACT_PAD = 80; + +function BadgeHero({ heroImage, badgeName }: { heroImage: string; badgeName: string }) { + const tilt = useCardTilt(30, 1.06); + const glareRef = useRef(null); + + // Mask string that clips overlays to the badge image's visible pixels. + // This ensures glare and edge effects don't paint over transparent areas. + const imageMask: React.CSSProperties = { + maskImage: `url(${heroImage})`, + WebkitMaskImage: `url(${heroImage})`, + maskSize: 'cover', + WebkitMaskSize: 'cover', + maskRepeat: 'no-repeat', + WebkitMaskRepeat: 'no-repeat', + maskPosition: 'center', + WebkitMaskPosition: 'center', + }; + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + tilt.onPointerMove(e); + + // Move the specular glare to follow the cursor. + // Coordinates are mapped to the image area (inset by INTERACT_PAD). + const el = tilt.ref.current; + const glare = glareRef.current; + if (!el || !glare) return; + const rect = el.getBoundingClientRect(); + const x = ((e.clientX - rect.left - INTERACT_PAD) / (rect.width - INTERACT_PAD * 2)) * 100; + const y = ((e.clientY - rect.top - INTERACT_PAD) / (rect.height - INTERACT_PAD * 2)) * 100; + glare.style.background = `radial-gradient(circle at ${x}% ${y}%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0.08) 35%, transparent 65%)`; + glare.style.opacity = '1'; + }, + [tilt], + ); + + const handlePointerLeave = useCallback( + () => { + tilt.onPointerLeave(); + const glare = glareRef.current; + if (glare) glare.style.opacity = '0'; + }, + [tilt], + ); + + return ( +
+ {/* Rotating light rays (behind tilt container) */} + - {/* Full markdown content -- detail view only, outside card */} - {!preview && event.content && ( -
-
- - {event.content} - -
+ {/* Full markdown content -- detail view only, outside card */} + {!preview && event.content && ( +
+
+ + {event.content} +
- )} +
+ )}
); } diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index ee1d2cd4..9802f989 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -1,27 +1,27 @@ -import { useMemo } from 'react'; +import { type ReactNode, useMemo } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; -import { Award, MessageSquareOff } from 'lucide-react'; +import { Award, Image, MessageSquareOff } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Skeleton } from '@/components/ui/skeleton'; import { EmojifiedText } from '@/components/CustomEmoji'; -import { NoteCard } from '@/components/NoteCard'; +import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { parseBadgeDefinition } from '@/components/BadgeContent'; import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; +import { getKindLabel, getKindIcon } from '@/lib/extraKinds'; import type { NostrEvent } from '@nostrify/nostrify'; -/** Kinds that render as a full NoteCard instead of a generic embed. */ -const NOTECARD_KINDS = new Set([30000, 39089]); - interface EmbeddedNaddrProps { /** The decoded naddr coordinates. */ addr: AddrCoords; className?: string; + /** When true, ProfileHoverCards inside the card are disabled to prevent nested hover cards. */ + disableHoverCards?: boolean; } /** Maximum characters of content to show in the embedded preview. */ @@ -61,7 +61,7 @@ function extractMetadata(event: NostrEvent): { } /** Inline embedded card for an addressable Nostr event (naddr). */ -export function EmbeddedNaddr({ addr, className }: EmbeddedNaddrProps) { +export function EmbeddedNaddr({ addr, className, disableHoverCards }: EmbeddedNaddrProps) { const { data: event, isLoading, isError } = useAddrEvent(addr); if (isLoading) { @@ -72,21 +72,12 @@ export function EmbeddedNaddr({ addr, className }: EmbeddedNaddrProps) { return ; } - // For follow packs / starter packs, render the same NoteCard used in feeds (without actions) - if (NOTECARD_KINDS.has(event.kind)) { - return ( -
e.stopPropagation()}> - -
- ); - } - // Badge definitions get a compact showcase instead of a link-preview card if (event.kind === 30009) { return ; } - return ; + return ; } /** Compact badge showcase for kind 30009 embeds — smaller version of the feed BadgeContent. */ @@ -182,7 +173,7 @@ function EmbeddedBadgeCard({ event, className }: { event: NostrEvent; className? ); } -function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className?: string }) { +function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) { const navigate = useNavigate(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; @@ -203,6 +194,13 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className? return description.slice(0, MAX_CONTENT_LENGTH).trimEnd() + '…'; }, [description]); + // Kind label for context (e.g. "nsite" with icon) + const kindMeta = useMemo(() => { + const label = getKindLabel(event.kind); + if (!label) return undefined; + return { label, Icon: getKindIcon(event.kind) }; + }, [event.kind]); + return (
- {/* Image */} - {image && ( -
- { - (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; - }} - /> -
- )} - {/* Text content */}
{/* Author row */} @@ -250,28 +233,32 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className? ) : ( <> - e.stopPropagation()} - > - - - - {displayName[0]?.toUpperCase()} - - - + + e.stopPropagation()} + > + + + + {displayName[0]?.toUpperCase()} + + + + - e.stopPropagation()} - > - {author.data?.event ? ( - {displayName} - ) : displayName} - + + e.stopPropagation()} + > + {author.data?.event ? ( + {displayName} + ) : displayName} + + )} @@ -294,11 +281,38 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className?

)} + {/* Kind label and attachment indicators */} +
+ {kindMeta && ( + + {kindMeta.Icon && } + {kindMeta.label} + + )} + {image && ( + + + Image + + )} +
); } +/** Conditionally wraps children in a ProfileHoverCard. When disabled, renders children directly. */ +function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) { + if (disabled) { + return <>{children}; + } + return ( + + {children} + + ); +} + /** Tombstone shown when an addressable event could not be loaded. */ function EmbeddedNaddrTombstone({ addr, className }: { addr: AddrCoords; className?: string }) { const navigate = useNavigate(); @@ -344,7 +358,6 @@ function EmbeddedNaddrTombstone({ addr, className }: { addr: AddrCoords; classNa function EmbeddedNaddrSkeleton({ className }: { className?: string }) { return (
-
diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index 8328627c..e16b7b29 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -6,9 +6,9 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Skeleton } from '@/components/ui/skeleton'; import { EmojifiedText } from '@/components/CustomEmoji'; -import { NoteCard } from '@/components/NoteCard'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { VanishCardCompact } from '@/components/VanishEventContent'; +import { EncryptedMessageCompact } from '@/components/EncryptedMessageContent'; import { useEvent } from '@/hooks/useEvent'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; @@ -17,13 +17,11 @@ import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import { useAppContext } from '@/hooks/useAppContext'; import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_REGEX, extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls'; +import { getKindLabel, getKindIcon } from '@/lib/extraKinds'; /** NIP-62 Request to Vanish. */ const VANISH_KIND = 62; -/** Kinds that render as a full NoteCard instead of the generic embed card. */ -const NOTECARD_KINDS = new Set([30000, 39089]); - /** Bech32 charset used by NIP-19 identifiers. */ const B32 = '023456789acdefghjklmnpqrstuvwxyz'; @@ -95,20 +93,16 @@ export function EmbeddedNote({ eventId, relays, authorHint, className, disableHo return ; } - // For follow packs / lists, render the same rich NoteCard used in feeds - if (NOTECARD_KINDS.has(event.kind)) { - return ( -
e.stopPropagation()}> - -
- ); - } - // NIP-62 vanish events get their own dramatic inline card if (event.kind === VANISH_KIND) { return ; } + // Kind 4 encrypted DMs get a compact card instead of rendering ciphertext + if (event.kind === 4) { + return ; + } + return ; } @@ -118,7 +112,7 @@ function EmbeddedNoteCard({ className, disableHoverCards, }: { - event: { id: string; pubkey: string; content: string; created_at: number; tags: string[][] }; + event: { id: string; kind: number; pubkey: string; content: string; created_at: number; tags: string[][] }; className?: string; disableHoverCards?: boolean; }) { @@ -149,10 +143,20 @@ function EmbeddedNoteCard({ return cleaned.slice(0, MAX_CONTENT_LENGTH).trimEnd() + '…'; }, [event.content]); - // Extract first image for a small thumbnail - const firstImage = useMemo(() => { - return event.content.match(IMAGE_URL_REGEX)?.[0] ?? null; - }, [event.content]); + // For non-text kinds with empty content, extract title/description from tags + const tagMeta = useMemo(() => { + if (truncatedContent) return undefined; + const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; + const title = getTag('title') || getTag('name') || getTag('d'); + const description = getTag('summary') || getTag('description'); + + // Build a kind label line for context (e.g. "nsite") + const kindLabel = getKindLabel(event.kind); + const KindIcon = getKindIcon(event.kind); + + if (!title && !description && !kindLabel) return undefined; + return { title, description, kindLabel, KindIcon }; + }, [truncatedContent, event.tags, event.kind]); // Detect stripped attachments to show indicator chips const attachments = useMemo(() => { @@ -195,21 +199,6 @@ function EmbeddedNoteCard({ } }} > - {/* Optional image thumbnail — skip when content-warning is blurred */} - {firstImage && !(hasCW && config.contentWarningPolicy === 'blur') && ( -
- { - (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; - }} - /> -
- )} - {/* Note content */}
{/* Author row */} @@ -255,22 +244,37 @@ function EmbeddedNoteCard({
- {/* Content warning notice or text preview */} + {/* Content warning notice or text preview or tag-based metadata */} {hasCW && config.contentWarningPolicy === 'blur' ? (

Content warning{cwTag?.[1] ? <>{' '}“{cwTag[1]}” : ''}

) : truncatedContent ? ( + ) : tagMeta ? ( + <> + {tagMeta.title && ( +

{tagMeta.title}

+ )} + {tagMeta.description && ( +

{tagMeta.description}

+ )} + {tagMeta.kindLabel && ( +

+ {tagMeta.KindIcon && } + {tagMeta.kindLabel} +

+ )} + ) : null} {/* Attachment indicators for stripped media/links */} - {!hasCW && (attachments.imgs > (firstImage ? 1 : 0) || attachments.vids > 0 || attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0) && ( + {!hasCW && (attachments.imgs > 0 || attachments.vids > 0 || attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0) && (
- {attachments.imgs > (firstImage ? 1 : 0) && ( + {attachments.imgs > 0 && ( - {attachments.imgs > 1 ? `${attachments.imgs} images` : '1 image'} + {attachments.imgs > 1 ? `${attachments.imgs} images` : 'Image'} )} {attachments.vids > 0 && ( diff --git a/src/components/EncryptedMessageContent.tsx b/src/components/EncryptedMessageContent.tsx new file mode 100644 index 00000000..eb916469 --- /dev/null +++ b/src/components/EncryptedMessageContent.tsx @@ -0,0 +1,220 @@ +import type { NostrEvent } from '@nostrify/nostrify'; +import { useMemo } from 'react'; +import { Mail } from 'lucide-react'; +import { Link, useNavigate } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { ProfileHoverCard } from '@/components/ProfileHoverCard'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useProfileUrl } from '@/hooks/useProfileUrl'; +import { getAvatarShape } from '@/lib/avatarShape'; +import { getDisplayName } from '@/lib/getDisplayName'; +import { genUserName } from '@/lib/genUserName'; +import { Skeleton } from '@/components/ui/skeleton'; +import { timeAgo } from '@/lib/timeAgo'; +import { cn } from '@/lib/utils'; + +interface EncryptedMessageContentProps { + event: NostrEvent; + /** Whether to use the compact card layout (feed) vs expanded (detail page). */ + compact?: boolean; + className?: string; +} + +/** Renders a single participant avatar with name and link. */ +function Participant({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const metadata = author.data?.metadata; + const avatarShape = getAvatarShape(metadata); + const displayName = getDisplayName(metadata, pubkey); + const profileUrl = useProfileUrl(pubkey, metadata); + + if (author.isLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+ + e.stopPropagation()}> + + + + {displayName[0]?.toUpperCase()} + + + + + + e.stopPropagation()} + className="text-xs font-medium text-foreground/80 hover:text-foreground truncate max-w-[80px] transition-colors" + > + {displayName} + + +
+ ); +} + +/** + * Visual display for kind 4 (NIP-04 encrypted DM) events. + * + * Instead of rendering the encrypted ciphertext, it shows a + * "mail in transit" visualization: sender avatar -> dashed path with mail icon -> recipient avatar. + */ +export function EncryptedMessageContent({ event, compact: _compact, className }: EncryptedMessageContentProps) { + const recipientPubkey = event.tags.find(([n]) => n === 'p')?.[1]; + const senderAuthor = useAuthor(event.pubkey); + const senderName = getDisplayName(senderAuthor.data?.metadata, event.pubkey); + + if (!recipientPubkey) { + return ( +
+

Encrypted message (no recipient found)

+
+ ); + } + + return ( +
+
+ {/* Description */} +

+ {senderName} sent a direct message +

+ +
+ {/* Sender */} + + + {/* Transit path */} +
+ {/* Dotted line */} +
+ + {/* Mail icon in the center */} +
+
+ +
+
+
+ + {/* Recipient */} + +
+
+
+ ); +} + +interface EncryptedMessageCompactProps { + event: { id: string; kind: number; pubkey: string; content: string; created_at: number; tags: string[][] }; + className?: string; +} + +/** + * Compact inline card for kind 4 events used in quote posts, reply indicators, + * and the reply composer. Matches the style of EmbeddedNoteCard. + */ +export function EncryptedMessageCompact({ event, className }: EncryptedMessageCompactProps) { + const navigate = useNavigate(); + const author = useAuthor(event.pubkey); + const metadata = author.data?.metadata; + const avatarShape = getAvatarShape(metadata); + const displayName = metadata?.name || genUserName(event.pubkey); + const recipientPubkey = event.tags.find(([n]) => n === 'p')?.[1]; + const recipientAuthor = useAuthor(recipientPubkey ?? ''); + const recipientName = recipientPubkey + ? getDisplayName(recipientAuthor.data?.metadata, recipientPubkey) + : undefined; + + const neventId = useMemo( + () => nip19.neventEncode({ id: event.id, author: event.pubkey }), + [event.id, event.pubkey], + ); + + const profileUrl = useProfileUrl(event.pubkey, metadata); + + return ( +
{ + e.stopPropagation(); + navigate(`/${neventId}`); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + navigate(`/${neventId}`); + } + }} + > +
+ {/* Author row */} +
+ {author.isLoading ? ( + <> + + + + ) : ( + <> + + e.stopPropagation()} + > + + + + {displayName[0]?.toUpperCase()} + + + + + + + e.stopPropagation()} + > + {displayName} + + + + )} + + + · {timeAgo(event.created_at)} + +
+ + {/* Content line */} +

+ + + Sent a direct message{recipientName ? <> to {recipientName} : ''} + +

+
+
+ ); +} diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 4de6b997..193f3e8b 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -1084,6 +1084,8 @@ function hasVideo(tags: string[][]): boolean { const WELL_KNOWN_KIND_LABELS: Record = { 32267: 'App', 30063: 'Release', + 15128: 'Nsite', + 35128: 'Nsite', }; export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) { @@ -1107,6 +1109,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey if (kindDef?.id) return CONTENT_KIND_ICONS[kindDef.id] ?? FileText; // Fallback icons for well-known kinds not in EXTRA_KINDS if (addr.kind === 32267 || addr.kind === 30063) return Package; + if (addr.kind === 15128 || addr.kind === 35128) return Globe; return FileText; }, [kindDef, addr.kind]); diff --git a/src/components/ExternalContentSidebarItem.tsx b/src/components/ExternalContentSidebarItem.tsx index 81d921dd..e5f053b0 100644 --- a/src/components/ExternalContentSidebarItem.tsx +++ b/src/components/ExternalContentSidebarItem.tsx @@ -102,7 +102,7 @@ export function ExternalContentSidebarItem({ - + diff --git a/src/components/FabButton.tsx b/src/components/FabButton.tsx new file mode 100644 index 00000000..772cd6b7 --- /dev/null +++ b/src/components/FabButton.tsx @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { getAvatarShape, getEmojiMaskUrl } from '@/lib/avatarShape'; + +interface FabButtonProps { + onClick: () => void; + icon: React.ReactNode; + disabled?: boolean; + className?: string; + title?: string; +} + +/** + * Reusable FAB that inherits the current user's avatar shape (emoji mask or + * circle fallback), matching the FloatingComposeButton style exactly. + */ +export function FabButton({ onClick, icon, disabled, className = '', title }: FabButtonProps) { + const { metadata } = useCurrentUser(); + const avatarShape = getAvatarShape(metadata); + + const shapeMaskStyle = useMemo(() => { + if (!avatarShape) return undefined; + const maskUrl = getEmojiMaskUrl(avatarShape); + if (!maskUrl) return undefined; + return { + WebkitMaskImage: `url(${maskUrl})`, + maskImage: `url(${maskUrl})`, + WebkitMaskSize: 'contain', + maskSize: 'contain' as string, + WebkitMaskRepeat: 'no-repeat', + maskRepeat: 'no-repeat' as string, + WebkitMaskPosition: 'center', + maskPosition: 'center' as string, + }; + }, [avatarShape]); + + return ( + + ); +} diff --git a/src/components/FloatingComposeButton.tsx b/src/components/FloatingComposeButton.tsx index 8fe6d932..47059bd1 100644 --- a/src/components/FloatingComposeButton.tsx +++ b/src/components/FloatingComposeButton.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { Plus, Construction } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; @@ -9,7 +9,7 @@ import { } from '@/components/ui/dialog'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { getAvatarShape, getEmojiMaskUrl } from '@/lib/avatarShape'; +import { FabButton } from '@/components/FabButton'; @@ -25,30 +25,11 @@ interface FloatingComposeButtonProps { } export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: FloatingComposeButtonProps) { - const { user, metadata, isLoading } = useCurrentUser(); + const { user, isLoading } = useCurrentUser(); const navigate = useNavigate(); const [composeOpen, setComposeOpen] = useState(false); const [comingSoonOpen, setComingSoonOpen] = useState(false); - const avatarShape = getAvatarShape(metadata); - - /** When the user has a custom emoji shape, use it as the FAB mask instead of a circle. */ - const shapeMaskStyle = useMemo(() => { - if (!avatarShape) return undefined; - const maskUrl = getEmojiMaskUrl(avatarShape); - if (!maskUrl) return undefined; - return { - WebkitMaskImage: `url(${maskUrl})`, - maskImage: `url(${maskUrl})`, - WebkitMaskSize: 'contain', - maskSize: 'contain' as string, - WebkitMaskRepeat: 'no-repeat', - maskRepeat: 'no-repeat' as string, - WebkitMaskPosition: 'center', - maskPosition: 'center' as string, - }; - }, [avatarShape]); - // Hide until user metadata is resolved so the shape mask is immediately // correct — avoids a brief flash of the default circle fallback. if (!user || isLoading) { @@ -69,22 +50,10 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: Floa return ( <> - + icon={icon ?? } + /> {/* Kind 1: Compose modal */} {kind === 1 && ( diff --git a/src/components/FontPicker.tsx b/src/components/FontPicker.tsx index 719a26eb..44972286 100644 --- a/src/components/FontPicker.tsx +++ b/src/components/FontPicker.tsx @@ -66,19 +66,23 @@ function familyFromFilename(filename: string): string { } /** - * Font picker component for selecting a single custom font. + * Bare font picker combobox — no section header, no preview text. * * Supports two modes: - * - **Uncontrolled** (default): reads/writes via `useTheme().applyCustomTheme()` + * - **Uncontrolled** (default): reads/writes the body font via `useTheme().applyCustomTheme()` * - **Controlled**: pass `value` and `onChange` props to manage state externally * * Also supports uploading a custom font file via Blossom. */ -export function FontPicker({ value, onChange }: { +export function FontPicker({ value, onChange, placeholder = 'Default (Inter)', placeholderFont }: { /** Controlled value — overrides useTheme() when provided. */ value?: ThemeFont | undefined; /** Controlled onChange — called instead of applyCustomTheme() when provided. */ onChange?: (font: ThemeFont | undefined) => void; + /** Text shown when no font is selected. Defaults to "Default (Inter)". */ + placeholder?: string; + /** Font to render the placeholder text in (when no value is selected). */ + placeholderFont?: ThemeFont | undefined; } = {}) { const { theme, customTheme, applyCustomTheme } = useTheme(); const { user } = useCurrentUser(); @@ -169,12 +173,7 @@ export function FontPicker({ value, onChange }: { }; return ( -
- - - Font - - + <> -
- )} +
{/* Action buttons */} - {(hasShakespeare || isApp || webUrls[0] || cloneUrls[0]) && ( + {(hasShakespeare || isApp || webUrls[0]) && (
{hasShakespeare && ( - ) : !hasShakespeare && cloneUrls[0] ? ( - ) : null}
)} diff --git a/src/components/InteractionsModal.tsx b/src/components/InteractionsModal.tsx index 0f96272f..3bc22d14 100644 --- a/src/components/InteractionsModal.tsx +++ b/src/components/InteractionsModal.tsx @@ -132,7 +132,7 @@ function RepostsTab({ reposts }: { reposts: RepostEntry[] }) { return (
{reposts.map((repost, i) => ( - + ))}
); @@ -231,6 +231,44 @@ function ZapsTab({ zaps }: { zaps: ZapEntry[] }) { /* ──── Shared Row Components ──── */ +function RepostRow({ entry }: { entry: RepostEntry }) { + const author = useAuthor(entry.pubkey); + const metadata = author.data?.metadata; + const avatarShape = getAvatarShape(metadata); + const displayName = metadata?.name || genUserName(entry.pubkey); + const nevent = useMemo(() => nip19.neventEncode({ id: entry.eventId, author: entry.pubkey }), [entry.eventId, entry.pubkey]); + + return ( + + + + + {displayName[0].toUpperCase()} + + + +
+
+ + {author.data?.event ? ( + {displayName} + ) : displayName} + + {metadata?.nip05 && ( + + )} +
+ {timeAgo(entry.createdAt)} +
+ + + + ); +} + function ReactionRow({ entry }: { entry: ReactionEntry }) { const author = useAuthor(entry.pubkey); const metadata = author.data?.metadata; @@ -269,43 +307,6 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) { ); } -function UserRow({ pubkey, subtitle }: { pubkey: string; subtitle?: string }) { - const author = useAuthor(pubkey); - const metadata = author.data?.metadata; - const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(pubkey); - const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); - - return ( - - - - - {displayName[0].toUpperCase()} - - - -
-
- - {author.data?.event ? ( - {displayName} - ) : displayName} - - {metadata?.nip05 && ( - - )} -
- {subtitle && ( - {subtitle} - )} -
- - ); -} function ZapRow({ zap }: { zap: ZapEntry }) { const author = useAuthor(zap.senderPubkey); diff --git a/src/components/LiveStreamPage.tsx b/src/components/LiveStreamPage.tsx index 8ba8925a..7b0e7a63 100644 --- a/src/components/LiveStreamPage.tsx +++ b/src/components/LiveStreamPage.tsx @@ -374,19 +374,23 @@ function ParticipantRow({ pubkey, role }: { pubkey: string; role?: string }) { return (
- - - - - {displayName[0]?.toUpperCase()} - - - - - {author.data?.event ? ( - {displayName} - ) : displayName} - + + + + + + {displayName[0]?.toUpperCase()} + + + + + + + {author.data?.event ? ( + {displayName} + ) : displayName} + + {role && ( {role} diff --git a/src/components/MobileSearchSheet.tsx b/src/components/MobileSearchSheet.tsx index 912efe7b..12067a8c 100644 --- a/src/components/MobileSearchSheet.tsx +++ b/src/components/MobileSearchSheet.tsx @@ -20,6 +20,8 @@ import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikipediaSearch'; import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; +import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; import { cn } from '@/lib/utils'; interface MobileSearchSheetProps { @@ -30,6 +32,7 @@ interface MobileSearchSheetProps { export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { user } = useCurrentUser(); const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(-1); const inputRef = useRef(null); @@ -47,6 +50,9 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { // Country suggestion (local, synchronous) const countryMatch = useMemo(() => searchCountry(query), [query]); + // Nav item suggestions (local, synchronous) + const navItems = useMemo(() => searchSidebarItems(query, !!user), [query, user]); + // URL detection — show "Comment on" option when query is a full URL const queryIsUrl = useMemo(() => isFullUrl(query), [query]); const hasUrlComment = queryIsUrl; @@ -79,11 +85,14 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { const hasIdentifier = !!identifierMatch; const hasWikipedia = !!wikipediaResult; const hasArchive = !!archiveResult; + const navItemCount = navItems.length; - const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); + const totalItems = navItemCount + profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); - // Order: [identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] + // Order: [...navItems, identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] let nextMobileIdx = 0; + const navItemStartIndex = nextMobileIdx; + nextMobileIdx += navItemCount; const identifierIndex = hasIdentifier ? nextMobileIdx++ : -1; const urlCommentIndex = hasUrlComment ? nextMobileIdx++ : -1; const countryTopIndex = (hasCountry && countryAtTop) ? nextMobileIdx++ : -1; @@ -132,6 +141,11 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { navigate(path); }, [navigate, handleClose]); + const handleSelectNavItem = useCallback((item: SidebarItemDef) => { + handleClose(); + navigate(item.path); + }, [navigate, handleClose]); + const handleSelectWikipedia = useCallback((result: WikipediaSearchResult) => { handleClose(); navigate(`/i/${encodeURIComponent(result.url)}`); @@ -166,7 +180,9 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { if (e.key === 'Enter') { e.preventDefault(); if (selectedIndex >= 0 && selectedIndex < totalItems) { - if (hasIdentifier && selectedIndex === identifierIndex) { + if (navItemCount > 0 && selectedIndex >= navItemStartIndex && selectedIndex < navItemStartIndex + navItemCount) { + handleSelectNavItem(navItems[selectedIndex - navItemStartIndex]); + } else if (hasIdentifier && selectedIndex === identifierIndex) { // Identifier item navigation path is determined by the component // Trigger via its onClick handler const sheet = document.querySelector('[data-mobile-search-results]'); @@ -198,7 +214,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { } }; - const hasResults = query.trim().length > 0 && (hasIdentifier || hasUrlComment || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)); + const hasResults = query.trim().length > 0 && (navItemCount > 0 || hasIdentifier || hasUrlComment || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)); if (!open) return null; @@ -216,6 +232,14 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { {/* Results list — reversed so closest to input = most relevant */} {hasResults && (
+ {navItems.map((item, index) => ( + + ))} {hasIdentifier && ( void; +}) { + const Icon = item.icon; + + return ( + + ); +} + /** * Mobile autocomplete item for a detected Nostr identifier. */ diff --git a/src/components/NostrEventSidebarItem.tsx b/src/components/NostrEventSidebarItem.tsx index 277543df..47ae6663 100644 --- a/src/components/NostrEventSidebarItem.tsx +++ b/src/components/NostrEventSidebarItem.tsx @@ -150,7 +150,7 @@ export function NostrEventSidebarItem({ )} - + {isProfile ? ( ) : ( diff --git a/src/components/NostrProvider.tsx b/src/components/NostrProvider.tsx index e51eee9d..da28626e 100644 --- a/src/components/NostrProvider.tsx +++ b/src/components/NostrProvider.tsx @@ -1,6 +1,8 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { NostrEvent, NostrFilter, NPool, NRelay1 } from '@nostrify/nostrify'; import { NostrContext } from '@nostrify/react'; +import { NUser, useNostrLogin } from '@nostrify/react/login'; +import type { NostrSigner } from '@nostrify/types'; import { useAppContext } from '@/hooks/useAppContext'; import { getEffectiveRelays, DITTO_RELAYS, DIVINE_RELAY, ZAPSTORE_RELAY } from '@/lib/appRelays'; import { NostrBatcher } from '@/lib/NostrBatcher'; @@ -12,6 +14,7 @@ interface NostrProviderProps { const NostrProvider: React.FC = (props) => { const { children } = props; const { config } = useAppContext(); + const { logins } = useNostrLogin(); // Create NPool instance only once const pool = useRef(undefined); @@ -19,6 +22,39 @@ const NostrProvider: React.FC = (props) => { // Use refs so the pool always has the latest data const effectiveRelays = useRef(getEffectiveRelays(config.relayMetadata, config.useAppRelays)); + // Stable ref to the current user's signer for NIP-42 AUTH. + // The `open()` callback reads from this ref when a relay sends an AUTH + // challenge, so it always uses the latest signer without recreating the pool. + const signerRef = useRef(undefined); + + // Derive the current signer from the active login. This mirrors the + // logic in useCurrentUser but avoids a circular dependency (useCurrentUser + // depends on NostrContext which we are providing here). + const currentLogin = logins[0]; + const currentSigner = useMemo(() => { + if (!currentLogin) return undefined; + try { + switch (currentLogin.type) { + case 'nsec': + return NUser.fromNsecLogin(currentLogin).signer; + case 'bunker': + // pool.current is guaranteed to exist here: the pool is created + // synchronously during the first render (below), and useMemo runs + // after the render body has executed. + return NUser.fromBunkerLogin(currentLogin, pool.current!).signer; + case 'extension': + return NUser.fromExtensionLogin(currentLogin).signer; + default: + return undefined; + } + } catch { + return undefined; + } + }, [currentLogin]); + + // Keep the ref in sync so the AUTH callback always sees the latest signer. + signerRef.current = currentSigner; + // Update effective relays ref when config changes. The NPool reads from // this ref, so new queries automatically use the updated relay set. // @@ -35,7 +71,25 @@ const NostrProvider: React.FC = (props) => { if (!pool.current) { pool.current = new NPool({ open(url: string) { - return new NRelay1(url); + return new NRelay1(url, { + // NIP-42: Respond to relay AUTH challenges by signing a kind + // 22242 ephemeral event with the current user's signer. + auth: async (challenge: string) => { + const signer = signerRef.current; + if (!signer) { + throw new Error('AUTH failed: no signer available (user not logged in)'); + } + return signer.signEvent({ + kind: 22242, + content: '', + tags: [ + ['relay', url], + ['challenge', challenge], + ], + created_at: Math.floor(Date.now() / 1000), + }); + }, + }); }, reqRouter(filters: NostrFilter[]): Map { const routes = new Map(); diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index cd1e0285..d5ae7d42 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -484,6 +484,7 @@ export function NostrSync() { const remoteTheme: ThemeConfig = { colors: parsed.colors, ...(parsed.font && { font: parsed.font }), + ...(parsed.titleFont && { titleFont: parsed.titleFont }), ...(parsed.background && { background: parsed.background }), }; diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 2c53189c..4d8c3ab3 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -5,14 +5,16 @@ import { FileText, GitBranch, GitPullRequest, + Mail, MessageCircle, + Rocket, MoreHorizontal, Package, - Palette, Play, Radio, Share2, SmilePlus, + Sparkles, Users, Zap, } from "lucide-react"; @@ -42,6 +44,7 @@ import { FollowPackContent } from "@/components/FollowPackContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; import { GitRepoCard } from "@/components/GitRepoCard"; +import { NsiteCard } from "@/components/NsiteCard"; import { ImageGallery } from "@/components/ImageGallery"; import { CardsIcon } from "@/components/icons/CardsIcon"; import { ChestIcon } from "@/components/icons/ChestIcon"; @@ -62,6 +65,7 @@ import { ReplyComposeModal } from "@/components/ReplyComposeModal"; import { ReplyContext } from "@/components/ReplyContext"; import { RepostMenu } from "@/components/RepostMenu"; import { ThemeContent } from "@/components/ThemeContent"; +import { EncryptedMessageContent } from "@/components/EncryptedMessageContent"; import { VanishCardCompact } from "@/components/VanishEventContent"; import { ZapstoreAppContent } from "@/components/ZapstoreAppContent"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -243,6 +247,7 @@ export const NoteCard = memo(function NoteCard({ const isProfileBadges = event.kind === 30008; const isBadge = isBadgeDefinition || isProfileBadges; const isReaction = event.kind === 7; + const isRepost = event.kind === 6 || event.kind === 16; const isPhoto = event.kind === 20; const isNormalVideo = event.kind === 21; const isShortVideo = event.kind === 22; @@ -257,9 +262,11 @@ export const NoteCard = memo(function NoteCard({ const isPatch = event.kind === 1617; const isPullRequest = event.kind === 1618; const isCustomNip = event.kind === 30817; + const isNsite = event.kind === 15128 || event.kind === 35128; const isZapstoreApp = event.kind === 32267; + const isEncryptedDM = event.kind === 4; const isVanish = event.kind === 62; - const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip; + const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite; const isTextNote = !isVine && !isPoll && @@ -277,11 +284,13 @@ export const NoteCard = memo(function NoteCard({ !isEmojiPack && !isBadge && !isReaction && + !isRepost && !isPhoto && !isVideo && !isAudioKind && !isDevKind && !isZapstoreApp && + !isEncryptedDM && !isVanish; // Kind 1 specific — images now render inline in NoteContent, only videos go to NoteMedia @@ -463,8 +472,12 @@ export const NoteCard = memo(function NoteCard({ ) : isCustomNip ? ( + ) : isNsite ? ( + ) : isZapstoreApp ? ( + ) : isEncryptedDM ? ( + ) : ( - +
+
+ {/* Repost icon bubble instead of avatar */} +
+ +
+ {threaded && ( +
+ )} +
+
+
+ {author.isLoading ? ( + + ) : ( + + e.stopPropagation()} + > + + + + {displayName[0]?.toUpperCase()} + + + + + )} + {author.isLoading ? ( + + ) : ( + + e.stopPropagation()} + > + {author.data?.event ? ( + + {displayName} + + ) : ( + displayName + )} + + + )} + reposted + + {timeAgo(event.created_at)} + +
+
+
+ + ); + } + + // Normal repost card (standalone or in feed) + return ( +
+
+ {/* Repost icon */} +
+ +
+ + {/* Author + "reposted" label */} +
+ {author.isLoading ? ( + <> + + + + ) : ( + <> + + e.stopPropagation()} + > + + + + {displayName[0]?.toUpperCase()} + + + + + + e.stopPropagation()} + > + {author.data?.event ? ( + + {displayName} + + ) : ( + displayName + )} + + + reposted + + {timeAgo(event.created_at)} + + + )} +
+
+
+ ); + } + // ── Threaded layout (with or without connector line) ── if (threaded || threadedLast) { return ( @@ -1552,7 +1713,7 @@ function FollowPackAuthorLine({ pubkey, createdAt }: { pubkey: string; createdAt ); } -interface EventActionHeaderProps { +export interface EventActionHeaderProps { /** Pubkey of the person performing the action. */ pubkey: string; /** Lucide icon component shown to the left of the author name. */ @@ -1578,6 +1739,11 @@ interface KindHeaderConfig { } const KIND_HEADER_MAP: Record = { + 4: { + icon: Mail, + action: "sent an", + noun: "encrypted message", + }, 37516: { icon: ChestIcon, action: "hid a", @@ -1597,13 +1763,13 @@ const KIND_HEADER_MAP: Record = { nounRoute: "/decks", }, 36767: { - icon: Palette, + icon: Sparkles, action: "shared a", noun: "theme", nounRoute: "/themes", }, 16767: { - icon: Palette, + icon: Sparkles, action: "updated their", noun: "theme", nounRoute: "/themes", @@ -1662,10 +1828,22 @@ const KIND_HEADER_MAP: Record = { noun: "NIP", nounRoute: "/development", }, + 15128: { + icon: Rocket, + action: "deployed an", + noun: "nsite", + nounRoute: "/development", + }, + 35128: { + icon: Rocket, + action: "deployed an", + noun: "nsite", + nounRoute: "/development", + }, }; /** Generic action header: icon · [author name] [action] [linked noun] */ -function EventActionHeader({ +export function EventActionHeader({ pubkey, icon: Icon, iconClassName, @@ -1691,19 +1869,21 @@ function EventActionHeader({ {author.isLoading ? ( ) : ( - e.stopPropagation()} - > - {author.data?.event ? ( - - {name} - - ) : ( - name - )} - + + e.stopPropagation()} + > + {author.data?.event ? ( + + {name} + + ) : ( + name + )} + + )} {action} diff --git a/src/components/NsiteCard.tsx b/src/components/NsiteCard.tsx new file mode 100644 index 00000000..7454b508 --- /dev/null +++ b/src/components/NsiteCard.tsx @@ -0,0 +1,153 @@ +import type { NostrEvent } from "@nostrify/nostrify"; +import { ExternalLink, FileText, Globe, Server } from "lucide-react"; +import { nip19 } from "nostr-tools"; + +import { ExternalFavicon } from "@/components/ExternalFavicon"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useLinkPreview } from "@/hooks/useLinkPreview"; +import { cn } from "@/lib/utils"; + +interface NsiteCardProps { + event: NostrEvent; +} + +/** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */ +function hexToBase36(hex: string): string { + let n = 0n; + for (let i = 0; i < hex.length; i++) { + n = n * 16n + BigInt(parseInt(hex[i], 16)); + } + const b36 = n.toString(36); + return b36.padStart(50, "0"); +} + +/** Build the nsite.lol gateway URL for an nsite event. */ +function getNsiteUrl(event: NostrEvent): string { + const dTag = event.tags.find(([n]) => n === "d")?.[1]; + + if (event.kind === 35128 && dTag) { + const pubkeyB36 = hexToBase36(event.pubkey); + return `https://${pubkeyB36}${dTag}.nsite.lol`; + } + + const npub = nip19.npubEncode(event.pubkey); + return `https://${npub}.nsite.lol`; +} + +/** Renders an nsite deployment card with a rich link preview. */ +export function NsiteCard({ event }: NsiteCardProps) { + const title = event.tags.find(([n]) => n === "title")?.[1]; + const description = event.tags.find(([n]) => n === "description")?.[1]; + const dTag = event.tags.find(([n]) => n === "d")?.[1]; + const sourceUrl = event.tags.find(([n]) => n === "source")?.[1]; + const pathTags = event.tags.filter(([n]) => n === "path"); + const serverTags = event.tags.filter(([n]) => n === "server"); + + const isNamed = event.kind === 35128 && !!dTag; + const siteUrl = getNsiteUrl(event); + const displayName = title || (isNamed ? dTag : "Root Site"); + + const { data: preview, isLoading } = useLinkPreview(siteUrl); + const image = preview?.thumbnail_url; + const previewTitle = preview?.title; + + if (isLoading) { + return ; + } + + return ( + e.stopPropagation()} + > + {/* Link preview thumbnail */} + {image && ( +
+ { + (e.currentTarget.parentElement as HTMLElement).style.display = "none"; + }} + /> +
+ )} + +
+ + ); +} + +function NsiteCardSkeleton() { + return ( +
+ +
+ + + + +
+
+ ); +} diff --git a/src/components/PollContent.tsx b/src/components/PollContent.tsx index 599f3901..6a7c2df5 100644 --- a/src/components/PollContent.tsx +++ b/src/components/PollContent.tsx @@ -1,7 +1,7 @@ import { useState, useMemo } from 'react'; import { BarChart3, CheckCircle2, Clock } from 'lucide-react'; import { useNostr } from '@nostrify/react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { NoteContent } from '@/components/NoteContent'; @@ -64,6 +64,7 @@ function tallyVotes( export function PollContent({ event }: { event: NostrEvent }) { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const queryClient = useQueryClient(); const { mutate: publishEvent } = useNostrPublish(); const options = useMemo(() => getOptions(event.tags), [event.tags]); @@ -118,6 +119,10 @@ export function PollContent({ event }: { event: NostrEvent }) { ['e', event.id], ['response', selectedOption], ], + }, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['poll-votes', event.id] }); + }, }); }; diff --git a/src/components/ProfileRightSidebar.tsx b/src/components/ProfileRightSidebar.tsx index a03ed4f4..7a8945e7 100644 --- a/src/components/ProfileRightSidebar.tsx +++ b/src/components/ProfileRightSidebar.tsx @@ -499,7 +499,7 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo