Merge branch 'main' into feat-blobbi

This commit is contained in:
filemon
2026-03-27 01:25:27 -03:00
101 changed files with 8880 additions and 891 deletions
+237
View File
@@ -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.
+23 -17
View File
@@ -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:
+38
View File
@@ -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 `<MyKindCard>` 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 ? <MyKindCard event={event} /> : ...`
- 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.
+19
View File
@@ -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.
+20 -5
View File
@@ -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", "<marker>"]`
### Font Tag
Format: `["f", "<family>", "<url>"]`
Format: `["f", "<family>", "<url>", "<role>"]`
| 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
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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 = "";
+231 -137
View File
@@ -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": {
+11 -5
View File
@@ -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",
+19
View File
@@ -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.
+2 -3
View File
@@ -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",
+6 -2
View File
@@ -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() {
<Route path="/" element={<HomePage />} />
<Route path="/feed" element={<Index />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/messages" element={<Messages />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/trends" element={<TrendsPage />} />
<Route path="/profile" element={<ProfileRedirect />} />
@@ -219,9 +220,12 @@ export function AppRouter() {
<Route path="/archive" element={<ArchivePage />} />
<Route path="/bluesky" element={<BlueskyPage />} />
<Route path="/wikipedia" element={<WikipediaPage />} />
<Route path="/letters" element={<LettersPage />} />
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/safety" element={<CSAEPolicyPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
<Route path="/r/*" element={<RelayPage />} />
<Route
path="/settings/lists"
+12 -1
View File
@@ -3,7 +3,7 @@ import { useLocalStorage } from '@/hooks/useLocalStorage';
import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext';
import { builtinThemes, themePresets, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
import { AppConfigSchema } from '@/lib/schemas';
import { loadAndApplyFont } from '@/lib/fontLoader';
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
import { hslToRgb, parseHsl, rgbToHex } from '@/lib/colorUtils';
import { z } from 'zod';
@@ -158,6 +158,8 @@ function useApplyFonts(theme: Theme, customTheme: ThemeConfig | undefined, theme
const activeConfig = resolved === 'custom' ? customTheme : resolveThemeConfig(resolved, themes);
const fontFamily = activeConfig?.font?.family;
const fontUrl = activeConfig?.font?.url;
const titleFontFamily = activeConfig?.titleFont?.family;
const titleFontUrl = activeConfig?.titleFont?.url;
useEffect(() => {
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. */
+1 -1
View File
@@ -58,7 +58,7 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp
className="w-full rounded-xl object-cover max-h-96 mb-6"
/>
)}
<div className="prose prose-sm max-w-none text-foreground prose-headings:text-foreground prose-headings:font-bold prose-a:text-primary prose-img:rounded-lg">
<div className="prose prose-sm max-w-none break-words text-foreground prose-headings:text-foreground prose-headings:font-bold prose-strong:text-foreground prose-a:text-primary prose-img:rounded-lg prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:bg-muted prose-pre:text-foreground prose-code:text-[13px] prose-code:text-foreground prose-code:before:content-none prose-code:after:content-none prose-code:bg-muted prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:font-normal prose-li:marker:text-muted-foreground prose-blockquote:text-muted-foreground prose-blockquote:border-border prose-hr:border-border prose-th:text-foreground">
<Markdown rehypePlugins={[rehypeSanitize]}>
{event.content}
</Markdown>
+120 -34
View File
@@ -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 (
<div>
{/* Hero badge image */}
{/* Hero badge image with 3D tilt */}
{heroImage ? (
<div className="relative isolate flex justify-center py-10 overflow-hidden">
{/* Rotating light rays */}
<div
className="absolute -z-10 pointer-events-none"
aria-hidden="true"
style={{
width: 420,
height: 420,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<div
className="w-full h-full animate-badge-spotlight"
style={{
background: `repeating-conic-gradient(
hsl(var(--primary) / 0.08) 0deg 6deg,
transparent 6deg 18deg
)`,
maskImage: 'radial-gradient(circle, black 15%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(circle, black 15%, transparent 70%)',
}}
/>
</div>
<img
src={heroImage}
alt={badge.name}
className="size-36 rounded-2xl object-cover drop-shadow-lg relative z-[1]"
loading="lazy"
/>
</div>
<BadgeHero heroImage={heroImage} badgeName={badge.name} />
) : (
<div className="flex items-center justify-center h-[180px]">
<Award className="size-16 text-primary/20" />
@@ -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<HTMLDivElement>(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<HTMLDivElement>) => {
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 (
<div className="relative isolate flex justify-center py-10 overflow-hidden">
{/* Rotating light rays (behind tilt container) */}
<div
className="absolute -z-10 pointer-events-none"
aria-hidden="true"
style={{
width: 420,
height: 420,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<div
className="w-full h-full animate-badge-spotlight"
style={{
background: `repeating-conic-gradient(
hsl(var(--primary) / 0.08) 0deg 6deg,
transparent 6deg 18deg
)`,
maskImage: 'radial-gradient(circle, black 15%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(circle, black 15%, transparent 70%)',
}}
/>
</div>
{/*
3D-tiltable badge. The large padding expands the pointer hit-area
well beyond the image so the mouse begins influencing tilt from a
distance. Negative margin compensates so layout stays unchanged.
*/}
<div
ref={tilt.ref}
style={{ ...tilt.style, transformStyle: 'preserve-3d', padding: INTERACT_PAD, margin: -INTERACT_PAD }}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
className="relative select-none"
>
<img
src={heroImage}
alt={badgeName}
className="size-36 object-cover drop-shadow-lg"
loading="lazy"
draggable={false}
/>
{/* Specular glare overlay — masked to the image's alpha channel */}
<div
ref={glareRef}
className="absolute pointer-events-none"
style={{
inset: INTERACT_PAD,
opacity: 0,
transition: 'opacity 0.4s ease-out',
mixBlendMode: 'overlay',
...imageMask,
}}
aria-hidden="true"
/>
</div>
</div>
);
}
function CommentsSkeleton() {
return (
<div className="divide-y divide-border">
+3 -7
View File
@@ -2,7 +2,7 @@ import { useMemo, useState, useEffect, useId } from 'react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/useTheme';
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl } from '@/lib/colorUtils';
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl, hexLuminance } from '@/lib/colorUtils';
import type { CoreThemeColors } from '@/themes';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -12,7 +12,7 @@ function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function getColors(tags: string[][]): string[] {
export function getColors(tags: string[][]): string[] {
return tags
.filter(([n]) => n === 'c')
.map(([, v]) => v)
@@ -195,10 +195,6 @@ function DiagonalStripesLayout({ colors }: { colors: string[] }) {
// ─── Palette → theme mapping ─────────────────────────────
function hexLuminance(hex: string): number {
return getLuminance(...hexToRgb(hex));
}
function hexContrast(hex1: string, hex2: string): number {
return getContrastRatio(hexToRgb(hex1), hexToRgb(hex2));
}
@@ -242,7 +238,7 @@ function enforceContrast(hsl: string, bgHsl: string, targetRatio: number): strin
* 3. primary = most saturated remaining color; if contrast < 3:1 against
* background, adjust its lightness until it passes
*/
function paletteToTheme(colors: string[]): CoreThemeColors {
export function paletteToTheme(colors: string[]): CoreThemeColors {
if (colors.length === 0) {
return { background: '0 0% 10%', text: '0 0% 98%', primary: '258 70% 55%' };
}
+471 -217
View File
@@ -1,10 +1,21 @@
import { useMemo } from 'react';
import type React from 'react';
import { type ReactNode, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
Award, BarChart3, BookOpen, Camera, Clapperboard, FileText, Film,
GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music,
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles,
} from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { CardsIcon } from '@/components/icons/CardsIcon';
import { ChestIcon } from '@/components/icons/ChestIcon';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { LinkPreview } from '@/components/LinkPreview';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { ReactionEmoji } from '@/components/CustomEmoji';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
@@ -16,7 +27,10 @@ import { useLinkPreview } from '@/hooks/useLinkPreview';
import { getDisplayName } from '@/lib/getDisplayName';
import { genUserName } from '@/lib/genUserName';
import { getCountryInfo } from '@/lib/countries';
import { EXTRA_KINDS } from '@/lib/extraKinds';
/** Default classes shared by all comment context rows. */
const ROW_CLASS = 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden';
/** Parsed root reference from a kind 1111 comment's uppercase tags. */
interface CommentRoot {
@@ -58,52 +72,174 @@ function parseCommentRoot(event: NostrEvent): CommentRoot | undefined {
return undefined;
}
/** Get a display name for an event based on its kind and tags. */
function getEventDisplayName(event: NostrEvent): string {
// Try title tag first (used by articles, themes, follow packs, etc.)
const title = event.tags.find(([name]) => name === 'title')?.[1];
if (title) return title;
/**
* Singular comment-context labels for every supported kind.
* Must use singular form with article ("a post", "an article") since these
* appear as "Commenting on {label}". EXTRA_KINDS labels are plural/categorical
* ("Requests to Vanish", "User Statuses") and must NOT be used directly.
*/
const KIND_LABELS: Record<number, string> = {
1: 'a post',
4: 'an encrypted message',
6: 'a repost',
7: 'a reaction',
16: 'a repost',
20: 'a photo',
21: 'a video',
22: 'a short video',
62: 'a request to vanish',
1063: 'a file',
1068: 'a poll',
1111: 'a comment',
1222: 'a voice message',
1617: 'a patch',
1618: 'a pull request',
3367: 'a color moment',
7516: 'a found log',
15128: 'an nsite',
16767: 'a theme',
30008: 'profile badges',
30009: 'a badge',
30023: 'an article',
30030: 'an emoji pack',
30054: 'a podcast episode',
30055: 'a podcast trailer',
30063: 'a release',
30311: 'a stream',
30315: 'a status',
30617: 'a repository',
30817: 'a custom NIP',
31922: 'a calendar event',
31923: 'a calendar event',
32267: 'an app',
34139: 'a playlist',
34236: 'a vine',
34550: 'a community',
35128: 'an nsite',
36767: 'a theme',
36787: 'a track',
37381: 'a Magic deck',
37516: 'a geocache',
39089: 'a follow pack',
};
// Try name tag (used by communities, emoji packs, etc.)
const name = event.tags.find(([name]) => name === 'name')?.[1];
if (name) return name;
/** Kind-specific icons — matches sidebar and NoteCard icons. */
const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: string }>>> = {
1: MessageSquare,
4: Mail,
6: RepostIcon,
16: RepostIcon,
20: Camera,
21: Film,
22: Film,
1063: FileText,
1068: BarChart3,
1222: Mic,
1617: FileText,
1618: GitPullRequest,
15128: Rocket,
35128: Rocket,
30008: Award,
30009: Award,
30023: BookOpen,
30030: SmilePlus,
30054: Podcast,
30055: Podcast,
30063: Package,
30311: Radio,
30617: GitBranch,
32267: Package,
34236: Clapperboard,
36767: Sparkles,
16767: Sparkles,
36787: Music,
34139: Music,
37381: CardsIcon,
37516: ChestIcon,
7516: ChestIcon,
39089: PartyPopper,
3367: Palette,
};
// Try d tag (addressable events use this as an identifier)
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
if (dTag) return dTag;
// Fall back to kind label from EXTRA_KINDS
const kindDef = EXTRA_KINDS.find((def) =>
def.subKinds?.some((sub) => sub.kind === event.kind) || def.kind === event.kind,
);
if (kindDef) return kindDef.label.toLowerCase();
// Last resort
return 'a post';
/**
* Get a singular comment-context label for a kind number.
* Only uses KIND_LABELS (which has proper singular forms with articles).
* Never falls through to EXTRA_KINDS labels since those are plural/categorical.
*/
function getKindLabel(kind: number): string {
return KIND_LABELS[kind] ?? 'a post';
}
/** Get a human-readable kind label for fallback display. */
function getKindLabel(rootKind: string | undefined): string {
/** Parse a rootKind string into a label, handling both numeric and external content kinds. */
function getRootKindLabel(rootKind: string | undefined): string {
if (!rootKind) return 'a post';
const kindNum = parseInt(rootKind, 10);
if (isNaN(kindNum)) {
// External content kinds (web, #, etc.)
if (rootKind === 'web' || rootKind === 'https' || rootKind === 'http') return 'a link';
if (rootKind === '#') return 'a hashtag';
return rootKind;
}
if (kindNum === 7) return 'a reaction';
if (kindNum === 32267) return 'an app';
if (kindNum === 30063) return 'a release';
return getKindLabel(kindNum);
}
const kindDef = EXTRA_KINDS.find((def) =>
def.subKinds?.some((sub) => sub.kind === kindNum) || def.kind === kindNum,
);
if (kindDef) return kindDef.label.toLowerCase();
/** Suffix that describes the kind, appended after a title (e.g. "Wet Dry World theme"). */
const KIND_SUFFIXES: Partial<Record<number, string>> = {
30009: 'badge',
30030: 'emoji pack',
36767: 'theme',
16767: 'theme',
39089: 'follow pack',
37381: 'deck',
37516: 'geocache',
34550: 'community',
30054: 'episode',
30055: 'trailer',
34139: 'playlist',
};
return 'a post';
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto app"). */
const KIND_POSTFIXES: Partial<Record<number, string>> = {
32267: 'on Zapstore',
30063: 'release',
};
/** Get a display name for an event based on its kind and tags. */
function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.ComponentType<{ className?: string }> } {
const icon = KIND_ICONS[event.kind];
// Nsite deployments: "{siteName} nsite" with rocket icon
if (event.kind === 15128 || event.kind === 35128) {
const title = event.tags.find(([name]) => name === 'title')?.[1];
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
const siteName = title || dTag;
return { text: siteName ? `${siteName} nsite` : 'an nsite', icon };
}
// Extract a title-like string from tags
const title = event.tags.find(([name]) => name === 'title')?.[1];
const name = event.tags.find(([name]) => name === 'name')?.[1];
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
const displayTitle = title || name || dTag;
// Kinds with a custom postfix (e.g. "Ditto on Zapstore")
const postfix = KIND_POSTFIXES[event.kind];
if (postfix && displayTitle) {
return { text: `${displayTitle} ${postfix}`, icon };
}
// Kinds with a suffix (e.g. "Beagle Owner badge", "Wet Dry World theme")
const suffix = KIND_SUFFIXES[event.kind];
if (suffix && displayTitle) {
return { text: `${displayTitle} ${suffix}`, icon };
}
// Generic: just use the title if available
if (displayTitle) return { text: displayTitle, icon };
// Fall back to kind label
return { text: getKindLabel(event.kind), icon };
}
/** Build a navigation link for the root event. */
@@ -117,6 +253,63 @@ function getRootLink(event: NostrEvent): string {
return `/${nip19.neventEncode({ id: event.id, author: event.pubkey })}`;
}
// ─── Shared wrapper ────────────────────────────────────────────
interface CommentContextRowProps {
prefix: string;
className?: string;
loading?: boolean;
children?: ReactNode;
}
/** Shared row wrapper for all comment context variants. */
function CommentContextRow({ prefix, className, loading, children }: CommentContextRowProps) {
return (
<div className={className || ROW_CLASS}>
<span className="shrink-0">{prefix}</span>
{loading ? <Skeleton className="h-3.5 w-24 inline-block" /> : children}
</div>
);
}
// ─── Shared hover card for Nostr events ────────────────────────
interface EventHoverLinkProps {
display: { text: string; icon?: React.ComponentType<{ className?: string }> };
link: string;
hoverContent: ReactNode;
}
/** Link with icon that shows a hover card preview. */
function EventHoverLink({ display, link, hoverContent }: EventHoverLinkProps) {
const DisplayIcon = display.icon;
return (
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
to={link}
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
{DisplayIcon && <DisplayIcon className="size-3.5 shrink-0" />}
{display.text}
</Link>
</HoverCardTrigger>
<HoverCardContent
side="bottom"
align="start"
sideOffset={4}
className="w-80 p-0 rounded-2xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
{hoverContent}
</HoverCardContent>
</HoverCard>
);
}
// ─── Main export ───────────────────────────────────────────────
interface CommentContextProps {
event: NostrEvent;
className?: string;
@@ -152,6 +345,8 @@ export function CommentContext({ event, className }: CommentContextProps) {
}
}
// ─── Sub-components ────────────────────────────────────────────
/** Comment context when replying directly to another kind 1111 comment — shows "Replying to @user". */
function ReplyToCommentContext({ pubkey, eventId, className }: { pubkey: string; eventId?: string; className?: string }) {
const author = useAuthor(pubkey);
@@ -163,26 +358,18 @@ function ReplyToCommentContext({ pubkey, eventId, className }: { pubkey: string;
try { return `/${nip19.neventEncode({ id: eventId, author: pubkey })}`; } catch { return undefined; }
}, [eventId, pubkey]);
if (author.isLoading) {
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1'}>
<span className="shrink-0">Replying to</span>
<Skeleton className="h-3.5 w-24 inline-block" />
</div>
);
}
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden'}>
<span className="shrink-0">Replying to</span>
<Link
to={parentLink ?? `/${npubEncoded}`}
className="text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
@{displayName}
</Link>
</div>
<CommentContextRow prefix="Replying to" className={className} loading={author.isLoading}>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={parentLink ?? `/${npubEncoded}`}
className="text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
@{displayName}
</Link>
</ProfileHoverCard>
</CommentContextRow>
);
}
@@ -193,6 +380,11 @@ function AddrCommentContext({ root, className }: { root: CommentRoot; className?
return <ProfileCommentContext pubkey={root.addr.pubkey} className={className} />;
}
// Kind 30008 (profile badges) roots — show "@User's profile badges"
if (root.addr?.kind === 30008) {
return <ProfileBadgesCommentContext root={root} className={className} />;
}
return <GenericAddrCommentContext root={root} className={className} />;
}
@@ -203,26 +395,68 @@ function ProfileCommentContext({ pubkey, className }: { pubkey: string; classNam
const displayName = metadata?.name ?? genUserName(pubkey);
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
if (author.isLoading) {
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1'}>
<span className="shrink-0">Commenting on</span>
<Skeleton className="h-3.5 w-24 inline-block" />
</div>
);
}
return (
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={`/${npubEncoded}`}
className="text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
@{displayName}
</Link>
</ProfileHoverCard>
</CommentContextRow>
);
}
/** Comment context for kind 30008 (profile badges) roots — shows "Commenting on profile badges by @User". */
function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const pubkey = root.addr?.pubkey ?? '';
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
// Build naddr link for the profile badges event
const link = useMemo(() => {
if (!root.addr) return undefined;
try { return `/${nip19.naddrEncode({ kind: root.addr.kind, pubkey: root.addr.pubkey, identifier: root.addr.identifier })}`; } catch { return undefined; }
}, [root.addr]);
// Hover content for the addressable event
const hoverContent = root.addr ? (
<EmbeddedNaddr
addr={{ kind: root.addr.kind, pubkey: root.addr.pubkey, identifier: root.addr.identifier }}
className="border-0 rounded-none"
/>
) : undefined;
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden'}>
<span className="shrink-0">Commenting on</span>
<Link
to={`/${npubEncoded}`}
className="text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
@{displayName}
</Link>
</div>
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
{link && hoverContent ? (
<EventHoverLink
display={{ text: 'profile badges', icon: Award }}
link={link}
hoverContent={hoverContent}
/>
) : (
<span className="inline-flex items-center gap-1 truncate">
<Award className="size-3.5 shrink-0" />
profile badges
</span>
)}
<span className="shrink-0">by</span>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={`/${npubEncoded}`}
className="text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
@{displayName}
</Link>
</ProfileHoverCard>
</CommentContextRow>
);
}
@@ -233,33 +467,34 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
const isCommunity = root.rootKind === '34550' || root.addr?.kind === 34550;
const prefix = isCommunity ? 'Posted in' : 'Commenting on';
if (isLoading) {
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1'}>
<span className="shrink-0">{prefix}</span>
<Skeleton className="h-3.5 w-24 inline-block" />
</div>
);
}
const displayName = event ? getEventDisplayName(event) : getKindLabel(root.rootKind);
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
const link = event ? getRootLink(event) : undefined;
// Build hover card content for addressable events
const hoverContent = root.addr ? (
<EmbeddedNaddr
addr={{ kind: root.addr.kind, pubkey: root.addr.pubkey, identifier: root.addr.identifier }}
className="border-0 rounded-none"
/>
) : undefined;
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden'}>
<span className="shrink-0">{prefix}</span>
{link ? (
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
{link && hoverContent ? (
<EventHoverLink display={display} link={link} hoverContent={hoverContent} />
) : link ? (
<Link
to={link}
className="text-primary hover:underline truncate"
className="inline-flex items-center gap-1 text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{displayName}
{display.icon && <display.icon className="size-3.5 shrink-0" />}
{display.text}
</Link>
) : (
<span className="truncate">{displayName}</span>
<span className="truncate">{display.text}</span>
)}
</div>
</CommentContextRow>
);
}
@@ -267,70 +502,30 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
function EventCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const { data: event, isLoading } = useEvent(root.eventId);
const label = useMemo(() => {
if (!root.eventId) return undefined;
if (event) {
return (
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
to={getRootLink(event)}
className="text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
{getEventDisplayName(event)}
</Link>
</HoverCardTrigger>
<HoverCardContent
side="bottom"
align="start"
sideOffset={4}
className="w-80 p-0 rounded-2xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<EmbeddedNote eventId={root.eventId!} className="border-0 rounded-none" disableHoverCards />
</HoverCardContent>
</HoverCard>
);
}
return undefined;
}, [event, root.eventId]);
// Kind 7 reactions get special treatment
if (event?.kind === 7) {
return <ReactionCommentContext event={event} className={className} />;
}
if (isLoading) {
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1'}>
<span className="shrink-0">Commenting on</span>
<Skeleton className="h-3.5 w-24 inline-block" />
</div>
);
}
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
const link = event ? getRootLink(event) : undefined;
// Fallback for kind 7 when event hasn't loaded yet but rootKind indicates it
if (root.rootKind === '7') {
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1'}>
<span className="shrink-0">Commenting on</span>
<span className="truncate">a reaction</span>
</div>
);
}
const hoverContent = root.eventId ? (
<EmbeddedNote eventId={root.eventId} className="border-0 rounded-none" disableHoverCards />
) : undefined;
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden'}>
<span className="shrink-0">Commenting on</span>
{label ?? <span className="truncate">{getKindLabel(root.rootKind)}</span>}
</div>
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
{link && hoverContent ? (
<EventHoverLink display={display} link={link} hoverContent={hoverContent} />
) : (
<span className="truncate">{display.text}</span>
)}
</CommentContextRow>
);
}
/** Comment context for kind 7 reaction roots — shows "Commenting on {emoji} by {name}". */
/** Comment context for kind 7 reaction roots — shows "Commenting on {emoji} by @{name}". */
function ReactionCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
@@ -339,8 +534,7 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
const profileLink = `/${nip19.npubEncode(event.pubkey)}`;
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden'}>
<span className="shrink-0">Commenting on</span>
<CommentContextRow prefix="Commenting on" className={className}>
<Link
to={reactionLink}
className="text-primary hover:underline shrink-0 cursor-pointer"
@@ -352,15 +546,17 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
{author.isLoading ? (
<Skeleton className="h-3.5 w-16 inline-block" />
) : (
<Link
to={profileLink}
className="text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
{displayName}
</Link>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileLink}
className="text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
@{displayName}
</Link>
</ProfileHoverCard>
)}
</div>
</CommentContextRow>
);
}
@@ -378,45 +574,24 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN
return <UrlCommentContext url={identifier} className={className} />;
}
// Determine display text and link
let displayText: string;
let link: string | undefined;
// ISO 3166 country/subdivision identifiers get special treatment
if (identifier.startsWith('iso3166:')) {
const code = identifier.slice('iso3166:'.length);
const info = getCountryInfo(code);
if (info) {
displayText = info.subdivisionName
? `${info.flag} ${info.subdivisionName}`
: `${info.flag} ${info.name}`;
} else {
displayText = identifier;
}
link = `/i/${encodeURIComponent(identifier)}`;
} else if (identifier.startsWith('#')) {
displayText = identifier;
link = `/i/${encodeURIComponent(identifier)}`;
} else {
// podcast:guid:, etc.
displayText = identifier;
link = `/i/${encodeURIComponent(identifier)}`;
return <CountryCommentContext identifier={identifier} className={className} />;
}
// Generic fallback for other external identifiers
const link = `/i/${encodeURIComponent(identifier)}`;
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden'}>
<span className="shrink-0">Commenting on</span>
{link ? (
<Link
to={link}
className="text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{displayText}
</Link>
) : (
<span className="truncate">{displayText}</span>
)}
</div>
<CommentContextRow prefix="Commenting on" className={className}>
<Link
to={link}
className="text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{identifier}
</Link>
</CommentContextRow>
);
}
@@ -432,20 +607,10 @@ function UrlCommentContext({ url, className }: { url: string; className?: string
fallbackHost = url;
}
if (isLoading) {
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1'}>
<span className="shrink-0">Commenting on</span>
<Skeleton className="h-3.5 w-24 inline-block" />
</div>
);
}
const title = preview?.title;
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden'}>
<span className="shrink-0">Commenting on</span>
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
<ExternalFavicon url={url} size={14} className="shrink-0" />
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
@@ -467,36 +632,125 @@ function UrlCommentContext({ url, className }: { url: string; className?: string
<LinkPreview url={url} className="border-0 rounded-none" navigateToComments />
</HoverCardContent>
</HoverCard>
</div>
</CommentContextRow>
);
}
/** Comment context for ISBN identifiers — fetches and displays the book title. */
/** Comment context for ISO 3166 country/subdivision identifiers — shows flag and name with hover preview. */
function CountryCommentContext({ identifier, className }: { identifier: string; className?: string }) {
const code = identifier.slice('iso3166:'.length);
const info = getCountryInfo(code);
const link = `/i/${encodeURIComponent(identifier)}`;
const displayText = info
? info.subdivisionName
? `${info.flag} ${info.subdivisionName}`
: `${info.flag} ${info.name}`
: identifier;
return (
<CommentContextRow prefix="Commenting on" className={className}>
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
to={link}
className="text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
{displayText}
</Link>
</HoverCardTrigger>
<HoverCardContent
side="bottom"
align="start"
sideOffset={4}
className="w-64 p-0 rounded-2xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 px-4 py-3">
<span className="text-2xl leading-none shrink-0" role="img" aria-label={info ? `Flag of ${info.name}` : code}>
{info?.flag ?? '🌍'}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="size-3 shrink-0" />
<span>{info?.subdivisionName ? 'Region' : 'Country'}</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{info?.subdivisionName ?? info?.name ?? code}
</p>
{info?.subdivisionName && info.name && (
<p className="text-xs text-muted-foreground truncate">
{info.name}
</p>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
</CommentContextRow>
);
}
/** Comment context for ISBN identifiers — fetches and displays the book title with hover preview. */
function IsbnCommentContext({ identifier, className }: { identifier: string; className?: string }) {
const isbn = identifier.slice('isbn:'.length);
const { data: bookInfo, isLoading } = useBookInfo(isbn);
const link = `/i/${encodeURIComponent(identifier)}`;
const displayText = bookInfo?.title ?? identifier;
if (isLoading) {
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1'}>
<span className="shrink-0">Commenting on</span>
<Skeleton className="h-3.5 w-24 inline-block" />
</div>
);
}
const coverUrl = bookInfo?.cover?.medium || bookInfo?.cover?.large;
const authors = bookInfo?.authors?.map((a) => a.name).join(', ');
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden'}>
<span className="shrink-0">Commenting on</span>
<Link
to={link}
className="text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{displayText}
</Link>
</div>
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<Link
to={link}
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<BookOpen className="size-3.5 shrink-0" />
{displayText}
</Link>
</HoverCardTrigger>
<HoverCardContent
side="bottom"
align="start"
sideOffset={4}
className="w-72 p-0 rounded-2xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 px-4 py-3">
{coverUrl ? (
<img
src={coverUrl}
alt={bookInfo?.title || 'Book cover'}
className="w-9 h-12 rounded object-cover shrink-0"
loading="lazy"
/>
) : (
<div className="w-9 h-12 rounded bg-secondary flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground/40" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<BookOpen className="size-3 shrink-0" />
<span>Book</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{bookInfo?.title || `ISBN ${isbn}`}
</p>
{authors && (
<p className="text-xs text-muted-foreground truncate">
by {authors}
</p>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
</CommentContextRow>
);
}
+9 -9
View File
@@ -133,16 +133,16 @@ export function CustomNipCard({ event, preview = true }: CustomNipCardProps) {
</div>
</div>
{/* Full markdown content -- detail view only, outside card */}
{!preview && event.content && (
<div className="rounded-2xl border border-border overflow-hidden px-3.5 py-3">
<div className="prose prose-sm max-w-none text-foreground prose-headings:text-foreground prose-headings:font-bold prose-a:text-primary prose-img:rounded-lg">
<Markdown rehypePlugins={[rehypeSanitize]}>
{event.content}
</Markdown>
</div>
{/* Full markdown content -- detail view only, outside card */}
{!preview && event.content && (
<div className="rounded-2xl border border-border overflow-hidden px-4 py-4 sidebar:px-5 sidebar:py-5">
<div className="prose prose-sm max-w-none break-words text-foreground prose-headings:text-foreground prose-headings:font-bold prose-strong:text-foreground prose-a:text-primary prose-img:rounded-lg prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:bg-muted prose-pre:text-foreground prose-code:text-[13px] prose-code:text-foreground prose-code:before:content-none prose-code:after:content-none prose-code:bg-muted prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:font-normal prose-li:marker:text-muted-foreground prose-blockquote:text-muted-foreground prose-blockquote:border-border prose-hr:border-border prose-th:text-foreground">
<Markdown rehypePlugins={[rehypeSanitize]}>
{event.content}
</Markdown>
</div>
)}
</div>
)}
</div>
);
}
+68 -55
View File
@@ -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 <EmbeddedNaddrTombstone addr={addr} className={className} />;
}
// For follow packs / starter packs, render the same NoteCard used in feeds (without actions)
if (NOTECARD_KINDS.has(event.kind)) {
return (
<div className={className} onClick={(e) => e.stopPropagation()}>
<NoteCard event={event} compact className="rounded-2xl border border-border !border-b overflow-hidden" />
</div>
);
}
// Badge definitions get a compact showcase instead of a link-preview card
if (event.kind === 30009) {
return <EmbeddedBadgeCard event={event} className={className} />;
}
return <EmbeddedNaddrCard event={event} className={className} />;
return <EmbeddedNaddrCard event={event} className={className} disableHoverCards={disableHoverCards} />;
}
/** 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 (
<div
className={cn(
@@ -224,21 +222,6 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className?
}
}}
>
{/* Image */}
{image && (
<div className="w-full overflow-hidden">
<img
src={image}
alt=""
className="w-full h-[180px] object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
}}
/>
</div>
)}
{/* Text content */}
<div className="px-3 py-2 space-y-1">
{/* Author row */}
@@ -250,28 +233,32 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className?
</>
) : (
<>
<Link
to={`/${npub}`}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
<Link
to={`/${npub}`}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</MaybeProfileHoverCard>
<Link
to={`/${npub}`}
className="text-sm font-semibold truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
<Link
to={`/${npub}`}
className="text-sm font-semibold truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
</MaybeProfileHoverCard>
</>
)}
@@ -294,11 +281,38 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className?
</p>
)}
{/* Kind label and attachment indicators */}
<div className="flex items-center gap-2 flex-wrap">
{kindMeta && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
{kindMeta.Icon && <kindMeta.Icon className="size-3 shrink-0" />}
{kindMeta.label}
</span>
)}
{image && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Image className="size-3" />
Image
</span>
)}
</div>
</div>
</div>
);
}
/** 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 (
<ProfileHoverCard pubkey={pubkey} asChild>
{children}
</ProfileHoverCard>
);
}
/** 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 (
<div className={cn('rounded-2xl border border-border overflow-hidden', className)}>
<Skeleton className="w-full h-[180px] rounded-none" />
<div className="px-3.5 py-2.5 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
+41 -37
View File
@@ -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 <EmbeddedNoteTombstone eventId={eventId} relays={relays} authorHint={authorHint} className={className} />;
}
// For follow packs / lists, render the same rich NoteCard used in feeds
if (NOTECARD_KINDS.has(event.kind)) {
return (
<div className={className} onClick={(e) => e.stopPropagation()}>
<NoteCard event={event} compact className="rounded-2xl border border-border !border-b overflow-hidden" />
</div>
);
}
// NIP-62 vanish events get their own dramatic inline card
if (event.kind === VANISH_KIND) {
return <EmbeddedVanishCardWrapper event={event} className={className} />;
}
// Kind 4 encrypted DMs get a compact card instead of rendering ciphertext
if (event.kind === 4) {
return <EncryptedMessageCompact event={event} className={className} />;
}
return <EmbeddedNoteCard event={event} className={className} disableHoverCards={disableHoverCards} />;
}
@@ -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') && (
<div className="w-full overflow-hidden">
<img
src={firstImage}
alt=""
className="w-full h-[160px] object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
}}
/>
</div>
)}
{/* Note content */}
<div className="px-3 py-2 space-y-1">
{/* Author row */}
@@ -255,22 +244,37 @@ function EmbeddedNoteCard({
</span>
</div>
{/* Content warning notice or text preview */}
{/* Content warning notice or text preview or tag-based metadata */}
{hasCW && config.contentWarningPolicy === 'blur' ? (
<p className="text-xs text-muted-foreground italic">
Content warning{cwTag?.[1] ? <>{' '}&ldquo;{cwTag[1]}&rdquo;</> : ''}
</p>
) : truncatedContent ? (
<EmbedContentPreview text={truncatedContent} disableHoverCards={disableHoverCards} />
) : tagMeta ? (
<>
{tagMeta.title && (
<p className="text-sm font-semibold leading-snug line-clamp-2">{tagMeta.title}</p>
)}
{tagMeta.description && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">{tagMeta.description}</p>
)}
{tagMeta.kindLabel && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
{tagMeta.KindIcon && <tagMeta.KindIcon className="size-3 shrink-0" />}
{tagMeta.kindLabel}
</p>
)}
</>
) : 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) && (
<div className="flex items-center gap-2 flex-wrap">
{attachments.imgs > (firstImage ? 1 : 0) && (
{attachments.imgs > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Image className="size-3" />
{attachments.imgs > 1 ? `${attachments.imgs} images` : '1 image'}
{attachments.imgs > 1 ? `${attachments.imgs} images` : 'Image'}
</span>
)}
{attachments.vids > 0 && (
+220
View File
@@ -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 (
<div className="flex flex-col items-center gap-1.5 min-w-0">
<Skeleton className="size-10 rounded-full" />
<Skeleton className="h-3 w-14" />
</div>
);
}
return (
<div className="flex flex-col items-center gap-1.5 min-w-0">
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className="size-10 ring-2 ring-background shadow-md">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs font-semibold">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={profileUrl}
onClick={(e) => e.stopPropagation()}
className="text-xs font-medium text-foreground/80 hover:text-foreground truncate max-w-[80px] transition-colors"
>
{displayName}
</Link>
</ProfileHoverCard>
</div>
);
}
/**
* 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 (
<div className={cn('mt-2 px-4 py-3', className)}>
<p className="text-sm text-muted-foreground italic">Encrypted message (no recipient found)</p>
</div>
);
}
return (
<div className={cn('mt-2', className)}>
<div className="rounded-2xl border border-border px-5 py-4">
{/* Description */}
<p className="text-sm text-muted-foreground text-center mb-3">
<span className="font-medium text-foreground">{senderName}</span> sent a direct message
</p>
<div className="flex items-center justify-center gap-3">
{/* Sender */}
<Participant pubkey={event.pubkey} />
{/* Transit path */}
<div className="flex-1 flex items-center justify-center min-w-0 max-w-[180px] relative">
{/* Dotted line */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px border-t-2 border-dashed border-foreground/15" />
{/* Mail icon in the center */}
<div className="relative z-10 flex items-center justify-center">
<div className="size-9 rounded-full bg-background border-2 border-foreground/10 flex items-center justify-center shadow-sm">
<Mail className="size-4 text-muted-foreground" />
</div>
</div>
</div>
{/* Recipient */}
<Participant pubkey={recipientPubkey} />
</div>
</div>
</div>
);
}
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 (
<div
className={cn(
'group block rounded-2xl border border-border overflow-hidden',
'hover:bg-secondary/40 transition-colors cursor-pointer',
className,
)}
role="link"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
navigate(`/${neventId}`);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigate(`/${neventId}`);
}
}}
>
<div className="px-3 py-2 space-y-1">
{/* Author row */}
<div className="flex items-center gap-2 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-5 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-24" />
</>
) : (
<>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="text-sm font-semibold truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{displayName}
</Link>
</ProfileHoverCard>
</>
)}
<span className="text-xs text-muted-foreground shrink-0">
· {timeAgo(event.created_at)}
</span>
</div>
{/* Content line */}
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Mail className="size-3.5 shrink-0" />
<span>
Sent a direct message{recipientName ? <> to <span className="font-medium text-foreground">{recipientName}</span></> : ''}
</span>
</p>
</div>
</div>
);
}
+3
View File
@@ -1084,6 +1084,8 @@ function hasVideo(tags: string[][]): boolean {
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
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]);
@@ -102,7 +102,7 @@ export function ExternalContentSidebarItem({
<span className="shrink-0">
<ExternalSidebarIcon id={id} />
</span>
<span className="truncate">
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
<ExternalSidebarLabel id={id} />
</span>
</Link>
+54
View File
@@ -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<React.CSSProperties | undefined>(() => {
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 (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none ${className}`}
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
<div
className={`absolute inset-0 bg-primary ${shapeMaskStyle ? '' : 'rounded-full'}`}
style={shapeMaskStyle}
/>
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
{icon}
</span>
</button>
);
}
+6 -37
View File
@@ -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<React.CSSProperties | undefined>(() => {
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 (
<>
<button
<FabButton
onClick={handleClick}
className="relative size-16 transition-transform hover:scale-105 active:scale-95"
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
{/* FAB background: user's avatar shape (emoji mask) or circle (default) */}
<div
className="absolute inset-0 bg-primary rounded-full"
style={shapeMaskStyle}
/>
{/* Plus icon centered on the button */}
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
{icon ?? <Plus strokeWidth={4} size={16} />}
</span>
</button>
icon={icon ?? <Plus strokeWidth={4} size={16} />}
/>
{/* Kind 1: Compose modal */}
{kind === 1 && (
+52 -19
View File
@@ -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 (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Type className="size-3.5" />
Font
</span>
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
@@ -182,10 +181,15 @@ export function FontPicker({ value, onChange }: {
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal h-9 text-sm"
style={currentFont ? { fontFamily: `"${resolveCssFamily(currentFont.family)}", sans-serif` } : undefined}
style={currentFont
? { fontFamily: `"${resolveCssFamily(currentFont.family)}", sans-serif` }
: placeholderFont
? { fontFamily: `"${resolveCssFamily(placeholderFont.family)}", sans-serif` }
: undefined
}
>
<span className="truncate">
{currentFont?.family ?? 'Default (Inter)'}
{currentFont?.family ?? placeholder}
{isCustomUpload && (
<span className="ml-1.5 text-muted-foreground text-xs">(uploaded)</span>
)}
@@ -285,15 +289,44 @@ export function FontPicker({ value, onChange }: {
className="hidden"
onChange={handleFileUpload}
/>
</>
);
}
{currentFont && (
<p
className="text-sm text-muted-foreground"
style={{ fontFamily: `"${resolveCssFamily(currentFont.family)}", sans-serif` }}
>
The quick brown fox jumps over the lazy dog.
</p>
)}
/**
* Unified font section with body + title pickers and a live preview.
*
* Shows a single "Fonts" header, two labeled rows (Body / Title),
* and a combined preview showing both fonts in context.
*/
export function FontSection({ bodyFont, onBodyFontChange, titleFont, onTitleFontChange }: {
bodyFont?: ThemeFont | undefined;
onBodyFontChange?: (font: ThemeFont | undefined) => void;
titleFont?: ThemeFont | undefined;
onTitleFontChange?: (font: ThemeFont | undefined) => void;
}) {
return (
<div className="space-y-3">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Type className="size-3.5" />
Fonts
</span>
{/* Two-row picker layout */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-10 shrink-0">Title</span>
<div className="flex-1">
<FontPicker value={titleFont} onChange={onTitleFontChange} placeholder={bodyFont?.family ?? 'Default (Inter)'} placeholderFont={bodyFont} />
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-10 shrink-0">Body</span>
<div className="flex-1">
<FontPicker value={bodyFont} onChange={onBodyFontChange} />
</div>
</div>
</div>
</div>
);
}
+15 -29
View File
@@ -1,7 +1,8 @@
import type { NostrEvent } from "@nostrify/nostrify";
import { BookMarked, Copy, ExternalLink, Globe, Wand2 } from "lucide-react";
import { BookMarked, Copy, Check, ExternalLink, Globe, Wand2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { NostrURI } from "@/lib/NostrURI";
interface GitRepoCardProps {
event: NostrEvent;
@@ -22,9 +23,6 @@ export function GitRepoCard({ event }: GitRepoCardProps) {
const name = event.tags.find(([n]) => n === "name")?.[1];
const description = event.tags.find(([n]) => n === "description")?.[1];
const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => v);
const cloneUrls = event.tags
.filter(([n]) => n === "clone")
.map(([, v]) => v);
const isPersonalFork = event.tags.some(
([n, v]) => n === "t" && v === "personal-fork",
);
@@ -33,6 +31,10 @@ export function GitRepoCard({ event }: GitRepoCardProps) {
);
const dTag = event.tags.find(([n]) => n === "d")?.[1] ?? "";
// Nostr clone URI (nostr://npub/relay/identifier)
const nostrUri = NostrURI.fromEvent(event);
const nostrCloneUrl = nostrUri.toString();
// Shakespeare + web URL = this is a deployed application, not a repo
const isApp = hasShakespeare && !!webUrls[0];
const faviconUrl = isApp ? getFaviconUrl(webUrls[0]) : undefined;
@@ -48,9 +50,7 @@ export function GitRepoCard({ event }: GitRepoCardProps) {
setTimeout(() => setCopied(false), 2000);
};
const shakespeareUrl = cloneUrls[0]
? `https://shakespeare.diy/clone?url=${encodeURIComponent(cloneUrls[0])}`
: "https://shakespeare.diy";
const shakespeareUrl = `https://shakespeare.diy/clone?url=${encodeURIComponent(nostrCloneUrl)}`;
return (
<div className="mt-2 rounded-2xl border border-border overflow-hidden">
@@ -85,31 +85,29 @@ export function GitRepoCard({ event }: GitRepoCardProps) {
</p>
)}
{/* Clone URL -- hidden for apps */}
{!isApp && cloneUrls[0] && (
<div className="group/clone flex items-center gap-2 rounded-lg bg-muted/50 px-2.5 py-1.5">
{/* Nostr clone URI */}
<div className="group/nostr flex items-center gap-2 rounded-lg bg-muted/50 px-2.5 py-1.5">
<code className="flex-1 text-[11px] font-mono text-muted-foreground truncate select-all">
{cloneUrls[0]}
{nostrCloneUrl}
</code>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0 opacity-50 group-hover/clone:opacity-100 transition-opacity"
className="h-5 w-5 p-0 shrink-0 opacity-50 group-hover/nostr:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleCopy(cloneUrls[0]);
handleCopy(nostrCloneUrl);
}}
>
<Copy className="size-3" />
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
<span className="sr-only">
{copied ? "Copied" : "Copy"}
</span>
</Button>
</div>
)}
</div>
{/* Action buttons */}
{(hasShakespeare || isApp || webUrls[0] || cloneUrls[0]) && (
{(hasShakespeare || isApp || webUrls[0]) && (
<div className="flex flex-wrap gap-2 pt-0.5">
{hasShakespeare && (
<button
@@ -160,18 +158,6 @@ export function GitRepoCard({ event }: GitRepoCardProps) {
<Globe className="size-3" />
Browse Repository
</button>
) : !hasShakespeare && cloneUrls[0] ? (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-secondary/60"
onClick={(e) => {
e.stopPropagation();
handleCopy(cloneUrls[0]);
}}
>
<Copy className="size-3" />
{copied ? "Copied!" : "Copy Clone URL"}
</button>
) : null}
</div>
)}
+39 -38
View File
@@ -132,7 +132,7 @@ function RepostsTab({ reposts }: { reposts: RepostEntry[] }) {
return (
<div className="divide-y divide-border">
{reposts.map((repost, i) => (
<UserRow key={`${repost.pubkey}-${i}`} pubkey={repost.pubkey} subtitle={timeAgo(repost.createdAt)} />
<RepostRow key={`${repost.pubkey}-${i}`} entry={repost} />
))}
</div>
);
@@ -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 (
<Link
to={`/${nevent}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
>
<Avatar shape={avatarShape} className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm truncate">
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={entry.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</div>
<span className="text-xs text-muted-foreground">{timeAgo(entry.createdAt)}</span>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
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 (
<Link
to={`/${npub}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
>
<Avatar shape={avatarShape} className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm truncate">
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={pubkey} className="text-xs text-muted-foreground truncate" />
)}
</div>
{subtitle && (
<span className="text-xs text-muted-foreground">{subtitle}</span>
)}
</div>
</Link>
);
}
function ZapRow({ zap }: { zap: ZapEntry }) {
const author = useAuthor(zap.senderPubkey);
+17 -13
View File
@@ -374,19 +374,23 @@ function ParticipantRow({ pubkey, role }: { pubkey: string; role?: string }) {
return (
<div className="flex items-center gap-2.5">
<Link to={profileUrl} className="shrink-0">
<Avatar shape={avatarShape} className="size-7">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<Link to={profileUrl} className="text-sm font-medium hover:underline truncate">
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="shrink-0">
<Avatar shape={avatarShape} className="size-7">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="text-sm font-medium hover:underline truncate">
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
</ProfileHoverCard>
{role && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0 ml-auto">
{role}
+59 -4
View File
@@ -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<HTMLInputElement>(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 && (
<div data-mobile-search-results className="flex flex-col-reverse bg-popover/95 rounded-2xl mx-6 mb-0.5 overflow-hidden max-h-[55vh] overflow-y-auto shadow-lg">
{navItems.map((item, index) => (
<MobileNavItem
key={item.id}
item={item}
isSelected={index + navItemStartIndex === selectedIndex}
onClick={handleSelectNavItem}
/>
))}
{hasIdentifier && (
<MobileIdentifierItem
match={identifierMatch!}
@@ -314,6 +338,37 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
);
}
function MobileNavItem({
item,
isSelected,
onClick,
}: {
item: SidebarItemDef;
isSelected: boolean;
onClick: (item: SidebarItemDef) => void;
}) {
const Icon = item.icon;
return (
<button
data-search-item
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onClick(item)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="size-3.5 text-primary" />
</div>
<span className="font-semibold text-sm truncate">{item.label}</span>
</button>
);
}
/**
* Mobile autocomplete item for a detected Nostr identifier.
*/
+1 -1
View File
@@ -150,7 +150,7 @@ export function NostrEventSidebarItem({
<EventSidebarIcon kind={decoded.kind ?? 1} />
)}
</span>
<span className="truncate">
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
{isProfile ? (
<ProfileSidebarLabel pubkey={decoded.pubkey} />
) : (
+56 -2
View File
@@ -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<NostrProviderProps> = (props) => {
const { children } = props;
const { config } = useAppContext();
const { logins } = useNostrLogin();
// Create NPool instance only once
const pool = useRef<NPool | undefined>(undefined);
@@ -19,6 +22,39 @@ const NostrProvider: React.FC<NostrProviderProps> = (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<NostrSigner | undefined>(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<NostrProviderProps> = (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<URL['href'], NostrFilter[]> {
const routes = new Map<string, NostrFilter[]>();
+1
View File
@@ -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 }),
};
+200 -20
View File
@@ -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({
<PullRequestCard event={event} />
) : isCustomNip ? (
<CustomNipCard event={event} />
) : isNsite ? (
<NsiteCard event={event} />
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} compact />
) : isEncryptedDM ? (
<EncryptedMessageContent event={event} compact />
) : (
<TruncatedNoteContent
event={event}
@@ -581,7 +594,7 @@ export const NoteCard = memo(function NoteCard({
)}
</RepostMenu>
<ReactionButton
<ReactionButton
eventId={event.id}
eventPubkey={event.pubkey}
eventKind={event.kind}
@@ -851,6 +864,154 @@ export const NoteCard = memo(function NoteCard({
);
}
// ── Repost layout (kind 6 / 16) — compact activity-style card ──
if (isRepost) {
// Threaded repost (used in AncestorThread with connector line)
if (threaded || threadedLast) {
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{/* Repost icon bubble instead of avatar */}
<div className="flex items-center justify-center size-10 rounded-full bg-accent/10 shrink-0">
<RepostIcon className="size-5 text-accent" />
</div>
{threaded && (
<div className="w-0.5 flex-1 mt-2 bg-foreground/20 rounded-full" />
)}
</div>
<div
className={cn(
"flex-1 min-w-0 flex items-center min-h-10",
threaded && "pb-3",
)}
>
<div className="flex items-center gap-2">
{author.isLoading ? (
<Skeleton className="size-6 rounded-full shrink-0" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage
src={metadata?.picture}
alt={displayName}
/>
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
)}
{author.isLoading ? (
<Skeleton className="h-3.5 w-20" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
)}
<span className="text-sm text-muted-foreground">reposted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</div>
</div>
</div>
</article>
);
}
// Normal repost card (standalone or in feed)
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex items-center gap-3">
{/* Repost icon */}
<div className="flex items-center justify-center size-11 rounded-full bg-accent/10 shrink-0">
<RepostIcon className="size-5 text-accent" />
</div>
{/* Author + "reposted" label */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-4 w-24" />
</>
) : (
<>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground">reposted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</>
)}
</div>
</div>
</article>
);
}
// ── 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<number, KindHeaderConfig> = {
4: {
icon: Mail,
action: "sent an",
noun: "encrypted message",
},
37516: {
icon: ChestIcon,
action: "hid a",
@@ -1597,13 +1763,13 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
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<number, KindHeaderConfig> = {
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 ? (
<Skeleton className="h-3 w-20 inline-block" />
) : (
<Link
to={url}
className="font-medium hover:underline mr-1 truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{name}
</EmojifiedText>
) : (
name
)}
</Link>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={url}
className="font-medium hover:underline mr-1 truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{name}
</EmojifiedText>
) : (
name
)}
</Link>
</ProfileHoverCard>
)}
<span className={cn("shrink-0", author.isLoading && "ml-1")}>
{action}
+153
View File
@@ -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 <NsiteCardSkeleton />;
}
return (
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
"group block mt-2 rounded-2xl border border-border overflow-hidden",
"hover:bg-secondary/40 transition-colors",
)}
onClick={(e) => e.stopPropagation()}
>
{/* Link preview thumbnail */}
{image && (
<div className="w-full overflow-hidden bg-muted">
<img
src={image}
alt=""
className="w-full h-[180px] object-cover transition-transform duration-300 group-hover:scale-[1.02]"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = "none";
}}
/>
</div>
)}
<div className="px-3.5 py-2.5 space-y-1.5">
{/* Title with favicon */}
<div className="flex items-center gap-2 min-w-0">
<ExternalFavicon url={siteUrl} size={16} className="shrink-0" />
<p className="text-sm font-semibold leading-snug line-clamp-2">
{previewTitle || displayName}
</p>
</div>
{/* Description — prefer event description (it's curated), fall back to OEmbed author */}
{(description || preview?.author_name) && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{description || preview?.author_name}
</p>
)}
{/* Deployment stats + source link */}
<div className="flex items-center gap-3 pt-0.5 text-[11px] text-muted-foreground">
{pathTags.length > 0 && (
<span className="inline-flex items-center gap-1">
<FileText className="size-3" />
{pathTags.length} {pathTags.length === 1 ? "file" : "files"}
</span>
)}
{serverTags.length > 0 && (
<span className="inline-flex items-center gap-1">
<Server className="size-3" />
{serverTags.length} {serverTags.length === 1 ? "server" : "servers"}
</span>
)}
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
"ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded-full",
"hover:bg-primary/10 hover:text-primary transition-colors",
)}
onClick={(e) => e.stopPropagation()}
>
<Globe className="size-3" />
<span>Source</span>
</a>
)}
{!sourceUrl && (
<span className="ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded-full hover:bg-primary/10 hover:text-primary transition-colors">
<ExternalLink className="size-3" />
<span>Visit</span>
</span>
)}
</div>
</div>
</a>
);
}
function NsiteCardSkeleton() {
return (
<div className="mt-2 rounded-2xl border border-border overflow-hidden">
<Skeleton className="w-full h-[180px] rounded-none" />
<div className="px-3.5 py-2.5 space-y-1.5">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-32" />
</div>
</div>
);
}
+6 -1
View File
@@ -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] });
},
});
};
+2 -2
View File
@@ -499,7 +499,7 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo
<aside className={cn("w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3", className)}>
{/* Media Section — only shown when mediaEvents prop is provided */}
{mediaEvents !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<h2 className="text-xl font-bold mb-3">Media</h2>
<h2 className="text-xl font-bold mb-3" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Media</h2>
{mediaLoading ? (
<div className="flex flex-col gap-0.5">
{[
@@ -598,7 +598,7 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo
{/* Profile Fields Section */}
{fields && fields.length > 0 && (
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<h2 className="text-xl font-bold mb-3">Profile fields</h2>
<h2 className="text-xl font-bold mb-3" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Profile fields</h2>
<div className="space-y-4">
{fields.map((field, i) => (
<ProfileFieldRow key={i} field={field} />
+75 -8
View File
@@ -22,6 +22,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 ProfileSearchDropdownProps {
@@ -54,6 +56,7 @@ export function ProfileSearchDropdown({
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const { user } = useCurrentUser();
const { data: rawProfiles, isFetching, followedPubkeys } = useSearchProfiles(query);
// Wikipedia & Archive search (async, debounced by their hooks at >=2 chars)
@@ -68,6 +71,9 @@ export function ProfileSearchDropdown({
const countryMatchRaw = useMemo(() => searchCountry(query), [query]);
const countryMatch = hideCountry ? null : countryMatchRaw;
// 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]);
@@ -99,11 +105,11 @@ export function ProfileSearchDropdown({
// Show dropdown when we have results, or when text search is enabled and there's a query
useEffect(() => {
if (query.trim().length > 0) {
if (enableTextSearch || (profiles && profiles.length > 0) || countryMatch || wikipediaResult || archiveResult) {
if (enableTextSearch || (profiles && profiles.length > 0) || countryMatch || navItems.length > 0 || wikipediaResult || archiveResult) {
setOpen(true);
}
}
}, [profiles, query, enableTextSearch, countryMatch, wikipediaResult, archiveResult]);
}, [profiles, query, enableTextSearch, countryMatch, navItems, wikipediaResult, archiveResult]);
// Reset selected index when results change
useEffect(() => {
@@ -141,17 +147,20 @@ export function ProfileSearchDropdown({
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
}, [enableTextSearch, query, navigate]);
// Total selectable items: identifier? + URL comment? + country?(top) + profiles + country?(bottom) + wikipedia? + archive?
// Total selectable items: navItems + identifier? + URL comment? + country?(top) + profiles + country?(bottom) + wikipedia? + archive?
const hasCountry = !!countryMatch;
const hasUrlComment = queryIsUrl && enableTextSearch;
const hasIdentifier = !!identifierMatch;
const hasWikipedia = !!wikipediaResult;
const hasArchive = !!archiveResult;
const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0);
const navItemCount = navItems.length;
const totalItems = navItemCount + profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0);
// Map selectedIndex to what it refers to.
// Order: [identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?]
// Order: [...navItems, identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?]
let nextIdx = 0;
const navItemStartIndex = nextIdx;
nextIdx += navItemCount;
const identifierIndex = hasIdentifier ? nextIdx++ : -1;
const urlCommentIndex = hasUrlComment ? nextIdx++ : -1;
const countryTopIndex = (hasCountry && countryAtTop) ? nextIdx++ : -1;
@@ -197,6 +206,13 @@ export function ProfileSearchDropdown({
navigate(path);
}, [navigate]);
const handleSelectNavItem = useCallback((item: SidebarItemDef) => {
setOpen(false);
setQuery('');
inputRef.current?.blur();
navigate(item.path);
}, [navigate]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
@@ -208,7 +224,9 @@ export function ProfileSearchDropdown({
if (e.key === 'Enter') {
e.preventDefault();
if (open && 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) {
// Handled by the IdentifierItem component via its onClick
// which calls handleSelectIdentifier — trigger via DOM click
const items = listRef.current?.querySelectorAll('[data-search-item]');
@@ -303,13 +321,21 @@ export function ProfileSearchDropdown({
</div>
{/* Dropdown results — only when text search is not enabled */}
{!enableTextSearch && open && (hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && (
{!enableTextSearch && open && (navItemCount > 0 || hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && (
<div
ref={listRef}
role="listbox"
className="absolute top-full left-0 right-0 mt-1.5 z-50 rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
>
<div className="max-h-[320px] overflow-y-auto py-1">
{navItems.map((item, index) => (
<NavItem
key={item.id}
item={item}
isSelected={index + navItemStartIndex === selectedIndex}
onClick={handleSelectNavItem}
/>
))}
{hasIdentifier && (
<IdentifierItem
match={identifierMatch!}
@@ -379,6 +405,16 @@ export function ProfileSearchDropdown({
</span>
</button>
{/* Nav item results — sidebar pages matching query */}
{navItems.map((item, index) => (
<NavItem
key={item.id}
item={item}
isSelected={index + navItemStartIndex === selectedIndex}
onClick={handleSelectNavItem}
/>
))}
{/* Identifier suggestion — NIP-05, NIP-19, hex */}
{hasIdentifier && (
<IdentifierItem
@@ -448,7 +484,7 @@ export function ProfileSearchDropdown({
)}
{/* Empty state — only when text search is not enabled */}
{!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && profiles && profiles.length === 0 && (
{!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && navItemCount === 0 && profiles && profiles.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1.5 z-50 rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150">
<div className="py-6 text-center text-sm text-muted-foreground">
No profiles found
@@ -459,6 +495,37 @@ export function ProfileSearchDropdown({
);
}
function NavItem({
item,
isSelected,
onClick,
}: {
item: SidebarItemDef;
isSelected: boolean;
onClick: (item: SidebarItemDef) => void;
}) {
const Icon = item.icon;
return (
<button
data-search-item
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors cursor-pointer',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onClick(item)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-10 shrink-0 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="size-4 text-primary" />
</div>
<span className="font-semibold text-sm truncate">{item.label}</span>
</button>
);
}
/**
* Autocomplete item for a detected Nostr identifier (NIP-05, NIP-19, hex).
* Resolves the identifier in the background and renders a profile or event preview.
+6 -6
View File
@@ -212,13 +212,13 @@ export function PullRequestCard({
{/* PR description */}
{!preview && hasDescription && (
<div className="rounded-2xl border border-border overflow-hidden px-3.5 py-3">
<div className="prose prose-sm max-w-none text-foreground prose-headings:text-foreground prose-headings:font-bold prose-a:text-primary prose-img:rounded-lg">
<Markdown rehypePlugins={[rehypeSanitize]}>
{event.content}
</Markdown>
</div>
<div className="rounded-2xl border border-border overflow-hidden px-4 py-4 sidebar:px-5 sidebar:py-5">
<div className="prose prose-sm max-w-none break-words text-foreground prose-headings:text-foreground prose-headings:font-bold prose-strong:text-foreground prose-a:text-primary prose-img:rounded-lg prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:bg-muted prose-pre:text-foreground prose-code:text-[13px] prose-code:text-foreground prose-code:before:content-none prose-code:after:content-none prose-code:bg-muted prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:font-normal prose-li:marker:text-muted-foreground prose-blockquote:text-muted-foreground prose-blockquote:border-border prose-hr:border-border prose-th:text-foreground">
<Markdown rehypePlugins={[rehypeSanitize]}>
{event.content}
</Markdown>
</div>
</div>
)}
</div>
);
+19 -79
View File
@@ -1,7 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useState } from 'react';
import { X } from 'lucide-react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import {
@@ -10,17 +8,11 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { NoteContent } from '@/components/NoteContent';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { ComposeBox } from '@/components/ComposeBox';
import { LinkEmbed } from '@/components/LinkEmbed';
import { VanishCardCompact } from '@/components/VanishEventContent';
import { ProfilePreview } from '@/components/ExternalContentHeader';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
interface ReplyComposeModalProps {
@@ -42,12 +34,6 @@ interface ReplyComposeModalProps {
placeholder?: string;
}
/** Extracts image URLs from note content. */
function extractImages(content: string): string[] {
const urlRegex = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?/gi;
return content.match(urlRegex) || [];
}
export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSuccess, initialContent, initialMode, title: titleOverride, placeholder: placeholderOverride }: ReplyComposeModalProps) {
const isUrl = event instanceof URL;
const isReply = !!event;
@@ -165,7 +151,11 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
);
}
/** Compact embedded preview of the post being replied to. */
/**
* Compact embedded preview of the post being replied to.
* Delegates to the shared EmbeddedNote / EmbeddedNaddr components used by
* quote posts and hover cards, so every context renders events consistently.
*/
function EmbeddedPost({ event }: { event: NostrEvent }) {
// Kind 0 (profile) — show a profile card instead of trying to render the raw JSON content
if (event.kind === 0) {
@@ -176,70 +166,20 @@ function EmbeddedPost({ event }: { event: NostrEvent }) {
);
}
// Kind 62 (Request to Vanish) — show a compact vanish preview
if (event.kind === 62) {
return <VanishCardCompact event={event} timestamp={timeAgo(event.created_at)} className="mx-4 mb-2" />;
// Addressable events (kind 30000-39999) — use EmbeddedNaddr
if (event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
return (
<div className="mx-4 mb-2">
<EmbeddedNaddr addr={{ kind: event.kind, pubkey: event.pubkey, identifier: dTag }} />
</div>
);
}
return <EmbeddedNote event={event} />;
}
/** Compact embedded preview for regular note events. */
function EmbeddedNote({ event }: { event: NostrEvent }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(event.pubkey);
const nip05 = metadata?.nip05;
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
const images = useMemo(() => extractImages(event.content), [event.content]);
// Everything else — use EmbeddedNote (the event is already in the query cache)
return (
<div className="mx-4 mb-2 rounded-xl border border-border bg-secondary/30 overflow-hidden">
<div className="px-3 py-2.5">
{/* Author row */}
<div className="flex items-center gap-2 mb-1.5">
<Link to={`/${npub}`} className="shrink-0">
<Avatar shape={avatarShape} className="size-8">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="flex items-center gap-1 min-w-0 text-sm">
<span className="font-bold truncate">{displayName}</span>
{nip05 && (
<VerifiedNip05Text nip05={nip05} pubkey={event.pubkey} className="text-muted-foreground truncate" />
)}
{metadata?.bot && (
<span className="text-xs text-primary" title="Bot account">🤖</span>
)}
<span className="text-muted-foreground shrink-0">·</span>
<span className="text-muted-foreground shrink-0">{timeAgo(event.created_at)}</span>
</div>
</div>
{/* Content preview clamp to a few lines */}
<div className="text-sm line-clamp-4 overflow-hidden">
<NoteContent event={event} className="text-sm leading-relaxed" disableEmbeds />
</div>
{/* Show first image thumbnail if any */}
{images.length > 0 && (
<div className="mt-2 rounded-lg overflow-hidden border border-border max-w-[120px]">
<img
src={images[0]}
alt=""
className="w-full h-auto max-h-[80px] object-cover"
loading="lazy"
/>
</div>
)}
</div>
<div className="mx-4 mb-2">
<EmbeddedNote eventId={event.id} authorHint={event.pubkey} disableHoverCards />
</div>
);
}
+3 -3
View File
@@ -127,7 +127,7 @@ export function RightSidebar() {
{/* Trending Tags */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-bold text-foreground">Trends</h2>
<h2 className="text-xl font-bold text-foreground" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Trends</h2>
<Link to="/trends" className="text-sm text-primary hover:underline">View all</Link>
</div>
@@ -175,7 +175,7 @@ export function RightSidebar() {
{/* Hot Posts */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-bold text-foreground">Hot Posts</h2>
<h2 className="text-xl font-bold text-foreground" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Hot Posts</h2>
<Link to="/trends" className="text-sm text-primary hover:underline">More</Link>
</div>
@@ -206,7 +206,7 @@ export function RightSidebar() {
{/* Latest Accounts */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-bold text-foreground">New Accounts</h2>
<h2 className="text-xl font-bold text-foreground" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>New Accounts</h2>
</div>
{accountsLoading ? (
+2 -2
View File
@@ -90,7 +90,7 @@ function ItemRow({ item, onAdd, onClose }: { item: HiddenSidebarItem; onAdd: (id
className="flex items-center gap-3 flex-1 min-w-0 px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors cursor-pointer"
>
{sidebarItemIcon(item.id, 'size-5 shrink-0')}
<span className="truncate">{item.label}</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{item.label}</span>
</button>
<button
onClick={() => { onAdd(item.id); onClose(); }}
@@ -282,7 +282,7 @@ export function SidebarMoreMenu({
<div key={item.id} className="flex items-center">
<Link to={itemPath(item.id, undefined, homePage)} onClick={() => { onOpenChange(false); onNavigate?.(); }} className="flex items-center gap-3 flex-1 min-w-0 px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors">
{sidebarItemIcon(item.id, 'size-5 shrink-0')}
<span className="truncate">{item.label}</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{item.label}</span>
</Link>
<button onClick={() => { onAdd(item.id); onOpenChange(false); }} className="size-8 flex items-center justify-center shrink-0 rounded-sm text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors" title={`Add ${item.label} to sidebar`}>
<Plus className="size-4" strokeWidth={4} />
+1 -1
View File
@@ -71,7 +71,7 @@ export function SidebarNavItem({
<span className="absolute -top-1 right-0 size-2.5 bg-primary rounded-full" />
)}
</span>
<span className="truncate">{label}</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{label}</span>
</Link>
{editing && (
+1 -1
View File
@@ -61,7 +61,7 @@ export function TabButton({ label, active, onClick, disabled, className, childre
onMouseLeave={handleMouseLeave}
disabled={disabled}
className={cn(
'flex-1 py-1.5 text-center text-sm font-medium transition-colors relative px-4 whitespace-nowrap',
'flex-1 flex items-center justify-center py-1.5 text-sm font-medium transition-colors relative px-4 whitespace-nowrap',
active ? 'text-foreground' : 'text-muted-foreground',
disabled && 'opacity-50 cursor-not-allowed',
className,
+2
View File
@@ -46,6 +46,7 @@ export function ThemeContent({ event }: ThemeContentProps) {
identifier: undefined as string | undefined,
background: active.background,
font: active.font,
titleFont: active.titleFont,
sourceRef: active.sourceRef,
};
}
@@ -68,6 +69,7 @@ export function ThemeContent({ event }: ThemeContentProps) {
colors: parsed.colors,
title: parsed.title,
font: parsed.font,
titleFont: parsed.titleFont,
background: parsed.background,
};
applyCustomTheme(themeConfig);
+37 -11
View File
@@ -10,7 +10,7 @@ import type { ThemeDefinition } from '@/lib/themeEvent';
import { themePresets, coreToTokens, resolveTheme, resolveThemeConfig, type CoreThemeColors, type ThemeTokens, type ThemeConfig, type ThemesConfig, type ThemeFont, type ThemeBackground } from '@/themes';
import { hslStringToHex, hexToHslString } from '@/lib/colorUtils';
import { ColorPicker } from '@/components/ui/color-picker';
import { FontPicker } from '@/components/FontPicker';
import { FontSection } from '@/components/FontPicker';
import { BackgroundPicker } from '@/components/BackgroundPicker';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -193,6 +193,7 @@ export function ThemeGrid({
label: preset.label,
colors: preset.colors,
font: preset.font,
titleFont: preset.titleFont,
background: preset.background,
}));
@@ -211,15 +212,15 @@ export function ThemeGrid({
onSelect?.();
}, [setTheme, onSelect, onEditingThemeChange]);
const handleSelectPreset = useCallback((preset: { colors: CoreThemeColors; font?: ThemeConfig['font']; background?: ThemeConfig['background'] }) => {
const handleSelectPreset = useCallback((preset: { colors: CoreThemeColors; font?: ThemeConfig['font']; titleFont?: ThemeConfig['titleFont']; background?: ThemeConfig['background'] }) => {
onEditingThemeChange?.(null);
applyCustomTheme({ colors: preset.colors, font: preset.font, background: preset.background });
applyCustomTheme({ colors: preset.colors, font: preset.font, titleFont: preset.titleFont, background: preset.background });
onSelect?.();
}, [applyCustomTheme, onSelect, onEditingThemeChange]);
const handleSelectUserTheme = useCallback((def: ThemeDefinition) => {
onEditingThemeChange?.(def);
applyCustomTheme({ colors: def.colors, font: def.font, background: def.background, title: def.title });
applyCustomTheme({ colors: def.colors, font: def.font, titleFont: def.titleFont, background: def.background, title: def.title });
onSelect?.();
}, [applyCustomTheme, onSelect, onEditingThemeChange]);
@@ -505,6 +506,7 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }:
const [snapshot, setSnapshot] = useState<{
colors: CoreThemeColors;
font: ThemeFont | undefined;
titleFont: ThemeFont | undefined;
background: ThemeBackground | undefined;
} | null>(null);
@@ -514,6 +516,7 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }:
setSnapshot({
colors: getEffectiveColors(theme, customTheme, themes),
font: customTheme?.font,
titleFont: customTheme?.titleFont,
background: customTheme?.background,
});
} else {
@@ -526,13 +529,14 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }:
const hasChanges = snapshot !== null && (
JSON.stringify(effectiveColors) !== JSON.stringify(snapshot.colors) ||
JSON.stringify(customTheme?.font) !== JSON.stringify(snapshot.font) ||
JSON.stringify(customTheme?.titleFont) !== JSON.stringify(snapshot.titleFont) ||
JSON.stringify(customTheme?.background) !== JSON.stringify(snapshot.background)
);
/** Reset all theme values to the snapshot */
const handleReset = useCallback(() => {
if (!snapshot) return;
applyCustomTheme({ ...customTheme, colors: snapshot.colors, font: snapshot.font, background: snapshot.background });
applyCustomTheme({ ...customTheme, colors: snapshot.colors, font: snapshot.font, titleFont: snapshot.titleFont, background: snapshot.background });
}, [snapshot, customTheme, applyCustomTheme]);
/** Handle a color change from the inline editor */
@@ -564,6 +568,7 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }:
...editingTheme,
colors: effectiveColors,
font: customTheme?.font,
titleFont: customTheme?.titleFont,
background: customTheme?.background,
});
toast({ title: `"${editingTheme.title}" updated`, description: 'Your theme has been saved and republished.' });
@@ -593,6 +598,7 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }:
description: publishDescription.trim() || undefined,
colors: effectiveColors,
font: customTheme?.font,
titleFont: customTheme?.titleFont,
background: customTheme?.background,
event: {} as ThemeDefinition['event'],
});
@@ -632,6 +638,7 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }:
label: preset.label,
colors: preset.colors,
font: preset.font,
titleFont: preset.titleFont,
background: preset.background,
}));
@@ -649,14 +656,14 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }:
setTheme(id);
}, [setTheme]);
const handleSelectPreset = useCallback((preset: { colors: CoreThemeColors; font?: ThemeConfig['font']; background?: ThemeConfig['background'] }) => {
const handleSelectPreset = useCallback((preset: { colors: CoreThemeColors; font?: ThemeConfig['font']; titleFont?: ThemeConfig['titleFont']; background?: ThemeConfig['background'] }) => {
setEditingTheme(null);
applyCustomTheme({ colors: preset.colors, font: preset.font, background: preset.background });
applyCustomTheme({ colors: preset.colors, font: preset.font, titleFont: preset.titleFont, background: preset.background });
}, [applyCustomTheme]);
const handleSelectUserTheme = useCallback((def: ThemeDefinition) => {
setEditingTheme(def);
applyCustomTheme({ colors: def.colors, font: def.font, background: def.background, title: def.title });
applyCustomTheme({ colors: def.colors, font: def.font, titleFont: def.titleFont, background: def.background, title: def.title });
}, [applyCustomTheme]);
type SectionItem = {
@@ -838,7 +845,7 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }:
onScroll={updateBuilderScroll}
>
<DialogHeader className="text-center">
<DialogTitle className="text-center">{editingTheme ? 'Edit Theme' : 'New Theme'}</DialogTitle>
<DialogTitle className="text-center" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{editingTheme ? 'Edit Theme' : 'New Theme'}</DialogTitle>
<DialogDescription className="text-center">
{editingTheme
? `Editing "${editingTheme.title}"`
@@ -859,8 +866,27 @@ export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }:
))}
</div>
{/* Font */}
<FontPicker />
{/* Fonts (body + title) */}
<FontSection
bodyFont={theme === 'custom' ? customTheme?.font : undefined}
onBodyFontChange={(font) => {
const currentColors = customTheme?.colors ?? {
background: '228 20% 10%',
text: '210 40% 98%',
primary: '258 70% 60%',
};
applyCustomTheme({ ...customTheme, colors: currentColors, font });
}}
titleFont={theme === 'custom' ? customTheme?.titleFont : undefined}
onTitleFontChange={(titleFont) => {
const currentColors = customTheme?.colors ?? {
background: '228 20% 10%',
text: '210 40% 98%',
primary: '258 70% 60%',
};
applyCustomTheme({ ...customTheme, colors: currentColors, titleFont });
}}
/>
{/* Background */}
<BackgroundPicker />
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
/**
* Mailbox icon from lief a rounded post-box with a flag.
* Rendered as a standard lucide-style SVG component.
*/
export const MailboxIcon = React.forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
({ className, strokeWidth = 2, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<path d="M20 5.5A4 4 0 0 1 22 9v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a4 4 0 0 1 8 0v8a2 2 0 0 1-2 2M6 5h4" />
<path d="M14 9V5h2v1h-2" />
</svg>
),
);
MailboxIcon.displayName = 'MailboxIcon';
@@ -0,0 +1,201 @@
/**
* ColorPaletteDisplay
*
* Renders a color palette (array of hex strings) in one of 6 layouts,
* matching the approach used in mew and espy for kind 3367 color moments.
*/
import { hexLuminance } from '@/lib/colorUtils';
export type PaletteLayout =
| 'horizontal'
| 'vertical'
| 'grid'
| 'star'
| 'checkerboard'
| 'diagonalStripes';
interface ColorPaletteDisplayProps {
colors: string[];
layout?: PaletteLayout;
/** className applied to the outer wrapper — controls size/shape */
className?: string;
/** Overlay content (emoji, buttons, etc.) */
children?: React.ReactNode;
}
function gridDimensions(n: number): { cols: number; rows: number } {
if (n <= 3) return { cols: n, rows: 1 };
if (n === 4) return { cols: 2, rows: 2 };
return { cols: 3, rows: 2 };
}
function pieSliceClipPath(index: number, total: number): string {
const STEPS = 12;
const OVERLAP = 0.5;
const startAngle = (index / total) * 360 - 90 - OVERLAP;
const endAngle = ((index + 1) / total) * 360 - 90 + OVERLAP;
const points: string[] = ['50% 50%'];
for (let s = 0; s <= STEPS; s++) {
const angle = ((startAngle + (s / STEPS) * (endAngle - startAngle)) * Math.PI) / 180;
const x = 50 + 150 * Math.cos(angle);
const y = 50 + 150 * Math.sin(angle);
points.push(`${x.toFixed(2)}% ${y.toFixed(2)}%`);
}
return `polygon(${points.join(', ')})`;
}
function HorizontalLayout({ colors }: { colors: string[] }) {
return (
<div className="flex flex-col w-full h-full">
{colors.map((color, i) => (
<div key={i} className="flex-1" style={{ backgroundColor: color }} />
))}
</div>
);
}
function VerticalLayout({ colors }: { colors: string[] }) {
return (
<div className="flex w-full h-full">
{colors.map((color, i) => (
<div key={i} className="flex-1" style={{ backgroundColor: color }} />
))}
</div>
);
}
function GridLayout({ colors }: { colors: string[] }) {
const { cols, rows } = gridDimensions(colors.length);
return (
<div
className="w-full h-full"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
}}
>
{colors.map((color, i) => (
<div key={i} style={{ backgroundColor: color }} />
))}
</div>
);
}
function StarLayout({ colors }: { colors: string[] }) {
const n = colors.length;
return (
<div className="relative w-full h-full" style={{ backgroundColor: colors[0] }}>
{colors.map((color, i) => (
<div
key={i}
className="absolute inset-0"
style={{
backgroundColor: color,
clipPath: pieSliceClipPath(i, n),
}}
/>
))}
</div>
);
}
const HEX_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
function safeHex(color: string): string {
return HEX_RE.test(color) ? color : '#000000';
}
function CheckerboardLayout({ colors }: { colors: string[] }) {
const n = colors.length;
const rows = n * Math.max(2, 4 - n);
const cellSize = 20;
const cols = rows;
const svgSize = cellSize * cols;
const rects: string[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const color = safeHex(colors[(r + c) % n]);
rects.push(
`<rect x="${c * cellSize}" y="${r * cellSize}" width="${cellSize}" height="${cellSize}" fill="${color}"/>`
);
}
}
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${svgSize}' height='${svgSize}'>${rects.join('')}</svg>`;
const dataUri = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
return (
<div
className="w-full h-full"
style={{
backgroundImage: dataUri,
backgroundSize: `${(100 / cols) * cellSize}% ${(100 / rows) * cellSize}%`,
backgroundRepeat: 'repeat',
imageRendering: 'pixelated',
}}
/>
);
}
function DiagonalStripesLayout({ colors }: { colors: string[] }) {
const n = colors.length;
const W = 400;
const H = 200;
const stripeW = (W + H) / n;
const polygons = colors.map((rawColor, i) => {
const color = safeHex(rawColor);
const x0 = i * stripeW;
const x1 = (i + 1) * stripeW;
const points = [
`${x0},0`,
`${x1},0`,
`${x1 - H},${H}`,
`${x0 - H},${H}`,
].join(' ');
return `<polygon points="${points}" fill="${color}"/>`;
});
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${W} ${H}' preserveAspectRatio='none' shape-rendering='geometricPrecision'>${polygons.join('')}</svg>`;
return (
<div
className="w-full h-full"
style={{
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
}}
/>
);
}
export function ColorPaletteDisplay({
colors,
layout = 'horizontal',
className = '',
children,
}: ColorPaletteDisplayProps) {
if (colors.length === 0) return null;
const avgLum = colors.reduce((s, c) => s + hexLuminance(c), 0) / colors.length;
const emojiColorClass = avgLum > 0.5 ? 'text-black/80' : 'text-white/90';
return (
<div className={`overflow-hidden ${className}`}>
{layout === 'horizontal' && <HorizontalLayout colors={colors} />}
{layout === 'vertical' && <VerticalLayout colors={colors} />}
{layout === 'grid' && <GridLayout colors={colors} />}
{layout === 'star' && <StarLayout colors={colors} />}
{layout === 'checkerboard' && <CheckerboardLayout colors={colors} />}
{layout === 'diagonalStripes' && <DiagonalStripesLayout colors={colors} />}
{children && (
<div className={`absolute inset-0 flex items-center justify-center pointer-events-none select-none ${emojiColorClass}`}>
{children}
</div>
)}
</div>
);
}
@@ -0,0 +1,493 @@
import { useState, useMemo, useRef, useCallback, useEffect, type ReactNode } from 'react';
import { ArrowLeft, Loader2, Pencil, Send, Sticker, X } from 'lucide-react';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { FabButton } from '@/components/FabButton';
import { nip19 } from 'nostr-tools';
import { useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { toast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { backgroundTextColor } from '@/lib/colorUtils';
import {
LETTER_KIND,
FONT_OPTIONS,
LINE_HEIGHT_RATIO,
DEFAULT_STATIONERY_COLOR,
resolveStationery,
type Stationery,
type FrameStyle,
type LetterContent,
type LetterSticker,
} from '@/lib/letterTypes';
import { useLetterPreferences } from '@/hooks/useLetterPreferences';
import { useThemeStationery } from '@/hooks/useThemeStationery';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { LetterEditor } from './LetterEditor';
import { LetterStickers } from './LetterStickers';
import { StickerPicker } from './StickerPicker';
import { DrawingCanvas } from './DrawingCanvas';
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { StationeryBackground } from './StationeryBackground';
import { SendAnimation, useEnvelopeDimensions } from './SendAnimation';
/** Lightweight letter preview used inside the send animation */
function AnimationLetter({ content, width }: { content: LetterContent; width: number }) {
const { text: textColor, line: lineColor } = useStationeryColors(content.stationery);
const resolved = resolveStationery(content.stationery ?? { color: DEFAULT_STATIONERY_COLOR });
const fontFamily = resolved.fontFamily ?? FONT_OPTIONS[0].family;
const lh = Math.round(width * LINE_HEIGHT_RATIO);
return (
<div className="relative" style={{ containerType: 'inline-size', width }}>
<StationeryBackground
stationery={content.stationery}
frame={content.stationery?.frame}
frameTint={content.stationery?.frameTint}
className="rounded-2xl"
>
<div className="relative z-10 flex flex-col" style={{ aspectRatio: '5 / 4', padding: '5cqw' }}>
<p
className="whitespace-pre-wrap font-semibold tracking-wide overflow-hidden flex-1 min-h-0"
style={{
fontSize: '4.8cqw',
lineHeight: `${lh}px`,
letterSpacing: '0.06em',
paddingTop: '0.5cqw',
fontFamily,
color: textColor,
backgroundImage: `linear-gradient(to bottom, transparent ${lh - 3}px, ${lineColor} ${lh - 3}px)`,
backgroundSize: `100% ${lh}px`,
backgroundRepeat: 'repeat-y',
maxHeight: `${lh * 5}px`,
}}
>
{content.body}
</p>
{content.closing && (
<div className="flex flex-col items-end" style={{ paddingTop: '6cqw', gap: '3cqw', paddingRight: '4cqw', fontFamily }}>
<p style={{ fontSize: '4.8cqw', color: textColor }}>{content.closing}</p>
</div>
)}
</div>
</StationeryBackground>
{content.stickers && content.stickers.length > 0 && (
<LetterStickers stickers={content.stickers} />
)}
</div>
);
}
const BODY_MAX_LENGTH = 220;
/** Inline chip showing the selected recipient with avatar + name + optional clear. */
function SelectedRecipient({ pubkey, onClear }: { pubkey: string; onClear?: () => void }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-2xl bg-muted/60 min-w-0">
<Avatar className="size-6 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/15 text-[10px] font-bold text-primary">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="text-base font-medium truncate">{displayName}</span>
{onClear && (
<button
onClick={onClear}
className="text-muted-foreground hover:text-foreground shrink-0 transition-colors p-0.5"
>
<X className="size-4" strokeWidth={3} />
</button>
)}
</div>
);
}
type Overlay = 'none' | 'font' | 'stationery' | 'frame' | 'sticker' | 'draw';
interface ComposeLetterSheetProps {
onClose: () => void;
toPubkey?: string;
}
export function ComposeLetterSheet({ onClose, toPubkey }: ComposeLetterSheetProps) {
const { user } = useCurrentUser();
const { mutateAsync: createEvent } = useNostrPublish();
const queryClient = useQueryClient();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bodyAreaRef = useRef<HTMLDivElement>(null);
const initialRecipient = useMemo(() => {
if (!toPubkey) return undefined;
try {
if (toPubkey.startsWith('npub1')) {
const d = nip19.decode(toPubkey);
if (d.type === 'npub') return d.data;
}
if (/^[0-9a-f]{64}$/i.test(toPubkey)) return toPubkey;
} catch { /* ignore */ }
return undefined;
}, [toPubkey]);
const { prefs, isThemeDefault } = useLetterPreferences();
const themeStationery = useThemeStationery();
const [resolvedRecipient, setResolvedRecipient] = useState<string | undefined>(initialRecipient);
const [body, setBody] = useState('');
const [closing, setClosing] = useState(() => prefs.closing ?? 'Warmly,');
const [signature, setSignature] = useState(() => prefs.signature ?? '');
const [selectedFont, setSelectedFont] = useState(
() => FONT_OPTIONS.find((f) => f.value === prefs.font) ?? FONT_OPTIONS[0],
);
// Start from the live theme stationery immediately — don't wait for encrypted settings.
// If the user has saved a custom stationery preference, switch to it once prefs load.
const [stationery, setStationery] = useState<Stationery>(themeStationery);
const [frame, setFrame] = useState<FrameStyle>(() => prefs.frame ?? 'none');
const [frameTint, setFrameTint] = useState(() => prefs.frameTint ?? false);
const [overlay, setOverlay] = useState<Overlay>('none');
const [stickers, setStickers] = useState<LetterSticker[]>([]);
const { emojis: customEmojis } = useCustomEmojis();
const [sealing, setSealing] = useState(false);
const [sendAnimationContent, setSendAnimationContent] = useState<LetterContent | null>(null);
const envDims = useEnvelopeDimensions();
const animLetterW = envDims.letterW;
// Once encrypted settings load, apply saved stationery preference (if any).
// isThemeDefault is false only when the user has an explicit saved stationery.
const prefsLoadedRef = useRef(false);
useEffect(() => {
if (prefsLoadedRef.current) return;
if (!isThemeDefault && prefs.stationery) {
setStationery(prefs.stationery as Stationery);
prefsLoadedRef.current = true;
} else if (isThemeDefault) {
// Settings loaded and confirmed no override — stay with theme stationery.
// Keep the live theme stationery in sync if the theme changes.
prefsLoadedRef.current = true;
}
}, [isThemeDefault, prefs.stationery]);
// Keep stationery in sync with the live theme when using the theme default.
// If the user has explicitly chosen a stationery in this session, don't override it.
const userPickedStationery = useRef(false);
const handleSetStationery = useCallback((s: Stationery) => {
userPickedStationery.current = true;
setStationery(s);
}, []);
useEffect(() => {
if (!userPickedStationery.current && isThemeDefault) {
setStationery(themeStationery);
}
}, [themeStationery, isThemeDefault]);
const [textareaPadPx, setTextareaPadPx] = useState(0);
useEffect(() => {
const el = bodyAreaRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
setTextareaPadPx(Math.ceil(w * 0.005));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const canSend = !!resolvedRecipient && body.trim().length > 0 && !!user;
const handleAddSticker = useCallback((emoji: { shortcode: string; url: string }) => {
setStickers((prev) => [
...prev,
{
url: emoji.url,
shortcode: emoji.shortcode,
x: 20 + Math.random() * 60,
y: 20 + Math.random() * 60,
rotation: -15 + Math.random() * 30,
},
]);
setOverlay('none');
}, []);
const handleAddDrawing = useCallback((svg: string) => {
setStickers((prev) => [
...prev,
{
url: '',
shortcode: 'drawing',
svg,
x: 20 + Math.random() * 60,
y: 20 + Math.random() * 60,
rotation: -15 + Math.random() * 30,
},
]);
setOverlay('none');
}, []);
const handleUpdateSticker = useCallback((index: number, patch: Partial<LetterSticker>) => {
setStickers((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)));
}, []);
const handleRemoveSticker = useCallback((index: number) => {
setStickers((prev) => prev.filter((_, i) => i !== index));
}, []);
const buildLetterContent = useCallback((): LetterContent => {
const finalStationery: Stationery = {
...stationery,
...(selectedFont.family !== FONT_OPTIONS[0].family
? { fontFamily: selectedFont.family }
: {}),
...(frame && frame !== 'none' ? { frame } : {}),
...(frame && frame !== 'none' && frameTint ? { frameTint: true } : {}),
};
return {
body: body.trim(),
...(closing.trim() ? { closing: closing.trim() } : {}),
...(signature.trim() ? { signature: signature.trim() } : {}),
...(stickers.length > 0 ? { stickers } : {}),
stationery: finalStationery,
};
}, [body, closing, signature, stickers, stationery, selectedFont, frame, frameTint]);
const handleSend = async () => {
if (!canSend || !user || !resolvedRecipient) return;
if (!user.signer.nip44) {
toast({ title: "your signer doesn't support encryption yet", variant: 'destructive' });
return;
}
setSealing(true);
try {
const letterContent = buildLetterContent();
const encrypted = await user.signer.nip44.encrypt(
resolvedRecipient,
JSON.stringify(letterContent)
);
const tags: string[][] = [
['p', resolvedRecipient],
['alt', 'Encrypted letter'],
];
await createEvent({ kind: LETTER_KIND, content: encrypted, tags });
queryClient.invalidateQueries({ queryKey: ['letters-sent'] });
queryClient.invalidateQueries({ queryKey: ['letters-inbox'] });
setSendAnimationContent(letterContent);
} catch (err) {
console.error('Failed to send letter:', err);
setSealing(false);
toast({ title: "couldn't send that one", variant: 'destructive' });
}
};
const recipientAuthor = useAuthor(resolvedRecipient);
const recipientName = recipientAuthor.data?.metadata?.display_name
|| recipientAuthor.data?.metadata?.name
|| (resolvedRecipient ? genUserName(resolvedRecipient) : 'friend');
const resolvedSt = useMemo(() => resolveStationery(stationery ?? { color: DEFAULT_STATIONERY_COLOR }), [stationery]);
const bgColor = resolvedSt.color ?? DEFAULT_STATIONERY_COLOR;
const primaryColor = resolvedSt.primaryColor ?? '#7c52e0';
const textColor = resolvedSt.textColor ?? backgroundTextColor(bgColor);
// Memoize the animation letter element — only recompute when content or width changes.
// Uses sendAnimationContent directly (not a ref) so deps are exhaustive.
const animLetterElement = useMemo(
() => sendAnimationContent
? <AnimationLetter content={sendAnimationContent} width={animLetterW} />
: <AnimationLetter content={{ body: '' }} width={animLetterW} />,
[sendAnimationContent, animLetterW],
);
return (
<>
{/* Pre-render letter hidden so images/fonts are loaded before animation fires */}
<div aria-hidden className="absolute opacity-0 pointer-events-none" style={{ width: animLetterW, top: -9999 }}>
<AnimationLetter content={buildLetterContent()} width={animLetterW} />
</div>
{sendAnimationContent && (
<SendAnimation
letterElement={animLetterElement}
letterWidth={animLetterW}
recipientName={recipientName}
recipientPicture={recipientAuthor.data?.metadata?.picture}
bgColor={bgColor}
primaryColor={primaryColor}
textColor={textColor}
onComplete={onClose}
/>
)}
<div
ref={bodyAreaRef}
className="absolute inset-0 z-40 bg-background flex flex-col overflow-y-auto"
style={sendAnimationContent ? { visibility: 'hidden', pointerEvents: 'none' } : undefined}
>
<LetterEditor
state={{
selectedFont, setSelectedFont,
stationery, setStationery: handleSetStationery,
frame, setFrame,
frameTint, setFrameTint,
closing, setClosing,
signature, setSignature,
}}
overlay={overlay}
setOverlay={(o) => setOverlay(o as Overlay)}
renderToolbarButtons={(buttons: ReactNode, drawer: ReactNode) => (
<div className="sticky top-0 z-10">
{drawer}
<SubHeaderBar className="relative">
<button
onClick={onClose}
className="pl-3 pr-1 py-1.5 text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<ArrowLeft className="size-5" />
</button>
{buttons}
</SubHeaderBar>
</div>
)}
extraButtons={
<>
{customEmojis.length > 0 && (
<TabButton
label="Stickers"
active={overlay === 'sticker'}
onClick={() => setOverlay(overlay === 'sticker' ? 'none' : 'sticker')}
>
<Sticker className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
)}
<TabButton
label="Draw"
active={overlay === 'draw'}
onClick={() => setOverlay(overlay === 'draw' ? 'none' : 'draw')}
>
<Pencil className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
</>
}
extraDrawerContent={
<>
{overlay === 'sticker' && <StickerPicker onSelect={handleAddSticker} />}
{overlay === 'draw' && <DrawingCanvas onConfirm={handleAddDrawing} onCancel={() => setOverlay('none')} />}
</>
}
beforeCard={
<div className="max-w-xl mx-auto w-full px-5 pb-2 pt-4 max-sidebar:pt-[calc(20px+2.5rem)]">
<div className="flex items-center">
<span className="text-sm font-medium text-muted-foreground shrink-0 w-14">To</span>
{!initialRecipient && !resolvedRecipient ? (
<div className="flex-1">
<ProfileSearchDropdown
placeholder="search for a person..."
onSelect={(profile) => setResolvedRecipient(profile.pubkey)}
hideCountry
className="w-full"
inputClassName="rounded-2xl bg-muted/60 border-0 focus-visible:ring-2 focus-visible:ring-primary/20 text-base h-auto py-2"
/>
</div>
) : (
<SelectedRecipient
pubkey={resolvedRecipient ?? initialRecipient!}
onClear={initialRecipient ? undefined : () => setResolvedRecipient(undefined)}
/>
)}
</div>
</div>
}
bodyContent={({ lineHeightPx, stationeryTextColor: textColor, stationeryLineColor: lineColor, resolvedFontFamily: fontFamily }) => (
<textarea
ref={textareaRef}
value={body}
onFocus={() => setOverlay('none')}
onChange={(e) => {
const el = e.target;
const next = e.target.value;
if (next.length > BODY_MAX_LENGTH) {
el.value = body;
return;
}
if (next.length > body.length && el.scrollHeight > el.clientHeight) {
el.value = body;
return;
}
setBody(next);
}}
maxLength={BODY_MAX_LENGTH}
placeholder="dear friend..."
className="w-full flex-1 min-h-0 border-none shadow-none resize-none overflow-hidden focus:outline-none font-semibold tracking-wide"
style={{
paddingTop: '0.5cqw',
paddingBottom: 0,
fontSize: '4.8cqw',
lineHeight: lineHeightPx > 0 ? `${lineHeightPx}px` : '8.4cqw',
...(lineHeightPx > 0 ? { maxHeight: `${lineHeightPx * 5 + textareaPadPx}px` } : {}),
letterSpacing: '0.06em',
fontFamily,
color: textColor,
caretColor: textColor,
backgroundColor: 'transparent',
...(lineHeightPx > 0 ? {
backgroundImage: `linear-gradient(to bottom, transparent ${lineHeightPx - 3}px, ${lineColor} ${lineHeightPx - 3}px)`,
backgroundSize: `100% ${lineHeightPx}px`,
backgroundRepeat: 'repeat-y',
} : {}),
}}
/>
)}
cardOverlay={
<LetterStickers
stickers={stickers}
editable
onUpdate={handleUpdateSticker}
onRemove={handleRemoveSticker}
containerRef={bodyAreaRef}
/>
}
/>
{/* Send FAB — fixed bottom right, matches app FAB style */}
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton
onClick={handleSend}
disabled={!canSend || sealing}
title="Send letter"
icon={sealing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
{/* Desktop FAB — sticky within column */}
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
<div className="pointer-events-auto">
<FabButton
onClick={handleSend}
disabled={!canSend || sealing}
title="Send letter"
icon={sealing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
</div>
</div>
</div>
</>
);
}
+163
View File
@@ -0,0 +1,163 @@
/**
* DrawingCanvas
*
* Freehand SVG drawing tool for creating hand-drawn stickers.
* Produces a tightly-cropped, point-simplified SVG string on confirm.
*/
import { useRef, useState, useCallback } from 'react';
import { Undo2, Check, X, Eraser } from 'lucide-react';
import { type Stroke, pointsToPath, strokesToSvg } from '@/lib/svgDrawing';
const CANVAS_SIZE = 300;
const COLORS = [
'#1a1a1a', '#e53e3e', '#dd6b20', '#d69e2e',
'#38a169', '#3182ce', '#805ad5', '#d53f8c', '#f7f7f7',
];
const BRUSH_SIZES = [
{ value: 3, label: 'S' },
{ value: 6, label: 'M' },
{ value: 10, label: 'L' },
{ value: 16, label: 'XL' },
];
function pointerToSvg(e: React.PointerEvent<SVGSVGElement>, svg: SVGSVGElement): [number, number] {
const rect = svg.getBoundingClientRect();
return [
((e.clientX - rect.left) / rect.width) * CANVAS_SIZE,
((e.clientY - rect.top) / rect.height) * CANVAS_SIZE,
];
}
interface DrawingCanvasProps {
onConfirm: (svg: string) => void;
onCancel: () => void;
}
export function DrawingCanvas({ onConfirm, onCancel }: DrawingCanvasProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [strokes, setStrokes] = useState<Stroke[]>([]);
const [activeStroke, setActiveStroke] = useState<Stroke | null>(null);
const [color, setColor] = useState(COLORS[0]);
const [brushSize, setBrushSize] = useState(BRUSH_SIZES[1].value);
const handlePointerDown = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
if (!svgRef.current) return;
e.preventDefault();
(e.target as Element).setPointerCapture?.(e.pointerId);
setActiveStroke({ points: [pointerToSvg(e, svgRef.current)], color, width: brushSize });
}, [color, brushSize]);
const handlePointerMove = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
if (!activeStroke || !svgRef.current) return;
e.preventDefault();
const pt = pointerToSvg(e, svgRef.current);
setActiveStroke((prev) => prev ? { ...prev, points: [...prev.points, pt] } : prev);
}, [activeStroke]);
const handlePointerUp = useCallback(() => {
if (!activeStroke) return;
setStrokes((prev) => [...prev, activeStroke]);
setActiveStroke(null);
}, [activeStroke]);
const handleConfirm = useCallback(() => {
const svg = strokesToSvg(strokes);
if (svg) onConfirm(svg);
}, [strokes, onConfirm]);
const allStrokes = activeStroke ? [...strokes, activeStroke] : strokes;
const hasStrokes = strokes.length > 0;
const actionBtn = 'p-2.5 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed';
return (
<div className="flex flex-col gap-3">
<div className="relative mx-auto w-full max-w-[300px]">
<div className="relative rounded-2xl overflow-hidden border-2 border-dashed border-border bg-white" style={{ aspectRatio: '1' }}>
<svg
ref={svgRef}
viewBox={`0 0 ${CANVAS_SIZE} ${CANVAS_SIZE}`}
className="absolute inset-0 w-full h-full cursor-crosshair"
style={{ touchAction: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<defs>
<pattern id="drawing-grid" width="30" height="30" patternUnits="userSpaceOnUse">
<circle cx="15" cy="15" r="0.5" fill="#d4d4d4" />
</pattern>
</defs>
<rect width={CANVAS_SIZE} height={CANVAS_SIZE} fill="url(#drawing-grid)" />
{allStrokes.map((s, i) => (
<path
key={i}
d={pointsToPath(s.points)}
fill="none"
stroke={s.color}
strokeWidth={s.width}
strokeLinecap="round"
strokeLinejoin="round"
opacity={s === activeStroke ? 0.8 : 1}
/>
))}
</svg>
</div>
</div>
{/* Colors */}
<div className="flex items-center justify-center gap-1.5">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`w-7 h-7 rounded-full transition-all border-2 ${color === c ? 'border-primary scale-110 shadow-md' : 'border-transparent hover:scale-105'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
{/* Brush sizes */}
<div className="flex items-center justify-center gap-2">
{BRUSH_SIZES.map((b) => (
<button
key={b.value}
type="button"
onClick={() => setBrushSize(b.value)}
className={`flex items-center justify-center rounded-xl px-3 py-1.5 text-xs font-semibold transition-all ${brushSize === b.value ? 'bg-primary text-primary-foreground shadow-sm' : 'bg-muted text-muted-foreground hover:text-foreground'}`}
>
<span className="rounded-full inline-block mr-1.5" style={{ width: Math.max(4, b.value), height: Math.max(4, b.value), backgroundColor: brushSize === b.value ? 'currentColor' : color }} />
{b.label}
</button>
))}
</div>
{/* Actions */}
<div className="flex items-center justify-center gap-2">
<button type="button" onClick={onCancel} className={actionBtn} title="Cancel">
<X className="w-5 h-5" strokeWidth={2.5} />
</button>
<button type="button" onClick={() => { setStrokes([]); setActiveStroke(null); }} disabled={!hasStrokes} className={actionBtn} title="Clear all">
<Eraser className="w-5 h-5" strokeWidth={2.5} />
</button>
<button type="button" onClick={() => setStrokes((p) => p.slice(0, -1))} disabled={!hasStrokes} className={actionBtn} title="Undo">
<Undo2 className="w-5 h-5" strokeWidth={2.5} />
</button>
<button
type="button"
onClick={handleConfirm}
disabled={!hasStrokes}
className="px-5 py-2.5 rounded-xl bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-1.5"
>
<Check className="w-4 h-4" strokeWidth={3} />
done
</button>
</div>
</div>
);
}
+232
View File
@@ -0,0 +1,232 @@
/**
* EnvelopeCard Wii Mail-inspired envelope tile for the letters grid.
*
* A sealed envelope single rounded rectangle:
*
*
* closed flap = StationeryBackground
* (stationery)
*
* V crease line
* [seal]
* V-fold body = opaque paper
*
*
* name 3h
*
* The closed flap shows the real StationeryBackground (images, palettes, emoji).
* Below the V crease is the opaque paper body with corner folds and subtle shading.
* Avatar wax seal at the V vertex.
* Label: name left-aligned, shorthand time right-aligned.
*/
import { useMemo } from 'react';
import { Clock } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useDecryptLetter } from '@/hooks/useLetters';
import { genUserName } from '@/lib/genUserName';
import { resolveStationery, DEFAULT_STATIONERY_COLOR, type Letter } from '@/lib/letterTypes';
import { hexLuminance, darkenHex, lightenHex, blendHex } from '@/lib/colorUtils';
import { StationeryBackground } from './StationeryBackground';
interface EnvelopeCardProps {
letter: Letter;
mode: 'inbox' | 'sent';
index: number;
onClick: () => void;
}
// ---------------------------------------------------------------------------
// Color helpers
// ---------------------------------------------------------------------------
function deriveColors(bgHex: string, primaryHex: string) {
const body = blendHex(bgHex, primaryHex, 0.08);
const isDark = hexLuminance(body) < 0.45;
return {
body,
bodyLight: lightenHex(body, 0.06),
bodyDark: darkenHex(body, 0.06),
stroke: darkenHex(body, 0.20),
corner: darkenHex(body, 0.12),
shadow: darkenHex(body, 0.30),
text: isDark ? lightenHex(body, 0.55) : darkenHex(body, 0.55),
textMuted: isDark ? lightenHex(body, 0.40) : darkenHex(body, 0.40),
};
}
// ---------------------------------------------------------------------------
// Shorthand relative time — "3m", "2h", "5d", "3w", etc.
// ---------------------------------------------------------------------------
function shortTimeAgo(ts: number): string {
const secs = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (secs < 60) return 'now';
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d`;
const weeks = Math.floor(days / 7);
if (weeks < 5) return `${weeks}w`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo`;
return `${Math.floor(days / 365)}y`;
}
// ---------------------------------------------------------------------------
// Proportions — from SendAnimation's calcDims at W=200
// ---------------------------------------------------------------------------
const V_PCT = 65;
const FLAP_Y_PCT = 15;
export function EnvelopeCard({ letter, mode, index, onClick }: EnvelopeCardProps) {
const otherPubkey = mode === 'inbox' ? letter.sender : letter.recipient;
const author = useAuthor(otherPubkey);
const { data: decrypted } = useDecryptLetter(letter);
const displayName = author.data?.metadata?.name || genUserName(otherPubkey);
const avatar = author.data?.metadata?.picture;
const timeStr = shortTimeAgo(letter.timestamp);
const stationery = decrypted?.stationery;
const resolved = useMemo(() => resolveStationery(stationery ?? { color: DEFAULT_STATIONERY_COLOR }), [stationery]);
const bgColor = resolved.color;
const primaryColor = resolved.primaryColor ?? resolved.colors?.[0] ?? bgColor;
const C = useMemo(() => deriveColors(bgColor, primaryColor), [bgColor, primaryColor]);
const flapClip = `polygon(0% 0%, 100% 0%, 100% ${FLAP_Y_PCT}%, 50% ${V_PCT}%, 0% ${FLAP_Y_PCT}%)`;
return (
<button
onClick={onClick}
className="envelope-card group outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 rounded-xl w-full"
style={{ '--entrance-delay': `${index * 60}ms` } as React.CSSProperties}
title={`${mode === 'inbox' ? 'From' : 'To'} ${displayName}`}
>
{/* Envelope — single rounded rect, ~1.59:1 like SendAnimation body */}
<div
className="envelope-body relative w-full overflow-hidden rounded-xl"
style={{
aspectRatio: '200 / 136',
boxShadow: `0 4px 16px ${C.shadow}22, 0 2px 6px ${C.shadow}18`,
}}
>
{/* Layer 0: Body paper — subtle gradient for depth */}
<div
className="absolute inset-0"
style={{
background: `linear-gradient(170deg, ${C.bodyLight} 0%, ${C.body} 45%, ${C.bodyDark} 100%)`,
}}
/>
{/* Layer 1: Closed flap = StationeryBackground clipped to flap shape */}
<div
className="absolute inset-0"
style={{ clipPath: flapClip, zIndex: 1 }}
>
<StationeryBackground
stationery={stationery}
className="w-full h-full"
/>
</div>
{/* Layer 2: V-fold front pocket + crease lines + corner folds + shading */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
viewBox="0 0 200 136"
preserveAspectRatio="none"
style={{ zIndex: 2 }}
>
<defs>
<linearGradient id={`vfold-${letter.event.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={C.body} />
<stop offset="100%" stopColor={C.bodyDark} />
</linearGradient>
</defs>
{/* V-fold front pocket */}
<path
d={`M0,${FLAP_Y_PCT * 1.36} L92,${V_PCT * 1.36 - 10} Q100,${V_PCT * 1.36} 108,${V_PCT * 1.36 - 10} L200,${FLAP_Y_PCT * 1.36} L200,128 Q200,136 192,136 L8,136 Q0,136 0,128 Z`}
fill={`url(#vfold-${letter.event.id})`}
/>
{/* V crease line */}
<path
d={`M0,${FLAP_Y_PCT * 1.36} L92,${V_PCT * 1.36 - 10} Q100,${V_PCT * 1.36} 108,${V_PCT * 1.36 - 10} L200,${FLAP_Y_PCT * 1.36}`}
fill="none"
stroke={C.stroke}
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.3"
/>
{/* Corner fold diagonals */}
<path d="M2,134 L70,70" stroke={C.corner} strokeWidth="1.2" strokeLinecap="round" fill="none" opacity="0.4" />
<path d="M198,134 L130,70" stroke={C.corner} strokeWidth="1.2" strokeLinecap="round" fill="none" opacity="0.4" />
{/* Subtle bottom edge shadow */}
<rect x="0" y="132" width="200" height="4" rx="2" fill={C.shadow} opacity="0.06" />
</svg>
{/* Layer 4: Name + time inside the envelope bottom */}
<div
className="absolute left-0 right-0 bottom-0 flex flex-col items-start px-2.5 pb-1.5 pt-0.5"
style={{ zIndex: 3 }}
>
<span
className="flex items-center gap-0.5 text-[9px] font-medium leading-tight"
style={{ color: C.textMuted }}
>
<Clock className="w-2 h-2" />
{timeStr}
</span>
<span
className="text-[11px] font-semibold truncate leading-tight max-w-full"
style={{ color: C.text }}
>
{displayName}
</span>
</div>
{/* Layer 3: Avatar wax seal — positioned above V vertex */}
<div
className="absolute z-10"
style={{
left: '50%',
top: `${V_PCT - 8}%`,
transform: 'translate(-50%, -50%)',
}}
>
<div
className="rounded-full transition-transform duration-200 group-hover:scale-110 overflow-hidden"
style={{
width: 40,
height: 40,
boxShadow: `0 3px 10px ${C.shadow}77, 0 1px 3px ${C.shadow}44, inset 0 1.5px 3px rgba(255,255,255,0.3)`,
border: `3px solid ${darkenHex(primaryColor, 0.18)}`,
background: `
radial-gradient(ellipse at 35% 30%, rgba(255,255,255,0.22) 0%, transparent 50%),
radial-gradient(ellipse at 65% 70%, rgba(0,0,0,0.12) 0%, transparent 50%),
radial-gradient(circle at 50% 50%, ${primaryColor}, ${darkenHex(primaryColor, 0.18)})
`,
}}
>
{avatar ? (
<img src={avatar} alt="" className="w-full h-full object-cover" loading="lazy" />
) : (
<div className="w-full h-full flex items-center justify-center">
<img
src="/logo.svg"
alt=""
style={{ width: 22, height: 22, filter: 'brightness(0) invert(1) opacity(0.85)' }}
/>
</div>
)}
</div>
</div>
</div>
</button>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { FRAME_PRESETS, type FrameStyle } from '@/lib/letterTypes';
import { NoneFramePreview, EmojiFramePreview } from './FramePreviews';
import { Switch } from '@/components/ui/switch';
interface FramePickerProps {
frame: FrameStyle;
frameTint: boolean;
onFrameSelect: (frame: FrameStyle) => void;
onFrameTintChange: (tint: boolean) => void;
}
export function FramePicker({ frame, frameTint, onFrameSelect, onFrameTintChange }: FramePickerProps) {
const hasFrame = frame !== 'none';
return (
<div className="space-y-3">
<div className="grid grid-cols-5 gap-2">
{FRAME_PRESETS.map((fp) => {
const isSelected = frame === fp.id;
return (
<button
key={fp.id}
onClick={() => onFrameSelect(fp.id)}
title={fp.name}
className={`aspect-square rounded-2xl overflow-hidden transition-all hover:scale-105 active:scale-95 ${
isSelected ? 'scale-105 shadow-md' : 'opacity-70 hover:opacity-100'
}`}
>
{fp.id === 'none' ? (
<NoneFramePreview />
) : (
<EmojiFramePreview frameId={fp.id} />
)}
</button>
);
})}
</div>
{hasFrame && (
<div className="flex items-center justify-between px-1 pt-1">
<span className="text-sm text-muted-foreground font-medium">match stationery color</span>
<Switch checked={frameTint} onCheckedChange={onFrameTintChange} />
</div>
)}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { FRAME_PRESETS, type FrameStyle } from '@/lib/letterTypes';
const BG = '#f5e6d3';
export function NoneFramePreview() {
return (
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
<rect x="10" y="10" width="44" height="44" fill={BG} rx="4" />
{[20,28,36,44].map(y => <line key={y} x1="14" y1={y} x2="50" y2={y} stroke="#c4a88240" strokeWidth="0.8" />)}
<line x1="22" y1="32" x2="42" y2="32" stroke="#c4a882" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export function EmojiFramePreview({ frameId }: { frameId: FrameStyle }) {
const preset = FRAME_PRESETS.find(f => f.id === frameId);
if (!preset?.emojis || !preset.bgColor) return null;
const positions = [
{x:5,y:6},{x:15,y:4},{x:25,y:7},{x:35,y:4},{x:45,y:6},{x:55,y:5},
{x:5,y:58},{x:15,y:60},{x:25,y:57},{x:35,y:60},{x:45,y:58},{x:55,y:59},
{x:4,y:18},{x:6,y:30},{x:4,y:42},{x:6,y:52},
{x:58,y:18},{x:60,y:30},{x:58,y:42},{x:60,y:52},
{x:8,y:10},{x:56,y:10},{x:8,y:54},{x:56,y:54},
];
return (
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
<rect x="0" y="0" width="64" height="64" fill={preset.bgColor} rx="8" />
{positions.map((p, i) => (
<text key={i} x={p.x} y={p.y} fontSize="8" textAnchor="middle" dominantBaseline="central">
{preset.emojis![i % preset.emojis!.length]}
</text>
))}
<rect x="10" y="10" width="44" height="44" fill={BG} rx="4" />
{[20,28,36,44].map(y => <line key={y} x1="14" y1={y} x2="50" y2={y} stroke="#c4a88240" strokeWidth="0.8" />)}
</svg>
);
}
+328
View File
@@ -0,0 +1,328 @@
import { useState, useRef, useEffect, useLayoutEffect } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Mail, MailOpen, Loader2, Lock, MoreHorizontal, Link2, Trash2, Braces } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useDecryptLetter } from '@/hooks/useLetters';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { FONT_OPTIONS, LETTER_KIND, LINE_HEIGHT_RATIO, type Letter } from '@/lib/letterTypes';
import { ensureLetterFonts } from '@/lib/letterUtils';
import { StationeryBackground } from './StationeryBackground';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { LetterStickers } from './LetterStickers';
import { formatDistanceToNow } from 'date-fns';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface LetterCardProps {
letter: Letter;
mode: 'inbox' | 'sent';
}
export function LetterCard({ letter, mode }: LetterCardProps) {
const [isOpen, setIsOpen] = useState(false);
const [hasOpened, setHasOpened] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const letterRef = useRef<HTMLDivElement>(null);
const [lineHeightPx, setLineHeightPx] = useState(0);
const otherPubkey = mode === 'inbox' ? letter.sender : letter.recipient;
const author = useAuthor(otherPubkey);
const { data: decrypted, isLoading: isDecrypting } = useDecryptLetter(letter);
const content = decrypted?.content;
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const displayName = author.data?.metadata?.name || genUserName(otherPubkey);
const avatar = author.data?.metadata?.picture;
const npub = nip19.npubEncode(otherPubkey);
const noteId = nip19.noteEncode(letter.event.id);
const letterUrl = `${window.location.origin}/${noteId}`;
const isOwnLetter = user?.pubkey === letter.sender;
const handleCopyLink = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(letterUrl);
toast({ description: 'Link copied to clipboard.' });
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
const removeFn = (old: Letter[] | undefined) =>
old ? old.filter((l) => l.event.id !== letter.event.id) : [];
queryClient.setQueriesData<Letter[]>({ queryKey: ['letters-inbox'] }, removeFn);
queryClient.setQueriesData<Letter[]>({ queryKey: ['letters-sent'] }, removeFn);
publishEvent(
{
kind: 5,
content: '',
tags: [
['e', letter.event.id],
['k', String(LETTER_KIND)],
],
},
{
onError: () => {
queryClient.invalidateQueries({ queryKey: ['letters-inbox'] });
queryClient.invalidateQueries({ queryKey: ['letters-sent'] });
},
}
);
};
const effectiveStationery = decrypted?.stationery;
const effectiveFrame = effectiveStationery?.frame;
const effectiveFrameTint = effectiveStationery?.frameTint;
const timeAgo = formatDistanceToNow(new Date(letter.timestamp * 1000), { addSuffix: true });
const { text: textColor, faint: faintColor, line: lineColor } = useStationeryColors(effectiveStationery);
const rawFont = effectiveStationery?.fontFamily;
const letterFontFamily = rawFont
? (rawFont.includes(',') ? rawFont : `${rawFont}, ${FONT_OPTIONS[0].family}`)
: FONT_OPTIONS[0].family;
// Lazy-load the letter's font when decrypted content is available
useLayoutEffect(() => { ensureLetterFonts(letterFontFamily); }, [letterFontFamily]);
useEffect(() => {
const el = letterRef.current;
if (!el) return;
let raf: number;
const ro = new ResizeObserver(([entry]) => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
setLineHeightPx(Math.round(w * LINE_HEIGHT_RATIO));
});
});
ro.observe(el);
return () => { ro.disconnect(); cancelAnimationFrame(raf); };
}, [hasOpened]);
return (
<div>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this letter?</AlertDialogTitle>
<AlertDialogDescription>
This publishes a deletion request. It may not be removed from all relays.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Envelope card */}
<div className="relative">
<button
onClick={() => { setIsOpen(o => !o); setHasOpened(true); }}
className={`
w-full text-left group
rounded-3xl overflow-hidden shadow-sm transition-all duration-200
${isOpen
? 'shadow-md ring-1 ring-primary/20'
: 'hover:shadow-md hover:ring-1 hover:ring-primary/10'
}
`}
>
<StationeryBackground
stationery={effectiveStationery}
className="h-16 w-full relative"
>
<div className="absolute inset-x-0 bottom-0 h-4">
<svg viewBox="0 0 100 12" preserveAspectRatio="none" className="w-full h-full">
<path d="M0 12 L50 1 L100 12 Z" fill="hsl(var(--card))" />
</svg>
</div>
</StationeryBackground>
<div className="bg-card px-4 pb-5 pt-1">
<div className="flex items-center gap-3">
{avatar ? (
<img src={avatar} alt="" className="w-9 h-9 rounded-full object-cover ring-2 ring-background shrink-0" />
) : (
<div className="w-9 h-9 rounded-full bg-secondary flex items-center justify-center text-sm font-semibold text-secondary-foreground shrink-0">
{displayName.charAt(0).toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0 leading-none pt-1">
<div className="truncate leading-none">
<span className="text-sm font-normal text-muted-foreground">{mode === 'inbox' ? 'from ' : 'to '}</span>
<Link
to={`/${npub}`}
onClick={(e) => e.stopPropagation()}
className="text-lg font-semibold text-foreground hover:text-primary transition-colors"
>{displayName}</Link>
</div>
<span className="text-xs text-muted-foreground leading-none mt-0.5 block">{timeAgo}</span>
</div>
{isOpen ? (
<MailOpen className="w-8 h-8 text-primary shrink-0 translate-y-0.5" strokeWidth={2} />
) : (
<Mail className="w-8 h-8 text-muted-foreground shrink-0 translate-y-0.5" strokeWidth={2} />
)}
</div>
</div>
</button>
{/* Menu — top right of card, only when open */}
{isOpen && (
<div className="absolute top-2 right-2 z-20">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="p-1 rounded-full hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<MoreHorizontal className="w-5 h-5" strokeWidth={2.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleCopyLink}>
<Link2 className="w-4 h-4 mr-2" />
Copy link
</DropdownMenuItem>
{content && (
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(JSON.stringify(content, null, 2));
toast({ description: 'Decrypted JSON copied.' });
}}>
<Braces className="w-4 h-4 mr-2" />
Copy decrypted JSON
</DropdownMenuItem>
)}
{isOwnLetter && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{/* Letter — expands below the card */}
<div
style={{
overflow: 'hidden',
maxHeight: isOpen ? '800px' : '0',
transition: 'max-height 0.3s ease-in-out',
}}
aria-hidden={!isOpen}
>
<div style={{ padding: '8px 0 8px' }}>
{hasOpened && (
<div style={effectiveFrame && effectiveFrame !== 'none'
? { padding: '28px 28px 44px' }
: { padding: '0 0 0' }
}>
<div ref={letterRef} className="relative" style={{ containerType: 'inline-size' }}>
<StationeryBackground
stationery={effectiveStationery}
frame={effectiveFrame}
frameTint={effectiveFrameTint}
className="rounded-3xl shadow-inner shadow-black/5"
>
<div
className="relative z-10 flex flex-col"
style={{ aspectRatio: '5 / 4', padding: '5cqw' }}
>
{isDecrypting ? (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2.5" style={{ color: faintColor }}>
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-xs">unsealing...</span>
</div>
</div>
) : content ? (
<>
<p
className="whitespace-pre-wrap font-semibold tracking-wide overflow-hidden flex-1 min-h-0"
style={{
fontSize: '4.8cqw',
lineHeight: lineHeightPx > 0 ? `${lineHeightPx}px` : '8.4cqw',
letterSpacing: '0.06em',
paddingTop: '0.5cqw',
fontFamily: letterFontFamily,
color: textColor,
...(lineHeightPx > 0 ? {
backgroundImage: `linear-gradient(to bottom, transparent ${lineHeightPx - 3}px, ${lineColor} ${lineHeightPx - 3}px)`,
backgroundSize: `100% ${lineHeightPx}px`,
backgroundRepeat: 'repeat-y',
maxHeight: `${lineHeightPx * 5}px`,
} : {}),
backgroundPosition: '0 0',
}}
>
{content.body}
</p>
{(content.closing || content.signature) && (
<div className="flex flex-col items-end" style={{ paddingTop: '6cqw', gap: '3cqw', paddingRight: '4cqw', fontFamily: letterFontFamily }}>
{content.closing && (
<p style={{ fontSize: '4.8cqw', color: textColor }}>{content.closing}</p>
)}
{content.signature && (
<p className="font-semibold" style={{ fontSize: '5cqw', color: textColor }}>{content.signature}</p>
)}
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2" style={{ color: faintColor }}>
<Lock className="w-5 h-5" />
<p className="text-xs italic">couldn't unseal this one</p>
</div>
</div>
)}
</div>
</StationeryBackground>
{content?.stickers && content.stickers.length > 0 && (
<LetterStickers stickers={content.stickers} />
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
+318
View File
@@ -0,0 +1,318 @@
/**
* LetterDetailSheet Minimal modal showing just the letter card.
* Tap backdrop to dismiss.
*/
import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react';
import { Loader2, Lock } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useDecryptLetter } from '@/hooks/useLetters';
import { FONT_OPTIONS, LINE_HEIGHT_RATIO, COLOR_MOMENT_KIND, THEME_KIND, resolveStationery, colorMomentToStationery, themeToStationery, type Letter } from '@/lib/letterTypes';
import { hexLuminance, backgroundTextColor } from '@/lib/colorUtils';
import { ColorPaletteDisplay, type PaletteLayout } from './ColorPaletteDisplay';
import { ensureLetterFonts } from '@/lib/letterUtils';
import { StationeryBackground } from './StationeryBackground';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { LetterStickers } from './LetterStickers';
import { useTheme } from '@/hooks/useTheme';
import { toast } from '@/hooks/useToast';
import { paletteToTheme, getColors } from '@/components/ColorMomentContent';
import { parseThemeDefinition } from '@/lib/themeEvent';
import type { ThemeConfig } from '@/themes';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
// ---------------------------------------------------------------------------
// Attached gift — color moment or theme that can be applied on the spot
// ---------------------------------------------------------------------------
/** Renders an attached gift — present box overlapping a themed bubble. */
function LetterAttachment({ event }: { event: NostrEvent }) {
const { applyCustomTheme, theme, customTheme, setTheme } = useTheme();
const [applied, setApplied] = useState(false);
const prevRef = useRef<{ mode: typeof theme; config?: ThemeConfig }>();
const attachment = useMemo(() => {
if (event.kind === COLOR_MOMENT_KIND) {
const colors = getColors(event.tags);
if (colors.length < 2) return null;
const core = paletteToTheme(colors);
const stationery = colorMomentToStationery(event);
const resolved = resolveStationery(stationery);
return { type: 'color-moment' as const, label: 'Color Moment', colors, core, resolved };
}
if (event.kind === THEME_KIND) {
const parsed = parseThemeDefinition(event);
if (!parsed) return null;
const stationery = themeToStationery(event);
const resolved = resolveStationery(stationery);
return {
type: 'theme' as const,
label: parsed.title ?? 'Theme',
colors: [] as string[],
core: parsed.colors,
resolved,
themeConfig: { colors: parsed.colors, font: parsed.font, titleFont: parsed.titleFont, background: parsed.background, title: parsed.title } as ThemeConfig,
};
}
return null;
}, [event]);
const handleApply = useCallback(() => {
if (!attachment) return;
prevRef.current = { mode: theme, config: customTheme };
if (attachment.type === 'color-moment') {
applyCustomTheme(attachment.core);
} else {
applyCustomTheme(attachment.themeConfig!);
}
setApplied(true);
toast({
title: 'Theme applied',
description: `"${attachment.label}" is now your active theme.`,
action: (
<button
className="text-sm font-medium underline underline-offset-2"
onClick={() => {
const prev = prevRef.current;
if (!prev) return;
if (prev.mode === 'custom' && prev.config) applyCustomTheme(prev.config);
else setTheme(prev.mode);
setApplied(false);
}}
>
Undo
</button>
),
});
}, [attachment, theme, customTheme, applyCustomTheme, setTheme]);
if (!attachment) return null;
const { resolved } = attachment;
const bg = resolved.color;
const primary = `hsl(${attachment.core.primary})`;
// Use a visible border when the bg is very light to avoid white-on-white
const needsBorder = hexLuminance(bg) > 0.85;
const ribbonColor = attachment.type === 'color-moment'
? (attachment.colors[Math.floor(attachment.colors.length / 2)] ?? primary)
: primary;
// Derive readable text color from the scrimmed background
const textColor = backgroundTextColor(bg);
return (
<div className="relative max-w-[220px] mx-auto mt-16 pointer-events-none">
{/* Present box — overlaps the bubble top */}
<div className="flex justify-center relative z-10 mb-[-20px]">
<svg width="56" height="60" viewBox="0 0 56 60" fill="none" className="drop-shadow-sm">
{/* Box body */}
<rect x="4" y="26" width="48" height="32" rx="3" fill={bg} stroke={needsBorder ? '#0001' : 'none'} strokeWidth="1" />
<rect x="4" y="26" width="48" height="8" rx="3" fill="white" opacity="0.07" />
{/* Ribbon vertical */}
<rect x="24" y="26" width="8" height="32" fill={ribbonColor} opacity="0.8" />
{/* Ribbon horizontal */}
<rect x="4" y="38" width="48" height="6" fill={ribbonColor} opacity="0.8" />
{/* Lid */}
<rect x="2" y="18" width="52" height="10" rx="2.5" fill={bg} stroke={needsBorder ? '#0001' : ribbonColor} strokeWidth={needsBorder ? 1 : 0.5} strokeOpacity={needsBorder ? 1 : 0.2} />
<rect x="2" y="18" width="52" height="4" rx="2.5" fill="white" opacity="0.1" />
{/* Lid ribbon */}
<rect x="24" y="18" width="8" height="10" fill={ribbonColor} opacity="0.8" />
{/* Bow — left loop */}
<ellipse cx="20" cy="14" rx="8" ry="6" fill={ribbonColor} />
<ellipse cx="19.5" cy="12.5" rx="4.5" ry="3" fill="white" opacity="0.2" />
{/* Bow — right loop */}
<ellipse cx="36" cy="14" rx="8" ry="6" fill={ribbonColor} />
<ellipse cx="36.5" cy="12.5" rx="4.5" ry="3" fill="white" opacity="0.2" />
{/* Bow — knot */}
<ellipse cx="28" cy="16" rx="5" ry="4" fill={ribbonColor} />
<ellipse cx="28" cy="15" rx="2.5" ry="2" fill="white" opacity="0.15" />
</svg>
</div>
{/* Themed bubble — clickable */}
<div
onClick={handleApply}
role="button"
tabIndex={0}
title={applied ? 'Theme applied!' : `Tap to use "${attachment.label}" as your theme`}
className="relative rounded-2xl overflow-hidden pointer-events-auto cursor-pointer transition-transform duration-200 active:scale-95 hover:scale-[1.02]"
style={{
background: bg,
border: needsBorder ? '1px solid hsl(var(--border))' : `1px solid ${ribbonColor}22`,
}}
>
{/* Background: actual color moment pattern or theme image */}
{attachment.type === 'color-moment' && attachment.colors.length > 0 && (
<ColorPaletteDisplay
colors={attachment.colors}
layout={(resolved.layout as PaletteLayout) || 'horizontal'}
className="absolute inset-0"
/>
)}
{attachment.type === 'theme' && resolved.imageUrl && (
<div className="absolute inset-0">
<img
src={resolved.imageUrl}
alt=""
className="w-full h-full object-cover"
/>
</div>
)}
{/* Scrim for text readability */}
<div className="absolute inset-0" style={{ background: `${bg}bb` }} />
{/* Content */}
<div className="relative px-4 pt-7 pb-3.5 text-center">
<p className="text-xs font-semibold truncate" style={{ color: textColor }}>
{attachment.label}
</p>
<p className="text-[11px] mt-0.5" style={{ color: textColor, opacity: 0.6 }}>
{applied ? 'Applied as your theme' : 'Tap to use as theme'}
</p>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
interface LetterDetailSheetProps {
letter: Letter | null;
onClose: () => void;
}
export function LetterDetailSheet({ letter, onClose }: LetterDetailSheetProps) {
const letterRef = useRef<HTMLDivElement>(null);
const [lineHeightPx, setLineHeightPx] = useState(0);
const { data: decrypted, isLoading: isDecrypting } = useDecryptLetter(letter ?? undefined);
const content = decrypted?.content;
const effectiveStationery = decrypted?.stationery;
const effectiveFrame = effectiveStationery?.frame;
const effectiveFrameTint = effectiveStationery?.frameTint;
const { text: textColor, faint: faintColor, line: lineColor } = useStationeryColors(effectiveStationery);
const rawFont = effectiveStationery?.fontFamily;
const letterFontFamily = rawFont
? (rawFont.includes(',') ? rawFont : `${rawFont}, ${FONT_OPTIONS[0].family}`)
: FONT_OPTIONS[0].family;
// Lazy-load the letter's font when decrypted content is available
useLayoutEffect(() => { ensureLetterFonts(letterFontFamily); }, [letterFontFamily]);
// ResizeObserver for ruled line height — re-attaches when the dialog opens (letter changes)
useEffect(() => {
if (!letter) return;
// Small delay to let the Dialog portal mount and layout
const timer = setTimeout(() => {
const el = letterRef.current;
if (!el) return;
const w = el.getBoundingClientRect().width;
if (w > 0) setLineHeightPx(Math.round(w * LINE_HEIGHT_RATIO));
}, 50);
const el = letterRef.current;
if (!el) return () => clearTimeout(timer);
let raf: number;
const ro = new ResizeObserver(([entry]) => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
if (w > 0) setLineHeightPx(Math.round(w * LINE_HEIGHT_RATIO));
});
});
ro.observe(el);
return () => { clearTimeout(timer); ro.disconnect(); cancelAnimationFrame(raf); };
}, [letter]);
return (
<Dialog open={!!letter} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="p-0 gap-0 border-none bg-transparent shadow-none max-w-[calc(100vw-2rem)] sm:max-w-lg overflow-visible [&>button]:hidden">
<DialogTitle className="sr-only">Letter</DialogTitle>
<div style={effectiveFrame && effectiveFrame !== 'none'
? { padding: '28px 28px 44px' }
: { padding: '0' }
}>
<div ref={letterRef} className="relative" style={{ containerType: 'inline-size' }}>
<StationeryBackground
stationery={effectiveStationery}
frame={effectiveFrame}
frameTint={effectiveFrameTint}
className="rounded-3xl shadow-inner shadow-black/5"
>
<div
className="relative z-10 flex flex-col"
style={{ aspectRatio: '5 / 4', padding: '5cqw' }}
>
{isDecrypting ? (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2.5" style={{ color: faintColor }}>
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-xs">unsealing...</span>
</div>
</div>
) : content ? (
<>
<p
className="whitespace-pre-wrap font-semibold tracking-wide overflow-hidden flex-1 min-h-0"
style={{
fontSize: '4.8cqw',
lineHeight: lineHeightPx > 0 ? `${lineHeightPx}px` : '8.4cqw',
letterSpacing: '0.06em',
paddingTop: '0.5cqw',
fontFamily: letterFontFamily,
color: textColor,
...(lineHeightPx > 0 ? {
backgroundImage: `linear-gradient(to bottom, transparent ${lineHeightPx - 3}px, ${lineColor} ${lineHeightPx - 3}px)`,
backgroundSize: `100% ${lineHeightPx}px`,
backgroundRepeat: 'repeat-y',
maxHeight: `${lineHeightPx * 5}px`,
} : {}),
backgroundPosition: '0 0',
}}
>
{content.body}
</p>
{(content.closing || content.signature) && (
<div className="flex flex-col items-end" style={{ paddingTop: '6cqw', gap: '3cqw', paddingRight: '4cqw', fontFamily: letterFontFamily }}>
{content.closing && (
<p style={{ fontSize: '4.8cqw', color: textColor }}>{content.closing}</p>
)}
{content.signature && (
<p className="font-semibold" style={{ fontSize: '5cqw', color: textColor }}>{content.signature}</p>
)}
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2" style={{ color: faintColor }}>
<Lock className="w-5 h-5" />
<p className="text-xs italic">couldn't unseal this one</p>
</div>
</div>
)}
</div>
</StationeryBackground>
{content?.stickers && content.stickers.length > 0 && (
<LetterStickers stickers={content.stickers} />
)}
</div>
{/* Attached gift — color moment or theme */}
{effectiveStationery?.event && (
<LetterAttachment event={effectiveStationery.event} />
)}
</div>
</DialogContent>
</Dialog>
);
}
+292
View File
@@ -0,0 +1,292 @@
/**
* LetterEditor drawer and preview card for composing/previewing letters.
*
* Callers own the sticky header. LetterEditor exposes its toolbar buttons
* via the `renderToolbarButtons` render prop so callers can place them
* inline in their own header row alongside back buttons, titles, etc.
*/
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { Paintbrush } from 'lucide-react';
import { TabButton } from '@/components/TabButton';
import {
FONT_OPTIONS,
CLOSING_PRESETS,
LINE_HEIGHT_RATIO,
type Stationery,
type FrameStyle,
} from '@/lib/letterTypes';
import { StationeryBackground } from './StationeryBackground';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { StationeryPicker } from './StationeryPicker';
import { FramePicker } from './FramePicker';
import { resolveFont } from '@/lib/letterUtils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
// ---------------------------------------------------------------------------
// FrameIcon — shared SVG
// ---------------------------------------------------------------------------
export function FrameIcon({ className, strokeWidth = 2 }: { className?: string; strokeWidth?: number }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className}>
<rect x="2" y="3" width="20" height="18" rx="3" />
<rect x="5.5" y="6.5" width="13" height="11" rx="2" />
<path d="M4 5 C4.5 5.5 5 6 5.5 6.5" />
<path d="M20 5 C19.5 5.5 19 6 18.5 6.5" />
<path d="M4 19 C4.5 18.5 5 18 5.5 17.5" />
<path d="M20 19 C19.5 18.5 19 18 18.5 17.5" />
</svg>
);
}
// ---------------------------------------------------------------------------
// Overlay types — base set shared by all consumers
// ---------------------------------------------------------------------------
export type BaseOverlay = 'none' | 'font' | 'stationery' | 'frame';
// ---------------------------------------------------------------------------
// LetterEditor types
// ---------------------------------------------------------------------------
export interface LetterEditorFont {
value: string;
label: string;
family: string;
}
export interface LetterEditorState {
selectedFont: LetterEditorFont;
setSelectedFont: (f: LetterEditorFont) => void;
stationery: Stationery;
setStationery: (s: Stationery) => void;
frame: FrameStyle;
setFrame: (f: FrameStyle) => void;
frameTint: boolean;
setFrameTint: (v: boolean) => void;
closing: string;
setClosing: (v: string) => void;
signature: string;
setSignature: (v: string) => void;
}
interface LetterEditorProps {
state: LetterEditorState;
/**
* Render prop receives the toolbar button nodes and the sliding drawer so
* the caller can compose them into their own sticky header+drawer region.
* The drawer is passed separately so callers that want the arc-then-drawer
* pattern can render it outside the SubHeaderBar.
*/
renderToolbarButtons: (buttons: ReactNode, drawer: ReactNode) => ReactNode;
/** Extra buttons appended after the base Aa/paintbrush/frame buttons. */
extraButtons?: ReactNode;
/** Extra drawer panels for overlays beyond 'font'/'stationery'/'frame'. */
extraDrawerContent?: ReactNode;
/** Current overlay — managed externally so callers can add custom overlays. */
overlay: string;
setOverlay: (o: string) => void;
/** Body content rendered inside the card above the outro. */
bodyContent?: (ctx: { lineHeightPx: number; stationeryTextColor: string; stationeryLineColor: string; resolvedFontFamily: string }) => ReactNode;
/** Content rendered on top of the card (e.g. stickers layer). */
cardOverlay?: ReactNode;
/** Content rendered between the drawer and the card (e.g. recipient row). */
beforeCard?: ReactNode;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function LetterEditor({
state,
renderToolbarButtons,
extraButtons,
extraDrawerContent,
overlay,
setOverlay,
bodyContent,
cardOverlay,
beforeCard,
}: LetterEditorProps) {
const {
selectedFont, setSelectedFont,
stationery, setStationery,
frame, setFrame,
frameTint, setFrameTint,
closing, setClosing,
signature, setSignature,
} = state;
const cardRef = useRef<HTMLDivElement>(null);
const [lineHeightPx, setLineHeightPx] = useState(0);
useEffect(() => {
const el = cardRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
setLineHeightPx(Math.round(w * LINE_HEIGHT_RATIO));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const { text: stationeryTextColor, line: stationeryLineColor, fontFamily: themeFont } = useStationeryColors(stationery);
const resolvedFontFamily = resolveFont(selectedFont.family, themeFont);
const isBaseOverlay = (o: string): o is BaseOverlay => ['none', 'font', 'stationery', 'frame'].includes(o);
const drawerOpen = overlay !== 'none';
// The toolbar buttons — passed to renderToolbarButtons so the caller
// can embed them in their own sticky header row.
const toolbarButtons = (
<>
<TabButton
label="Font"
active={overlay === 'font'}
onClick={() => setOverlay(overlay === 'font' ? 'none' : 'font')}
>
<span className="text-base font-bold">Aa</span>
</TabButton>
<TabButton
label="Stationery"
active={overlay === 'stationery'}
onClick={() => setOverlay(overlay === 'stationery' ? 'none' : 'stationery')}
>
<Paintbrush className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
<TabButton
label="Frame"
active={overlay === 'frame'}
onClick={() => setOverlay(overlay === 'frame' ? 'none' : 'frame')}
>
<FrameIcon className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
{extraButtons}
</>
);
const drawer = (
<div
style={{
overflow: 'hidden',
maxHeight: drawerOpen ? (overlay === 'draw' ? '600px' : '400px') : '0',
transition: 'max-height 0.25s ease-in-out',
}}
>
<div className="max-w-xl mx-auto w-full px-4 pb-5 pt-3">
{overlay === 'font' && (
<div className="flex gap-2 flex-wrap">
{FONT_OPTIONS.map((font) => (
<button
key={font.value}
onClick={() => setSelectedFont(font)}
className={`px-4 py-2.5 rounded-2xl text-base font-medium transition-all ${
selectedFont.value === font.value
? 'bg-primary text-primary-foreground shadow-sm'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
style={{ fontFamily: font.family }}
>
{font.label}
</button>
))}
</div>
)}
{overlay === 'stationery' && (
<StationeryPicker selected={stationery} onSelect={setStationery} />
)}
{overlay === 'frame' && (
<FramePicker
frame={frame}
frameTint={frameTint}
onFrameSelect={setFrame}
onFrameTintChange={setFrameTint}
/>
)}
{!isBaseOverlay(overlay) && extraDrawerContent}
</div>
</div>
);
return (
<>
{/* Caller composes the toolbar buttons and drawer into their own sticky region */}
{renderToolbarButtons(toolbarButtons, drawer)}
{beforeCard}
<div
className="max-w-xl mx-auto w-full"
style={frame !== 'none' ? { padding: '28px 44px 44px' } : { padding: '0 16px 16px' }}
>
<div ref={cardRef} className="relative" style={{ containerType: 'inline-size' }}>
<StationeryBackground
stationery={stationery}
frame={frame}
frameTint={frameTint}
className="rounded-3xl shadow-[0_2px_12px_rgba(0,0,0,0.06)]"
>
<div className="relative z-10 flex flex-col" style={{ aspectRatio: '5 / 4', padding: '5cqw' }}>
{bodyContent?.({ lineHeightPx, stationeryTextColor, stationeryLineColor, resolvedFontFamily })}
<div className="flex flex-col items-end" style={{ paddingTop: '4cqw', gap: '3cqw', paddingRight: '4cqw' }}>
<Select value={closing || '__none__'} onValueChange={(v) => setClosing(v === '__none__' ? '' : v)}>
<SelectTrigger
className="w-auto h-auto focus:ring-0 focus:ring-offset-0 ring-0 ring-offset-0 outline-none rounded-2xl border-0 shadow-none flex-row-reverse gap-3 [&>span]:text-right"
style={{
fontSize: '4cqw',
padding: '2.5cqw 4cqw',
marginRight: '-4cqw',
color: closing ? stationeryTextColor : `${stationeryTextColor}44`,
backgroundColor: parseInt(stationeryTextColor.slice(stationeryTextColor.indexOf('(') + 1), 10) < 128
? 'rgba(0,0,0,0.07)'
: 'rgba(255,255,255,0.18)',
fontFamily: resolvedFontFamily,
}}
>
<SelectValue placeholder="Closing..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
<span className="text-muted-foreground italic">None</span>
</SelectItem>
{CLOSING_PRESETS.map((preset) => (
<SelectItem key={preset} value={preset}>
{preset}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="text"
value={signature}
onChange={(e) => setSignature(e.target.value)}
onFocus={() => setOverlay('none')}
maxLength={50}
placeholder="Your Name"
className="bg-transparent border-none font-semibold text-right focus:outline-none placeholder:opacity-60"
style={{
fontSize: '4.2cqw',
color: stationeryTextColor,
width: '60%',
fontFamily: resolvedFontFamily,
}}
/>
</div>
</div>
</StationeryBackground>
{cardOverlay}
</div>
</div>
</>
);
}
@@ -0,0 +1,204 @@
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ArrowLeft, Sparkles, RotateCcw } from 'lucide-react';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { useLetterPreferences } from '@/hooks/useLetterPreferences';
import { useThemeStationery } from '@/hooks/useThemeStationery';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import {
FONT_OPTIONS,
type Stationery,
type FrameStyle,
type SerializableStationery,
} from '@/lib/letterTypes';
import { LetterEditor, type BaseOverlay } from './LetterEditor';
/** Strip the non-serializable `event` field before persisting */
function toSerializable(s: Stationery): SerializableStationery {
const { event: _, ...rest } = s;
return rest;
}
export function LetterPreferencesSection() {
const { user } = useCurrentUser();
const navigate = useNavigate();
const { prefs, updatePrefs, resetStationery, isThemeDefault } = useLetterPreferences();
const themeStationery = useThemeStationery();
// Track whether any user-driven change has happened so we don't persist on mount
const mountedRef = useRef(false);
const [closing, setClosing] = useState(() => prefs.closing ?? 'Warmly,');
const [signature, setSignature] = useState(() => prefs.signature ?? '');
const [selectedFont, setSelectedFont] = useState(
() => FONT_OPTIONS.find((f) => f.value === prefs.font) ?? FONT_OPTIONS[0],
);
// When isThemeDefault, use the live theme stationery directly (not from prefs).
// When a custom stationery is saved, use that.
const [stationery, setStationery] = useState<Stationery>(
() => isThemeDefault ? themeStationery : (prefs.stationery as Stationery ?? themeStationery),
);
const [frame, setFrame] = useState<FrameStyle>(() => prefs.frame ?? 'none');
const [frameTint, setFrameTint] = useState(() => prefs.frameTint ?? false);
const [friendsOnlyInbox, setFriendsOnlyInbox] = useState(() => prefs.friendsOnlyInbox ?? false);
const [friendsOnlySearch, setFriendsOnlySearch] = useState(() => prefs.friendsOnlySearch ?? false);
const [overlay, setOverlay] = useState<BaseOverlay>('none');
// Keep preview in sync with the live theme when no custom stationery is saved
useEffect(() => {
if (isThemeDefault) {
setStationery(themeStationery);
}
}, [isThemeDefault, themeStationery]);
// Persist non-stationery prefs on change (skip mount).
// `updatePrefs` is intentionally omitted — its identity changes on every settings
// update (because it closes over `settings`), which would cause an infinite loop:
// effect → updatePrefs → settings change → new updatePrefs → effect.
// `user` is omitted because the guard (`!user`) short-circuits if absent and
// the component already renders a login prompt when user is null.
const updatePrefsRef = useRef(updatePrefs);
updatePrefsRef.current = updatePrefs;
useEffect(() => {
if (!mountedRef.current || !user) return;
updatePrefsRef.current({ font: selectedFont.value, frame, frameTint, closing, signature, friendsOnlyInbox, friendsOnlySearch });
}, [selectedFont, frame, frameTint, closing, signature, friendsOnlyInbox, friendsOnlySearch, user]);
// Mark as mounted
useEffect(() => { mountedRef.current = true; }, []);
// When the user picks stationery, persist it (not called on theme-sync updates
// because those go through setStationery directly, not this handler)
const handleSetStationery = (s: Stationery) => {
setStationery(s);
if (!user) return;
updatePrefs({ stationery: toSerializable(s) });
};
if (!user) {
return (
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
Log in to set letter preferences.
</div>
);
}
return (
<div className="pb-8">
<LetterEditor
state={{
selectedFont, setSelectedFont,
stationery, setStationery: handleSetStationery,
frame, setFrame,
frameTint, setFrameTint,
closing, setClosing,
signature, setSignature,
}}
overlay={overlay}
setOverlay={(o) => setOverlay(o as BaseOverlay)}
renderToolbarButtons={(buttons: ReactNode, drawer: ReactNode) => (
<div className="sticky top-0 z-50">
<div className="flex items-center gap-4 px-4 mt-4 mb-1">
<button
onClick={() => navigate('/letters')}
className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1 truncate">Letter Preferences</h1>
</div>
{drawer}
<SubHeaderBar className="relative">
{buttons}
</SubHeaderBar>
</div>
)}
beforeCard={
<div className="pt-4 max-w-xl mx-auto w-full px-5">
{isThemeDefault ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-2xl bg-primary/8 border border-primary/20 text-sm mb-3">
<Sparkles className="w-4 h-4 text-primary shrink-0" />
<span className="text-muted-foreground flex-1">
Using your{' '}
<Link to="/settings" className="text-primary font-medium hover:underline">
Ditto theme
</Link>
{' '}as stationery
</span>
</div>
) : (
<div className="flex items-center justify-between gap-2 px-3 py-2 rounded-2xl bg-muted/60 border border-border text-sm mb-3">
<span className="text-muted-foreground">Custom stationery saved</span>
<Button
variant="ghost"
size="sm"
onClick={resetStationery}
className="h-7 px-2 text-xs gap-1"
>
<RotateCcw className="w-3 h-3" />
Reset to theme
</Button>
</div>
)}
</div>
}
bodyContent={({ lineHeightPx, stationeryTextColor, stationeryLineColor, resolvedFontFamily }) => (
<div
className="flex-1 min-h-0"
style={{
...(lineHeightPx > 0 ? {
backgroundImage: `linear-gradient(to bottom, transparent ${lineHeightPx - 3}px, ${stationeryLineColor} ${lineHeightPx - 3}px)`,
backgroundSize: `100% ${lineHeightPx}px`,
backgroundRepeat: 'repeat-y',
} : {}),
}}
>
<p
className="font-semibold tracking-wide opacity-40 pointer-events-none select-none"
style={{
fontSize: '3.6cqw',
lineHeight: lineHeightPx > 0 ? `${lineHeightPx}px` : '8.4cqw',
letterSpacing: '0.04em',
fontFamily: resolvedFontFamily,
color: stationeryTextColor,
}}
>
Pick a font, stationery, and frame above. Choose a closing and sign your name below.
</p>
</div>
)}
/>
<div className="max-w-xl mx-auto w-full px-5 pt-4 space-y-8">
<p className="text-sm text-muted-foreground text-center">
These defaults apply when you start a new letter. You can always change them while composing.
</p>
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">inbox</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Friends only</p>
<p className="text-xs text-muted-foreground">Only show letters from people you follow</p>
</div>
<Switch checked={friendsOnlyInbox} onCheckedChange={setFriendsOnlyInbox} />
</div>
</div>
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">compose</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Friends only</p>
<p className="text-xs text-muted-foreground">Only suggest friends when choosing a recipient</p>
</div>
<Switch checked={friendsOnlySearch} onCheckedChange={setFriendsOnlySearch} />
</div>
</div>
</div>
</div>
);
}
+318
View File
@@ -0,0 +1,318 @@
/**
* LetterStickers
*
* Renders stickers positioned on top of a letter card. Two modes:
*
* editable=true tap a sticker to select it, revealing controls
* editable=false stickers are rendered at their saved positions (read-only).
*/
import { useRef, useCallback, useState, useEffect } from 'react';
import { X, RotateCw, Maximize2 } from 'lucide-react';
import type { LetterSticker } from '@/lib/letterTypes';
import { sanitizeSvg } from '@/lib/sanitizeSvg';
const MIN_SCALE = 0.5;
const MAX_SCALE = 4;
const BASE_SIZE_CQW = 14;
function isSafeUrl(url: string): boolean {
try {
return new URL(url).protocol === 'https:';
} catch {
return false;
}
}
function StickerMedia({ sticker, sizeCqw, className }: { sticker: LetterSticker; sizeCqw: string; className?: string }) {
if (sticker.svg) {
return (
<div
style={{ width: sizeCqw, height: sizeCqw }}
className={`sticker-svg-wrap ${className ?? ''}`}
dangerouslySetInnerHTML={{ __html: sanitizeSvg(sticker.svg) }}
/>
);
}
if (!isSafeUrl(sticker.url)) return null;
return (
<img
src={sticker.url}
alt={sticker.shortcode}
style={{ width: sizeCqw, height: sizeCqw }}
className={className}
draggable={false}
/>
);
}
function StaticSticker({ sticker }: { sticker: LetterSticker }) {
const s = sticker.scale ?? 1;
const sizeCqw = `${BASE_SIZE_CQW * s}cqw`;
return (
<div
className="absolute pointer-events-none select-none"
style={{
left: `${sticker.x}%`,
top: `${sticker.y}%`,
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
zIndex: 30,
}}
>
<StickerMedia sticker={sticker} sizeCqw={sizeCqw} className="object-contain drop-shadow-md" />
</div>
);
}
interface EditableStickerProps {
sticker: LetterSticker;
index: number;
selected: boolean;
onSelect: (index: number) => void;
onUpdate: (index: number, patch: Partial<LetterSticker>) => void;
onRemove: (index: number) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
}
function EditableSticker({
sticker,
index,
selected,
onSelect,
onUpdate,
onRemove,
containerRef,
}: EditableStickerProps) {
const s = sticker.scale ?? 1;
const sizeCqw = `${BASE_SIZE_CQW * s}cqw`;
const dragging = useRef(false);
const hasMoved = useRef(false);
const [isDragging, setIsDragging] = useState(false);
const toPercent = useCallback((clientX: number, clientY: number) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return { x: sticker.x, y: sticker.y };
return {
x: Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)),
y: Math.max(0, Math.min(100, ((clientY - rect.top) / rect.height) * 100)),
};
}, [containerRef, sticker.x, sticker.y]);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
if (!selected) {
onSelect(index);
return;
}
dragging.current = true;
hasMoved.current = false;
setIsDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [selected, index, onSelect]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!dragging.current) return;
e.preventDefault();
hasMoved.current = true;
const { x, y } = toPercent(e.clientX, e.clientY);
onUpdate(index, { x, y });
}, [index, onUpdate, toPercent]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!dragging.current) return;
dragging.current = false;
setIsDragging(false);
try { (e.target as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* */ }
}, []);
const rotateRef = useRef<{ startAngle: number; startRotation: number } | null>(null);
const centerOfSticker = useCallback(() => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return { cx: 0, cy: 0 };
return {
cx: rect.left + (sticker.x / 100) * rect.width,
cy: rect.top + (sticker.y / 100) * rect.height,
};
}, [containerRef, sticker.x, sticker.y]);
const handleRotateDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
const { cx, cy } = centerOfSticker();
const startAngle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
rotateRef.current = { startAngle, startRotation: sticker.rotation };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [centerOfSticker, sticker.rotation]);
const handleRotateMove = useCallback((e: React.PointerEvent) => {
if (!rotateRef.current) return;
e.preventDefault();
const { cx, cy } = centerOfSticker();
const currentAngle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
const delta = currentAngle - rotateRef.current.startAngle;
let newRotation = rotateRef.current.startRotation + delta;
while (newRotation > 180) newRotation -= 360;
while (newRotation < -180) newRotation += 360;
onUpdate(index, { rotation: Math.round(newRotation) });
}, [centerOfSticker, index, onUpdate]);
const handleRotateUp = useCallback((e: React.PointerEvent) => {
rotateRef.current = null;
try { (e.target as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* */ }
}, []);
const resizeRef = useRef<{ startDist: number; startScale: number } | null>(null);
const handleResizeDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
const { cx, cy } = centerOfSticker();
const dist = Math.hypot(e.clientX - cx, e.clientY - cy);
resizeRef.current = { startDist: dist, startScale: s };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [centerOfSticker, s]);
const handleResizeMove = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
e.preventDefault();
const { cx, cy } = centerOfSticker();
const dist = Math.hypot(e.clientX - cx, e.clientY - cy);
const ratio = dist / resizeRef.current.startDist;
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, resizeRef.current.startScale * ratio));
onUpdate(index, { scale: Math.round(newScale * 100) / 100 });
}, [centerOfSticker, index, onUpdate]);
const handleResizeUp = useCallback((e: React.PointerEvent) => {
resizeRef.current = null;
try { (e.target as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* */ }
}, []);
return (
<div
className={`absolute select-none ${isDragging ? 'cursor-grabbing' : selected ? 'cursor-grab' : 'cursor-pointer'}`}
style={{
left: `${sticker.x}%`,
top: `${sticker.y}%`,
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
zIndex: selected ? 50 : 30,
transition: isDragging ? 'none' : 'filter 0.15s ease-out',
touchAction: 'none',
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{selected && (
<div
className="absolute border-2 border-primary border-dashed rounded-lg pointer-events-none"
style={{ inset: -8 }}
/>
)}
<StickerMedia sticker={sticker} sizeCqw={sizeCqw} className="object-contain drop-shadow-lg" />
{selected && (
<>
<button
type="button"
onPointerDown={(e) => { e.stopPropagation(); e.preventDefault(); onRemove(index); }}
className="absolute -top-3 -right-3 w-6 h-6 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center shadow-md active:scale-90 transition-transform"
style={{ transform: `rotate(${-sticker.rotation}deg)` }}
>
<X className="w-3.5 h-3.5" strokeWidth={3} />
</button>
<div
className="absolute -bottom-3 -right-3 w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow-md cursor-alias active:scale-90 transition-transform"
style={{ transform: `rotate(${-sticker.rotation}deg)`, touchAction: 'none' }}
onPointerDown={handleRotateDown}
onPointerMove={handleRotateMove}
onPointerUp={handleRotateUp}
>
<RotateCw className="w-3.5 h-3.5" strokeWidth={2.5} />
</div>
<div
className="absolute -bottom-3 -left-3 w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow-md cursor-nwse-resize active:scale-90 transition-transform"
style={{ transform: `rotate(${-sticker.rotation}deg)`, touchAction: 'none' }}
onPointerDown={handleResizeDown}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeUp}
>
<Maximize2 className="w-3.5 h-3.5" strokeWidth={2.5} />
</div>
</>
)}
</div>
);
}
interface LetterStickersProps {
stickers: LetterSticker[];
editable?: boolean;
onUpdate?: (index: number, patch: Partial<LetterSticker>) => void;
onRemove?: (index: number) => void;
containerRef?: React.RefObject<HTMLDivElement | null>;
}
export function LetterStickers({
stickers,
editable = false,
onUpdate,
onRemove,
containerRef,
}: LetterStickersProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
useEffect(() => {
if (!editable || selectedIndex === null) return;
const container = containerRef?.current;
if (!container) return;
const handleDown = (e: PointerEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-sticker]')) return;
setSelectedIndex(null);
};
container.addEventListener('pointerdown', handleDown);
return () => container.removeEventListener('pointerdown', handleDown);
}, [editable, selectedIndex, containerRef]);
useEffect(() => {
if (selectedIndex !== null && selectedIndex >= stickers.length) {
setSelectedIndex(null);
}
}, [stickers.length, selectedIndex]);
if (stickers.length === 0) return null;
return (
<>
{stickers.map((sticker, i) =>
editable && onUpdate && onRemove && containerRef ? (
<div key={`${sticker.shortcode}-${i}`} data-sticker>
<EditableSticker
sticker={sticker}
index={i}
selected={selectedIndex === i}
onSelect={setSelectedIndex}
onUpdate={onUpdate}
onRemove={(idx) => {
onRemove(idx);
setSelectedIndex(null);
}}
containerRef={containerRef}
/>
</div>
) : (
<StaticSticker key={`${sticker.shortcode}-${i}`} sticker={sticker} />
),
)}
</>
);
}
+524
View File
@@ -0,0 +1,524 @@
/**
* SendAnimation
*
* Full-screen overlay: letter slides into envelope, flap closes, wax seal
* stamps with the Ditto logo, floats away, then shows "Sent a letter to <name>!"
*
* Envelope colors are derived from the letter's stationery (theme colors).
* The wax seal uses the stationery's primary color and the Ditto logo.
*/
import { useId, useRef, useEffect, useLayoutEffect, useCallback, useState, useMemo } from 'react';
import { hexToRgb, rgbToHex, darkenHex, blendHex } from '@/lib/colorUtils';
// ---------------------------------------------------------------------------
// Easing + animation driver
// ---------------------------------------------------------------------------
const ease = {
inOutCubic: (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
outQuart: (t: number) => 1 - Math.pow(1 - t, 4),
outQuint: (t: number) => 1 - Math.pow(1 - t, 5),
inQuad: (t: number) => t * t,
};
function animateVal(
ms: number, fn: (t: number) => void, done: () => void, e: (t: number) => number,
): () => void {
let id = 0;
const s = performance.now();
const tick = (now: number) => {
const raw = Math.min(1, (now - s) / ms);
fn(e(raw));
if (raw < 1) id = requestAnimationFrame(tick); else done();
};
id = requestAnimationFrame(tick);
return () => cancelAnimationFrame(id);
}
// ---------------------------------------------------------------------------
// Envelope dimensions — responsive, using vw-based sizing
// ---------------------------------------------------------------------------
export function useEnvelopeDimensions() {
const [dims, setDims] = useState(() => calcDims(window.innerWidth));
useEffect(() => {
const onResize = () => setDims(calcDims(window.innerWidth));
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return dims;
}
function calcDims(vw: number) {
const envW = Math.min(Math.round(vw * 0.85), 420);
const envH = Math.round(envW / 1.588);
const r = Math.round(envW * 0.041);
const s = envW / 54;
const flapY = Math.round(envH * 0.147);
const vY = Math.round(envH * 0.647);
const flapTriH = Math.round(vY * 1.08);
const letterW = Math.round(envW * 0.82);
const letterH = letterW / (5 / 4);
const strokeV = Math.round(s * 1.6 * 10) / 10;
const strokeCorner = Math.round(s * 1.4 * 10) / 10;
return { envW, envH, r, flapY, vY, flapTriH, letterW, letterH, strokeV, strokeCorner };
}
// ---------------------------------------------------------------------------
// Derive envelope palette from stationery background + primary colors
// ---------------------------------------------------------------------------
function mixHex(hex: string, lightAmount: number): string {
const [r, g, b] = hexToRgb(hex);
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * lightAmount));
return rgbToHex(mix(r), mix(g), mix(b));
}
function envelopeColors(bgHex: string, primaryHex: string) {
// Tint the envelope body slightly toward the primary color so it
// contrasts with the raw background even on matching themes.
const body = blendHex(bgHex, primaryHex, 0.08);
const inner = blendHex(bgHex, primaryHex, 0.18);
return {
body,
inner,
stroke: darkenHex(body, 0.20),
corner: darkenHex(body, 0.12),
// Seal: use primary color
sealBase: primaryHex,
sealDark: darkenHex(primaryHex, 0.12),
sealDarker: darkenHex(primaryHex, 0.22),
sealEdge: darkenHex(primaryHex, 0.18),
};
}
// ---------------------------------------------------------------------------
// Confetti particles for the confirmation screen
// ---------------------------------------------------------------------------
interface ConfettiParticle {
delay: number;
duration: number;
size: number;
startRotate: number;
color: string;
}
function generateParticles(count: number, primaryHex: string): ConfettiParticle[] {
const [r, g, b] = hexToRgb(primaryHex);
const colors = [
primaryHex,
mixHex(primaryHex, 0.25),
mixHex(primaryHex, 0.45),
darkenHex(primaryHex, 0.15),
rgbToHex(r, g, Math.min(255, b + 40)),
];
return Array.from({ length: count }, () => ({
delay: Math.random() * 1.8,
duration: 2.5 + Math.random() * 2,
size: 18 + Math.random() * 16,
startRotate: Math.random() * 360,
color: colors[Math.floor(Math.random() * colors.length)],
}));
}
function haptic(pattern: number | number[] = 30) {
try { navigator?.vibrate?.(pattern); } catch { /* unsupported */ }
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface SendAnimationProps {
/** Pre-rendered letter element to animate */
letterElement: React.ReactNode;
/** Width of the letter element in px */
letterWidth: number;
recipientName: string;
recipientPicture?: string;
/** Background hex color of the stationery (used for envelope) */
bgColor: string;
/** Primary hex color of the stationery (used for wax seal) */
primaryColor: string;
/** Text/foreground color of the stationery (used for V-fold crease lines) */
textColor: string;
onComplete: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SendAnimation({
letterElement, letterWidth,
recipientName, recipientPicture,
bgColor, primaryColor, textColor,
onComplete,
}: SendAnimationProps) {
const d = useEnvelopeDimensions();
const splatId = useId();
const [t, setT] = useState(0);
const cancelRef = useRef<() => void>();
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const C = useMemo(() => envelopeColors(bgColor, primaryColor), [bgColor, primaryColor]);
const particles = useMemo(() => generateParticles(12, primaryColor), [primaryColor]);
const sealHapticFired = useRef(false);
const cleanup = useCallback(() => { cancelRef.current?.(); cancelRef.current = undefined; }, []);
useEffect(() => () => cleanup(), [cleanup]);
useLayoutEffect(() => {
cancelRef.current = animateVal(9000, setT, () => {
onCompleteRef.current();
}, ease.inOutCubic);
return cleanup;
}, [cleanup]);
const sub = (lo: number, hi: number) => Math.max(0, Math.min(1, (t - lo) / (hi - lo)));
const envAppear = ease.outQuint(sub(0.0, 0.06));
const slideIn = ease.outQuart(sub(0.04, 0.16));
const flapClose = ease.outQuart(sub(0.15, 0.21));
const sealP = ease.inOutCubic(sub(0.18, 0.38));
const flyP = ease.inQuad(sub(0.48, 0.58));
const confirmP = ease.outQuart(sub(0.58, 0.65));
const fadeOutP = ease.inQuad(sub(0.92, 1.00));
const letterTop = -d.letterH + slideIn * (d.letterH + d.flapY);
const flapDeg = flapClose * 180;
// Seal
const sealVisible = sealP > 0;
const sealDropY = (1 - sealP) * -120;
const impactT = Math.max(0, (sealP - 0.75) / 0.25);
const sealScaleX = impactT === 0 ? 1 : impactT < 0.4 ? 1 + impactT / 0.4 * 0.08 : 1.08 - (impactT - 0.4) / 0.6 * 0.08;
const sealScaleY = impactT === 0 ? 1 : impactT < 0.4 ? 1 - impactT / 0.4 * 0.06 : 0.94 + (impactT - 0.4) / 0.6 * 0.06;
const splatScale = impactT < 0.3 ? impactT / 0.3 : 1;
const sealShadowBlur = (1 - sealP) * 24 + 4;
const sealShadowY = (1 - sealP) * 30 + 2;
if (impactT > 0 && !sealHapticFired.current) {
sealHapticFired.current = true;
haptic([15, 30, 50]);
}
// Fly
const flyY = flyP * -250;
const flyOpacity = 1 - flyP;
const flyRotate = Math.sin(flyP * Math.PI) * 2;
const sealSize = Math.round(d.envW * 0.19);
const sealHalf = sealSize / 2;
const splatSize = Math.round(sealSize * 1.3);
const stageH = d.letterH + d.flapTriH + d.envH + 60;
return (
<div
className="absolute inset-0 z-50 bg-background flex items-center justify-center overflow-hidden"
style={{ opacity: 1 - fadeOutP }}
>
{/* Envelope animation */}
<div
className="absolute inset-0 flex items-center justify-center"
style={{ opacity: 1 - confirmP }}
>
<div className="relative" style={{ width: d.envW + 40, height: stageH, marginTop: -60 }}>
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: flyP > 0 ? `translateY(${flyY}px) rotate(${flyRotate}deg)` : undefined,
opacity: flyP > 0 ? flyOpacity : 1,
}}
>
<div
className="relative"
style={{
width: d.envW, height: d.envH,
marginTop: d.flapTriH,
opacity: envAppear,
transform: `scale(${0.94 + envAppear * 0.06})`,
}}
>
{/* Flap */}
<div
style={{
position: 'absolute', bottom: '100%', left: 0,
width: d.envW, height: d.flapTriH,
transformOrigin: 'bottom center',
transform: `rotateX(${flapDeg}deg)`,
transformStyle: 'preserve-3d',
zIndex: flapDeg > 90 ? 4 : -1,
}}
>
{/* Front face: inner lining color */}
<svg
width={d.envW} height={d.flapTriH}
viewBox={`0 0 ${d.envW} ${d.flapTriH}`}
className="absolute inset-0"
style={{ backfaceVisibility: 'hidden' }}
>
<path
d={`M${d.r * 0.65},${d.flapTriH} L${d.envW / 2 - d.r},${d.r} Q${d.envW / 2},0 ${d.envW / 2 + d.r},${d.r} L${d.envW - d.r * 0.65},${d.flapTriH} Z`}
fill={C.inner}
/>
</svg>
{/* Back face: body color */}
<svg
width={d.envW} height={d.flapTriH}
viewBox={`0 0 ${d.envW} ${d.flapTriH}`}
className="absolute inset-0"
style={{ backfaceVisibility: 'hidden', transform: 'rotateX(180deg)' }}
>
<path
d={`M${d.r},0 Q0,0 0,${d.r} L0,${d.flapY} L${d.envW / 2 - d.r},${d.flapTriH - d.r} Q${d.envW / 2},${d.flapTriH} ${d.envW / 2 + d.r},${d.flapTriH - d.r} L${d.envW},${d.flapY} L${d.envW},${d.r} Q${d.envW},0 ${d.envW - d.r},0 Z`}
fill={C.body}
/>
<path
d={`M0,${d.flapY} L${d.envW / 2 - d.r},${d.flapTriH - d.r} Q${d.envW / 2},${d.flapTriH} ${d.envW / 2 + d.r},${d.flapTriH - d.r} L${d.envW},${d.flapY}`}
stroke={textColor} strokeWidth={d.strokeV} strokeLinecap="round" strokeLinejoin="round" fill="none"
/>
</svg>
</div>
{/* Back wall */}
<div
className="absolute inset-0"
style={{
backgroundColor: C.body,
borderRadius: d.r,
boxShadow: '0 8px 40px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.10), 0 0 0 1px rgba(0,0,0,0.06)',
}}
/>
{/* Inner lining */}
<div
className="absolute overflow-hidden"
style={{
top: 0, left: 0, right: 0, height: d.vY,
borderRadius: `${d.r}px ${d.r}px 0 0`,
backgroundColor: C.inner,
zIndex: 0,
}}
/>
{/* Corner fold lines */}
<div className="absolute inset-0 overflow-hidden" style={{ borderRadius: d.r, zIndex: 1 }}>
<svg className="absolute inset-0" width={d.envW} height={d.envH} viewBox={`0 0 ${d.envW} ${d.envH}`}>
<path d={`M2,${d.envH - 2} L${d.envW * 0.35},${d.envH * 0.5}`} stroke={C.corner} strokeWidth={d.strokeCorner} strokeLinecap="round" fill="none" opacity="0.5" />
<path d={`M${d.envW - 2},${d.envH - 2} L${d.envW * 0.65},${d.envH * 0.5}`} stroke={C.corner} strokeWidth={d.strokeCorner} strokeLinecap="round" fill="none" opacity="0.5" />
</svg>
</div>
{/* Letter clip */}
<div
className="absolute overflow-hidden"
style={{
top: -(d.letterH + d.flapTriH), left: 0, right: 0, bottom: 0,
borderRadius: `0 0 ${d.r}px ${d.r}px`,
zIndex: 1,
}}
>
<div
className="absolute"
style={{
left: (d.envW - letterWidth) / 2,
width: letterWidth,
top: d.letterH + d.flapTriH + letterTop,
}}
>
{letterElement}
</div>
</div>
{/* Front V-fold pocket */}
<svg
className="absolute inset-0 pointer-events-none"
width={d.envW} height={d.envH}
viewBox={`0 0 ${d.envW} ${d.envH}`}
style={{ zIndex: 2 }}
>
<path
d={`M0,${d.flapY} L${d.envW / 2 - d.r},${d.vY - d.r} Q${d.envW / 2},${d.vY} ${d.envW / 2 + d.r},${d.vY - d.r} L${d.envW},${d.flapY} L${d.envW},${d.envH - d.r} Q${d.envW},${d.envH} ${d.envW - d.r},${d.envH} L${d.r},${d.envH} Q0,${d.envH} 0,${d.envH - d.r} Z`}
fill={C.body}
/>
{/* V crease lines in stationery text color */}
<path
d={`M0,${d.flapY} L${d.envW / 2 - d.r},${d.vY - d.r} Q${d.envW / 2},${d.vY} ${d.envW / 2 + d.r},${d.vY - d.r} L${d.envW},${d.flapY}`}
fill="none"
stroke={textColor}
strokeWidth={d.strokeV}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{/* Wax seal */}
{sealVisible && (
<div
className="absolute"
style={{
left: d.envW / 2 - sealHalf,
top: d.vY - sealHalf,
width: sealSize, height: sealSize,
zIndex: 5,
transform: `translateY(${sealDropY}px) scaleX(${sealScaleX}) scaleY(${sealScaleY})`,
transformOrigin: 'center center',
}}
>
{/* Splat blob on impact */}
{impactT > 0 && (
<svg
className="absolute"
width={splatSize} height={splatSize}
viewBox="0 0 84 84"
style={{
left: -(splatSize - sealSize) / 2,
top: -(splatSize - sealSize) / 2,
transform: `scale(${0.88 + splatScale * 0.12})`,
}}
>
<defs>
<radialGradient id={splatId}>
<stop offset="0%" stopColor={C.sealBase} />
<stop offset="50%" stopColor={C.sealDark} />
<stop offset="85%" stopColor={C.sealDarker} />
<stop offset="100%" stopColor={C.sealDarker} stopOpacity="0.6" />
</radialGradient>
</defs>
<path
d="M42 3 C50 2, 58 7, 64 13 C69 18, 76 24, 78 33 C80 41, 82 48, 77 56 C73 62, 66 70, 56 73 C48 76, 40 78, 32 74 C24 71, 14 66, 9 58 C5 50, 2 42, 4 34 C6 26, 12 18, 19 12 C26 6, 34 4, 42 3 Z"
fill={`url(#${splatId})`}
/>
<path
d="M42 3 C50 2, 58 7, 64 13 C69 18, 76 24, 78 33 C80 41, 82 48, 77 56 C73 62, 66 70, 56 73 C48 76, 40 78, 32 74 C24 71, 14 66, 9 58 C5 50, 2 42, 4 34 C6 26, 12 18, 19 12 C26 6, 34 4, 42 3 Z"
fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="1"
/>
</svg>
)}
{/* Seal disc */}
<div
className="absolute rounded-full"
style={{
inset: 2,
background: `
radial-gradient(ellipse at 35% 30%, rgba(255,255,255,0.22) 0%, transparent 50%),
radial-gradient(ellipse at 65% 70%, rgba(0,0,0,0.14) 0%, transparent 50%),
radial-gradient(circle at 50% 50%, ${C.sealBase} 0%, ${C.sealDark} 55%, ${C.sealDarker} 100%)
`,
boxShadow: `
0 ${sealShadowY}px ${sealShadowBlur}px rgba(0,0,0,0.28),
0 1px 3px rgba(0,0,0,0.14),
inset 0 1.5px 2px rgba(255,255,255,0.18),
inset 0 -1.5px 2px rgba(0,0,0,0.18)
`,
border: `2px solid ${C.sealEdge}`,
}}
/>
{/* Ditto logo */}
<div
className="absolute inset-0 flex items-center justify-center"
style={{ zIndex: 1 }}
>
<img
src="/logo.svg"
alt=""
style={{
width: sealSize * 0.58,
height: sealSize * 0.58,
filter: 'brightness(0) invert(1) opacity(0.85)',
}}
/>
</div>
</div>
)}
{/* Contact shadow */}
<div
className="absolute left-4 right-4"
style={{
bottom: -4, height: 8,
background: 'radial-gradient(ellipse at center, rgba(0,0,0,0.06) 0%, transparent 70%)',
zIndex: -1, borderRadius: '50%',
}}
/>
</div>
</div>
</div>
</div>
{/* Confirmation */}
{confirmP > 0 && (
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ opacity: confirmP }}
>
<div className="relative text-center space-y-5 px-6" style={{ transform: `translateY(${(1 - confirmP) * 12}px)` }}>
<div className="relative mx-auto" style={{ width: 96, height: 96 }}>
{/* Confetti burst */}
{particles.map((p, i) => {
const angle = (i / particles.length) * 360 + p.startRotate * 0.3;
const rad = (angle * Math.PI) / 180;
const dist = 60 + p.size * 3;
const tx = Math.cos(rad) * dist;
const ty = Math.sin(rad) * dist;
return (
<svg
key={i}
className="absolute pointer-events-none"
width={p.size} height={p.size}
viewBox="0 0 24 24"
fill="none"
style={{
left: '50%', top: '50%',
marginLeft: -p.size / 2, marginTop: -p.size / 2,
opacity: 0,
animation: `letter-send-burst ${p.duration}s ease-out ${p.delay * 0.4}s both`,
'--burst-tx': `${tx}px`,
'--burst-ty': `${ty}px`,
'--burst-rot': `${p.startRotate}deg`,
} as React.CSSProperties}
>
<circle cx="12" cy="12" r="5" fill={p.color} opacity={0.8} />
</svg>
);
})}
{/* Avatar */}
<div
className="w-24 h-24 rounded-full border-4 flex items-center justify-center overflow-hidden"
style={{
backgroundColor: bgColor,
borderColor: C.stroke,
animation: 'letter-send-avatar-in 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) both',
}}
>
{recipientPicture ? (
<img src={recipientPicture} alt="" className="w-full h-full object-cover" />
) : (
<img src="/logo.svg" alt="" style={{ width: 44, height: 44, opacity: 0.5 }} />
)}
</div>
</div>
<p
className="text-2xl font-bold text-foreground"
style={{ opacity: 0, animation: 'letter-send-fade-up 0.5s ease-out 0.3s forwards' }}
>
Sent a letter to {recipientName}!
</p>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,328 @@
/**
* StationeryBackground
*
* Renders a letter's stationery based on its source type:
*
* preset flat color + faint tiled emoji backsplash
* color-moment ColorPaletteDisplay with the actual layout
* theme flat background color + optional image
*
* Frame styles overlay on top.
*/
import { useMemo } from 'react';
import {
FRAME_PRESETS,
DEFAULT_STATIONERY_COLOR,
type Stationery,
type ResolvedStationery,
type FrameStyle,
resolveStationery,
} from '@/lib/letterTypes';
import { ColorPaletteDisplay, type PaletteLayout } from './ColorPaletteDisplay';
import { hexLuminance, darkenHex } from '@/lib/colorUtils';
export type { PaletteLayout } from './ColorPaletteDisplay';
const DEFAULT_STATIONERY: Stationery = { color: DEFAULT_STATIONERY_COLOR };
function frameTintColor(resolved: ResolvedStationery): string {
if (resolved.primaryColor) return resolved.primaryColor;
if (resolved.colors && resolved.colors.length >= 1) return resolved.colors[0];
return resolved.color ?? '#3a7a3a';
}
interface EmojiFrameProps {
tint: string | null;
thickness: number;
emojis: string[];
defaultBg: string;
}
function EmojiFrame({ tint, thickness, emojis, defaultBg }: EmojiFrameProps) {
const t = thickness;
const bgColor = tint ? darkenHex(tint, 0.45) : defaultBg;
const flowers = useMemo(() => {
const gap = 48;
const row1 = 8;
const row2 = t - 4;
const items: { emoji: string; left: string; top: string; size: number; rot: number }[] = [];
let ei = 0;
const next = () => emojis[ei++ % emojis.length];
const place = (left: string, top: string) => {
items.push({ emoji: next(), left, top, size: 40, rot: 0 });
};
for (const d of [row1, row2]) {
for (let x = 8; x < 800; x += gap) place(`${x}px`, `${d}px`);
for (let x = 8; x < 800; x += gap) place(`${x}px`, `calc(100% - ${d}px)`);
}
for (const d of [row1, row2]) {
for (let y = t + gap; y < 800; y += gap) place(`${d}px`, `${y}px`);
for (let y = t + gap; y < 800; y += gap) place(`calc(100% - ${d}px)`, `${y}px`);
}
return items;
}, [emojis, t]);
return (
<div
className="absolute pointer-events-none select-none overflow-hidden"
aria-hidden
style={{
inset: -t,
borderRadius: '2rem',
zIndex: -1,
backgroundColor: bgColor,
}}
>
{flowers.map((f, i) => (
<span
key={i}
style={{
position: 'absolute',
left: f.left,
top: f.top,
fontSize: `${f.size}px`,
transform: `rotate(${f.rot}deg) translate(-50%, -50%)`,
lineHeight: 1,
}}
>
{f.emoji}
</span>
))}
{tint && (
<div
style={{
position: 'absolute',
inset: 0,
backgroundColor: tint,
mixBlendMode: 'color',
opacity: 0.6,
}}
/>
)}
</div>
);
}
function sanitizeEmoji(raw: string): string {
return [...raw].filter((ch) => /\p{Emoji_Presentation}|\p{Extended_Pictographic}/u.test(ch)).join('').slice(0, 8);
}
function EmojiBacksplash({ emoji }: { emoji: string }) {
const safe = sanitizeEmoji(emoji);
if (!safe) return null;
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'><text x='12' y='44' font-size='32' opacity='0.10'>${safe}</text></svg>`;
return (
<div
className="absolute inset-0 pointer-events-none overflow-hidden select-none"
aria-hidden
style={{
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
backgroundSize: '64px 64px',
backgroundRepeat: 'repeat',
}}
/>
);
}
function EmojiEmblem({ emoji }: { emoji: string }) {
return (
<div
className="absolute inset-0 flex items-center justify-center pointer-events-none select-none"
aria-hidden
>
<span style={{ fontSize: '4rem', lineHeight: 1, opacity: 0.10, transform: 'scale(3.5)', display: 'inline-block' }}>{emoji}</span>
</div>
);
}
interface StationeryBackgroundProps {
stationery?: Stationery;
frame?: FrameStyle;
frameTint?: boolean;
className?: string;
children?: React.ReactNode;
}
function FrameRenderer({ frame, tint, thickness }: { frame: FrameStyle; tint: string | null; thickness: number }) {
const preset = FRAME_PRESETS.find(f => f.id === frame);
if (!preset?.emojis || !preset.bgColor) return null;
return <EmojiFrame key={frame} tint={tint} thickness={thickness} emojis={preset.emojis} defaultBg={preset.bgColor} />;
}
export function StationeryBackground({
stationery,
frame,
frameTint = false,
className = '',
children,
}: StationeryBackgroundProps) {
const s = resolveStationery(stationery ?? DEFAULT_STATIONERY);
const hasColors = s.colors && s.colors.length >= 2;
const tint = frameTint ? frameTintColor(s) : null;
const frameThickness = 28;
const containerStyle: React.CSSProperties = hasColors
? {}
: { backgroundColor: s.color };
const radiusClasses = className.match(/rounded-\S+/g)?.join(' ') ?? '';
const hasFrame = frame && frame !== 'none';
return (
<div className={hasFrame ? `isolate relative ${className}` : `relative ${className}`} style={hasFrame ? { overflow: 'visible' } : { overflow: 'hidden', ...containerStyle }}>
{hasFrame && <FrameRenderer frame={frame} tint={tint} thickness={frameThickness} />}
<div
className={hasFrame ? `relative overflow-hidden ${radiusClasses}` : ''}
style={hasFrame ? containerStyle : {}}
>
{hasColors && (
<ColorPaletteDisplay
colors={s.colors!}
layout={(s.layout as PaletteLayout) ?? 'horizontal'}
className="absolute inset-0 w-full h-full"
/>
)}
{hasColors && s.emoji && (
s.emojiMode === 'emblem'
? <EmojiEmblem emoji={s.emoji} />
: <EmojiBacksplash emoji={s.emoji} />
)}
{!hasColors && s.imageUrl && (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${s.imageUrl})`,
backgroundSize: s.imageMode === 'tile' ? 'auto' : 'cover',
backgroundPosition: 'center',
backgroundRepeat: s.imageMode === 'tile' ? 'repeat' : 'no-repeat',
opacity: 0.5,
}}
/>
)}
{!hasColors && s.emoji && (
s.emojiMode === 'emblem'
? <EmojiEmblem emoji={s.emoji} />
: <EmojiBacksplash emoji={s.emoji} />
)}
{children}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// StationeryPreview — picker grid swatch
// ---------------------------------------------------------------------------
interface StationeryPreviewProps {
stationery: Stationery;
selected?: boolean;
className?: string;
}
function ThemeMockup({ stationery }: { stationery: Stationery }) {
const s = resolveStationery(stationery);
const bg = s.color;
const text = s.textColor ?? '#333333';
const primary = s.primaryColor ?? '#4CAF50';
const isDark = hexLuminance(bg) < 0.3;
const cardBg = isDark ? `${bg}dd` : '#ffffffcc';
return (
<div className="absolute inset-0 p-1.5 flex flex-col gap-1" style={{ backgroundColor: bg }}>
{s.imageUrl && (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${s.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
opacity: 0.35,
}}
/>
)}
<div className="relative z-10 h-2 rounded-sm" style={{ backgroundColor: cardBg }} />
<div
className="relative z-10 flex-1 rounded-sm p-1 flex flex-col justify-between overflow-hidden"
style={{ backgroundColor: cardBg }}
>
<div className="space-y-0.5">
<div className="h-1 w-3/4 rounded-full opacity-60" style={{ backgroundColor: text }} />
<div className="h-1 w-1/2 rounded-full opacity-40" style={{ backgroundColor: text }} />
</div>
<div className="h-2 w-8 rounded-sm" style={{ backgroundColor: primary }} />
</div>
<div
className="absolute right-0 top-0 bottom-0 w-3 z-10"
style={{ backgroundColor: primary, opacity: 0.6 }}
/>
</div>
);
}
export function StationeryPreview({
stationery,
selected,
className = '',
}: StationeryPreviewProps) {
const s = resolveStationery(stationery);
const ringClass = selected
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: '';
if (s.colors && s.colors.length >= 2) {
return (
<div className={`relative overflow-hidden rounded-2xl ${ringClass} ${className}`}>
<ColorPaletteDisplay
colors={s.colors}
layout={(s.layout as PaletteLayout) ?? 'horizontal'}
className="w-full h-full"
>
{s.emoji && (
<span className="text-xl drop-shadow select-none">{s.emoji}</span>
)}
</ColorPaletteDisplay>
</div>
);
}
if (s.textColor || s.primaryColor) {
return (
<div className={`relative overflow-hidden rounded-2xl ${ringClass} ${className}`}>
<ThemeMockup stationery={stationery} />
</div>
);
}
return (
<div
className={`relative overflow-hidden rounded-2xl ${ringClass} ${className}`}
style={{ backgroundColor: s.color }}
>
{s.imageUrl && (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${s.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
{s.emoji && !s.imageUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none">
<span className="text-3xl opacity-70">{s.emoji}</span>
</div>
)}
</div>
);
}
+427
View File
@@ -0,0 +1,427 @@
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { NostrEvent } from '@nostrify/nostrify';
import {
STATIONERY_PRESETS,
COLOR_MOMENT_KIND,
THEME_KIND,
type Stationery,
colorMomentToStationery,
themeToStationery,
presetToStationery,
resolveStationery,
} from '@/lib/letterTypes';
import { useColorMomentsPage, useThemesPage } from '@/hooks/useStationery';
import { useFollowList } from '@/hooks/useFollowActions';
import { StationeryPreview } from './StationeryBackground';
const PAGE_SIZE = 24;
const PRESET_ENTRIES = Object.entries(STATIONERY_PRESETS);
// ---------------------------------------------------------------------------
// Paginated color moments grid with infinite scroll
// ---------------------------------------------------------------------------
function ColorMomentsGrid({
selectedStationery,
onSelect,
authors,
}: {
selectedStationery?: Stationery;
onSelect: (s: Stationery) => void;
authors?: string[];
}) {
const [pages, setPages] = useState<NostrEvent[][]>([]);
const [until, setUntil] = useState<number | undefined>(undefined);
const [hasMore, setHasMore] = useState(true);
const lastUntilRef = useRef<number | 'init' | undefined>('init');
const { data: page, isLoading } = useColorMomentsPage(PAGE_SIZE, until, authors);
useEffect(() => {
if (!page || isLoading) return;
if (lastUntilRef.current === until) return;
lastUntilRef.current = until;
if (page.length > 0) {
setPages((prev) => [...prev, page]);
if (page.length < PAGE_SIZE) setHasMore(false);
} else {
setHasMore(false);
}
}, [page, isLoading, until]);
const allItems = pages.flat();
const initialized = pages.length > 0 || (!isLoading && lastUntilRef.current !== 'init');
const loadMore = useCallback(() => {
if (!hasMore || isLoading || allItems.length === 0) return;
setUntil(allItems[allItems.length - 1].created_at - 1);
}, [hasMore, isLoading, allItems]);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelCallback = useCallback(
(node: HTMLDivElement | null) => {
observerRef.current?.disconnect();
if (!node) return;
observerRef.current = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) loadMore(); },
{ threshold: 0.1 }
);
observerRef.current.observe(node);
},
[loadMore]
);
if (!initialized && isLoading) {
return (
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="aspect-square rounded-2xl" />)}
</div>
);
}
if (initialized && allItems.length === 0) {
return <p className="py-6 text-center text-sm text-muted-foreground">none found try presets</p>;
}
return (
<div className="overflow-y-auto rounded-xl" style={{ height: 160 }}>
<div className="grid grid-cols-5 gap-2">
{allItems.map((event) => {
const stationery = colorMomentToStationery(event);
const isSelected = selectedStationery?.event?.id === event.id;
const name = event.tags.find(([n]) => n === 'name')?.[1];
return (
<button
key={event.id}
onClick={() => onSelect(stationery)}
title={name}
className="relative aspect-square rounded-2xl overflow-hidden transition-all hover:scale-105 active:scale-95"
>
<StationeryPreview
stationery={stationery}
selected={isSelected}
className="w-full h-full"
/>
</button>
);
})}
{hasMore && (
<div ref={sentinelCallback} className="col-span-5 h-2">
{isLoading && Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-2xl" />
))}
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Paginated themes grid with infinite scroll
// ---------------------------------------------------------------------------
function ThemesGrid({
selectedStationery,
onSelect,
authors,
}: {
selectedStationery?: Stationery;
onSelect: (s: Stationery) => void;
authors?: string[];
}) {
const [pages, setPages] = useState<NostrEvent[][]>([]);
const [until, setUntil] = useState<number | undefined>(undefined);
const [hasMore, setHasMore] = useState(true);
const lastUntilRef = useRef<number | 'init' | undefined>('init');
const { data: page, isLoading } = useThemesPage(PAGE_SIZE, until, authors);
useEffect(() => {
if (!page || isLoading) return;
if (lastUntilRef.current === until) return;
lastUntilRef.current = until;
if (page.length > 0) {
setPages((prev) => [...prev, page]);
if (page.length < PAGE_SIZE) setHasMore(false);
} else {
setHasMore(false);
}
}, [page, isLoading, until]);
const allItems = pages.flat();
const initialized = pages.length > 0 || (!isLoading && lastUntilRef.current !== 'init');
const loadMore = useCallback(() => {
if (!hasMore || isLoading || allItems.length === 0) return;
setUntil(allItems[allItems.length - 1].created_at - 1);
}, [hasMore, isLoading, allItems]);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelCallback = useCallback(
(node: HTMLDivElement | null) => {
observerRef.current?.disconnect();
if (!node) return;
observerRef.current = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) loadMore(); },
{ threshold: 0.1 }
);
observerRef.current.observe(node);
},
[loadMore]
);
if (!initialized && isLoading) {
return (
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="aspect-square rounded-2xl" />)}
</div>
);
}
if (initialized && allItems.length === 0) {
return <p className="py-6 text-center text-sm text-muted-foreground">none found try presets</p>;
}
return (
<div className="overflow-y-auto rounded-xl" style={{ height: 160 }}>
<div className="grid grid-cols-5 gap-2">
{allItems.map((event) => {
const stationery = themeToStationery(event);
const isSelected = selectedStationery?.event?.id === event.id;
const title = event.tags.find(([n]) => n === 'title')?.[1];
return (
<button
key={event.id}
onClick={() => onSelect(stationery)}
title={title}
className="relative aspect-square rounded-2xl overflow-hidden transition-all hover:scale-105 active:scale-95"
>
<StationeryPreview
stationery={stationery}
selected={isSelected}
className="w-full h-full"
/>
</button>
);
})}
{hasMore && (
<div ref={sentinelCallback} className="col-span-5 h-2">
{isLoading && Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-2xl" />
))}
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main StationeryPicker
// ---------------------------------------------------------------------------
interface StationeryPickerProps {
selected?: Stationery;
onSelect: (stationery: Stationery) => void;
}
type Tab = 'presets' | 'colors' | 'themes';
export function StationeryPicker({ selected, onSelect }: StationeryPickerProps) {
const [tab, setTab] = useState<Tab>('presets');
const [scope, setScope] = useState<'everyone' | 'friends' | 'mine'>('everyone');
const [infoOpen, setInfoOpen] = useState(false);
const { user } = useCurrentUser();
const followListData = useFollowList();
const followPubkeyArray = followListData.data?.pubkeys;
const followList = useMemo(() => new Set(followPubkeyArray ?? []), [followPubkeyArray]);
const scopedAuthors = useMemo(() => {
if (scope === 'mine') return user ? [user.pubkey] : undefined;
if (scope === 'friends') return followList.size > 0 ? Array.from(followList) : undefined;
return undefined;
}, [scope, user, followList]);
const resolved = selected ? resolveStationery(selected) : undefined;
const emojiMode = selected?.emojiMode ?? 'tile';
const hasEmoji = !!resolved?.emoji;
const isColorMoment = selected?.event?.kind === COLOR_MOMENT_KIND;
const isTheme = selected?.event?.kind === THEME_KIND;
const isSingleColor = isColorMoment && selected?.colors !== undefined && selected.colors.length === 0;
const toggleSingleColor = () => {
if (!selected || !isColorMoment) return;
if (isSingleColor) {
const { colors: _, ...rest } = selected;
onSelect(rest as Stationery);
} else {
onSelect({ ...selected, colors: [] });
}
};
const toggleEmojiMode = () => {
if (!selected) return;
onSelect({ ...selected, emojiMode: emojiMode === 'tile' ? 'emblem' : 'tile' });
};
const tabs: { id: Tab; label: string }[] = [
{ id: 'presets', label: 'presets' },
{ id: 'colors', label: 'moments' },
{ id: 'themes', label: 'themes' },
];
const isMomentsTab = tab === 'colors';
const isThemesTab = tab === 'themes';
const showInfoButton = isMomentsTab || isThemesTab;
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => { setTab(t.id); setScope('everyone'); }}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all border ${
tab === t.id
? 'bg-foreground text-background border-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground border-secondary'
}`}
>
{t.label}
</button>
))}
{showInfoButton && (
<button
onClick={() => setInfoOpen(true)}
className="ml-auto opacity-70 hover:opacity-100 transition-opacity text-xs text-muted-foreground font-medium px-2 py-1 rounded-full bg-secondary"
>
{isMomentsTab ? 'about' : 'about'}
</button>
)}
</div>
{showInfoButton && (
<div className="flex gap-1">
{(['everyone', 'friends', ...(user ? ['mine' as const] : [])] as const).map((s) => (
<button
key={s}
onClick={() => setScope(s)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-all border ${
scope === s
? 'bg-foreground text-background border-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground border-secondary'
}`}
>
{s}
</button>
))}
</div>
)}
<div>
{tab === 'presets' && (
<div className="rounded-xl">
<div className="grid grid-cols-5 gap-2">
{PRESET_ENTRIES.map(([key, preset]) => {
const stationery: Stationery = { color: preset.color, emoji: preset.emoji };
return (
<button
key={key}
onClick={() => onSelect(presetToStationery(key) ?? stationery)}
title={preset.name}
className="relative aspect-square rounded-2xl overflow-hidden transition-all hover:scale-105 active:scale-95"
>
<StationeryPreview
stationery={stationery}
selected={selected?.color === preset.color && !selected?.event}
className="w-full h-full"
/>
</button>
);
})}
</div>
</div>
)}
{tab === 'colors' && <ColorMomentsGrid key={scope} selectedStationery={selected} onSelect={onSelect} authors={scopedAuthors} />}
{tab === 'themes' && <ThemesGrid key={scope} selectedStationery={selected} onSelect={onSelect} authors={scopedAuthors} />}
</div>
{((hasEmoji && !isTheme) || isColorMoment) && (
<div className="flex items-center gap-4 px-1 pt-1">
{hasEmoji && !isTheme && (
<label className="flex items-center gap-1.5">
<Switch checked={emojiMode === 'emblem'} onCheckedChange={toggleEmojiMode} />
<span className="text-sm text-muted-foreground font-medium">emblem</span>
</label>
)}
{isColorMoment && (
<label className="flex items-center gap-1.5">
<Switch checked={isSingleColor} onCheckedChange={toggleSingleColor} />
<span className="text-sm text-muted-foreground font-medium">flat</span>
</label>
)}
</div>
)}
<Dialog open={infoOpen} onOpenChange={setInfoOpen}>
<DialogContent className="max-w-xs rounded-2xl">
{isMomentsTab && (
<>
<DialogHeader>
<DialogTitle>Color moments</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
Color moments are beautiful color combinations created and shared by the community. Each one gives your letter a unique palette and mood.
</p>
<p>
<Link
to="/colors"
onClick={() => setInfoOpen(false)}
className="text-foreground font-medium underline underline-offset-2 hover:no-underline"
>
Discover and create color moments
</Link>
</p>
</div>
</>
)}
{isThemesTab && (
<>
<DialogHeader>
<DialogTitle>Ditto themes</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
Ditto themes are UI themes shared by the community. Letters borrows their colors and fonts to style your letter.
</p>
<p>
<Link
to="/themes"
onClick={() => setInfoOpen(false)}
className="text-foreground font-medium underline underline-offset-2 hover:no-underline"
>
Browse and create themes
</Link>
</p>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Sticker, Info } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
interface StickerPickerProps {
onSelect: (emoji: CustomEmoji) => void;
}
export function StickerPicker({ onSelect }: StickerPickerProps) {
const { emojis, isLoading } = useCustomEmojis();
const [infoOpen, setInfoOpen] = useState(false);
return (
<div className="space-y-2">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-muted-foreground px-1">my stickers</span>
<button
onClick={() => setInfoOpen(true)}
className="ml-auto opacity-60 hover:opacity-100 transition-opacity"
aria-label="About stickers"
>
<Info className="w-5 h-5" strokeWidth={2.5} />
</button>
</div>
{isLoading && (
<div className="grid grid-cols-4 gap-1.5">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-xl" />
))}
</div>
)}
{!isLoading && emojis.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
<Sticker className="size-8 opacity-40" />
<p className="text-sm">no sticker packs yet</p>
<p className="text-xs opacity-70">add emoji packs to your profile to use stickers</p>
</div>
)}
{!isLoading && emojis.length > 0 && (
<ScrollArea className="h-[200px]">
<div className="grid grid-cols-4 gap-1.5 p-1">
{emojis.map((emoji) => (
<button
key={emoji.shortcode}
type="button"
title={emoji.shortcode}
onClick={() => onSelect(emoji)}
className="aspect-square rounded-xl overflow-hidden hover:bg-muted/80 transition-all p-1.5 group active:scale-90"
>
<img
src={emoji.url}
alt={emoji.shortcode}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
loading="lazy"
/>
</button>
))}
</div>
</ScrollArea>
)}
<Dialog open={infoOpen} onOpenChange={setInfoOpen}>
<DialogContent className="max-w-xs rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sticker className="w-5 h-5 shrink-0" />
Stickers
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
Stickers are custom emoji packs (NIP-30) published on Nostr. They get placed on top of your letter as decorations.
</p>
<p>
Add emoji packs to your profile to make them available as stickers.
</p>
<p>
<Link
to="/emojis"
onClick={() => setInfoOpen(false)}
className="text-foreground font-medium underline underline-offset-2 hover:no-underline"
>
Browse emoji packs
</Link>
</p>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useRef, useState } from 'react';
interface TiltState {
rotateX: number;
rotateY: number;
scale: number;
}
const INITIAL: TiltState = { rotateX: 0, rotateY: 0, scale: 1 };
/**
* Provides a 3D perspective-tilt effect driven by the mouse position
* relative to the element. Returns a ref to attach to the container,
* a style object for the `transform`, and pointer event handlers.
*
* @param maxTilt Maximum rotation in degrees (default 20)
* @param scaleFactor Scale multiplier on hover (default 1.05)
*/
export function useCardTilt(maxTilt = 20, scaleFactor = 1.05) {
const ref = useRef<HTMLDivElement>(null);
const [tilt, setTilt] = useState<TiltState>(INITIAL);
const frameRef = useRef<number>(0);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el) return;
// Throttle to one update per animation frame
cancelAnimationFrame(frameRef.current);
frameRef.current = requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
// Normalise to -1 … 1 from centre
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = ((e.clientY - rect.top) / rect.height) * 2 - 1;
setTilt({
// Positive Y-mouse → negative rotateX (tilts top away)
rotateX: -y * maxTilt,
// Positive X-mouse → positive rotateY (tilts right side away)
rotateY: x * maxTilt,
scale: scaleFactor,
});
});
},
[maxTilt, scaleFactor],
);
const handlePointerLeave = useCallback(() => {
cancelAnimationFrame(frameRef.current);
setTilt(INITIAL);
}, []);
const style: React.CSSProperties = {
transform: `perspective(600px) rotateX(${tilt.rotateX}deg) rotateY(${tilt.rotateY}deg) scale3d(${tilt.scale}, ${tilt.scale}, ${tilt.scale})`,
transition: tilt.scale === 1 ? 'transform 0.5s cubic-bezier(0.22, 1, 0.36, 1)' : 'transform 0.1s ease-out',
willChange: 'transform',
};
return {
ref,
style,
onPointerMove: handlePointerMove,
onPointerLeave: handlePointerLeave,
} as const;
}
+3
View File
@@ -8,6 +8,7 @@ import { useCurrentUser } from './useCurrentUser';
import type { Theme, FeedSettings, ContentWarningPolicy, SavedFeed } from '@/contexts/AppContext';
import type { ThemeConfig } from '@/themes';
import type { ContentFilter } from './useContentFilters';
import type { LetterPreferences } from '@/lib/letterTypes';
import { EncryptedSettingsSchema } from '@/lib/schemas';
/**
@@ -75,6 +76,8 @@ export interface EncryptedSettings {
sentryDsn?: string;
/** Saved feed tabs created from the search page. */
savedFeeds?: SavedFeed[];
/** Letter preferences (stationery, font, frame, closing, signature, inbox filters) */
letterPreferences?: LetterPreferences;
}
/**
+5
View File
@@ -30,6 +30,11 @@ export function useFeedTab<T extends string = string>(
}
}
} catch { /* sessionStorage unavailable */ }
// Validate the default tab against validTabs. If it's not in the list,
// fall back to the last valid tab (typically 'global').
if (validTabs && !validTabs.includes(defaultTab)) {
return validTabs[validTabs.length - 1];
}
return defaultTab;
});
+36
View File
@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { useEncryptedSettings } from './useEncryptedSettings';
import type { LetterPreferences } from '@/lib/letterTypes';
/**
* Persists per-user letter preferences in the encrypted settings event (NIP-78 kind 30078).
*
* `isThemeDefault` is true when no stationery has been explicitly saved callers
* should use `useThemeStationery()` as the live preview source in that case.
*/
export function useLetterPreferences() {
const { settings, updateSettings } = useEncryptedSettings();
// Raw saved prefs — stationery may be undefined if never set
const prefs: LetterPreferences = settings?.letterPreferences ?? {};
/** True when no stationery has been explicitly saved — use the active Ditto theme. */
const isThemeDefault = !prefs.stationery;
const updatePrefs = useCallback(
(patch: Partial<LetterPreferences>) => {
const current: LetterPreferences = settings?.letterPreferences ?? {};
updateSettings.mutate({ letterPreferences: { ...current, ...patch } });
},
[settings, updateSettings],
);
/** Remove the saved stationery, reverting to the active Ditto theme. */
const resetStationery = useCallback(() => {
const current: LetterPreferences = settings?.letterPreferences ?? {};
const { stationery: _removed, ...rest } = current;
updateSettings.mutate({ letterPreferences: rest });
}, [settings, updateSettings]);
return { prefs, updatePrefs, resetStationery, isThemeDefault };
}
+209
View File
@@ -0,0 +1,209 @@
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import {
LETTER_KIND,
type Letter,
type LetterContent,
type Stationery,
} from '@/lib/letterTypes';
const PAGE_SIZE = 50;
const EMPTY_DELETED = new Set<string>();
/** Parse a letter event into a Letter object (without decrypting).
* All presentation data (stationery, frame, font) is inside the
* encrypted content and populated later by useDecryptLetter. */
function parseLetterEvent(event: NostrEvent): Letter | null {
if (event.kind !== LETTER_KIND) return null;
const recipient = event.tags.find(([name]) => name === 'p')?.[1];
if (!recipient) return null;
return {
event,
recipient,
sender: event.pubkey,
decrypted: false,
timestamp: event.created_at,
};
}
/** Collect event IDs targeted by the user's kind 5 deletion requests. */
function getDeletedIds(deletionEvents: NostrEvent[]): Set<string> {
const ids = new Set<string>();
for (const event of deletionEvents) {
for (const [name, value] of event.tags) {
if (name === 'e' && value) ids.add(value);
}
}
return ids;
}
/** Fetch inbox letters (letters sent to the current user) with cursor-based pagination.
* When `friendPubkeys` is provided, only letters from those pubkeys are returned. */
export function useInbox(friendPubkeys?: string[]) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
// Fetch all deletion IDs once (not paginated — deletion events are small)
const deletionsQuery = useQuery({
queryKey: ['letters-deletions', user?.pubkey],
queryFn: async () => {
if (!user) return new Set<string>();
const deletions = await nostr.query([{ kinds: [5], authors: [user.pubkey], '#k': [String(LETTER_KIND)], limit: 500 }]);
return getDeletedIds(deletions);
},
enabled: !!user,
});
const deletedIds = deletionsQuery.data ?? EMPTY_DELETED;
const infiniteQuery = useInfiniteQuery({
queryKey: ['letters-inbox', user?.pubkey, friendPubkeys ?? null],
queryFn: async ({ pageParam }: { pageParam: number | undefined }) => {
if (!user) return [];
const filter: NostrFilter = {
kinds: [LETTER_KIND],
'#p': [user.pubkey],
limit: PAGE_SIZE,
};
if (pageParam) filter.until = pageParam;
if (friendPubkeys) {
if (friendPubkeys.length === 0) return [];
filter.authors = friendPubkeys;
}
const events = await nostr.query([filter]);
return events
.map(parseLetterEvent)
.filter((l): l is Letter => l !== null)
.sort((a, b) => b.timestamp - a.timestamp);
},
initialPageParam: undefined as number | undefined,
getNextPageParam: (lastPage) => {
if (lastPage.length < PAGE_SIZE) return undefined;
const oldest = lastPage[lastPage.length - 1];
return oldest ? oldest.timestamp : undefined;
},
enabled: !!user,
});
// Flatten pages and filter out deleted letters
const data = useMemo(() => {
if (!infiniteQuery.data) return undefined;
return infiniteQuery.data.pages
.flat()
.filter((l) => !deletedIds.has(l.event.id));
}, [infiniteQuery.data, deletedIds]);
return {
data,
isLoading: infiniteQuery.isLoading,
fetchNextPage: infiniteQuery.fetchNextPage,
hasNextPage: infiniteQuery.hasNextPage,
isFetchingNextPage: infiniteQuery.isFetchingNextPage,
};
}
/** Fetch sent letters (letters authored by the current user) with cursor-based pagination. */
export function useSentLetters() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
// Reuse the same deletion query (keyed by pubkey, shared across inbox/sent)
const deletionsQuery = useQuery({
queryKey: ['letters-deletions', user?.pubkey],
queryFn: async () => {
if (!user) return new Set<string>();
const deletions = await nostr.query([{ kinds: [5], authors: [user.pubkey], '#k': [String(LETTER_KIND)], limit: 500 }]);
return getDeletedIds(deletions);
},
enabled: !!user,
});
const deletedIds = deletionsQuery.data ?? EMPTY_DELETED;
const infiniteQuery = useInfiniteQuery({
queryKey: ['letters-sent', user?.pubkey],
queryFn: async ({ pageParam }: { pageParam: number | undefined }) => {
if (!user) return [];
const filter: NostrFilter = {
kinds: [LETTER_KIND],
authors: [user.pubkey],
limit: PAGE_SIZE,
};
if (pageParam) filter.until = pageParam;
const events = await nostr.query([filter]);
return events
.map(parseLetterEvent)
.filter((l): l is Letter => l !== null)
.sort((a, b) => b.timestamp - a.timestamp);
},
initialPageParam: undefined as number | undefined,
getNextPageParam: (lastPage) => {
if (lastPage.length < PAGE_SIZE) return undefined;
const oldest = lastPage[lastPage.length - 1];
return oldest ? oldest.timestamp : undefined;
},
enabled: !!user,
});
const data = useMemo(() => {
if (!infiniteQuery.data) return undefined;
return infiniteQuery.data.pages
.flat()
.filter((l) => !deletedIds.has(l.event.id));
}, [infiniteQuery.data, deletedIds]);
return {
data,
isLoading: infiniteQuery.isLoading,
fetchNextPage: infiniteQuery.fetchNextPage,
hasNextPage: infiniteQuery.hasNextPage,
isFetchingNextPage: infiniteQuery.isFetchingNextPage,
};
}
/** Result of decrypting a letter — includes extracted presentation data */
export interface DecryptedLetter {
content: LetterContent;
stationery?: Stationery;
}
/** Decrypt a letter's content using NIP-44 and extract presentation fields */
export function useDecryptLetter(letter: Letter | undefined) {
const { user } = useCurrentUser();
return useQuery({
queryKey: ['letter-decrypt', letter?.event.id, user?.pubkey],
queryFn: async (): Promise<DecryptedLetter | null> => {
if (!user || !letter) return null;
if (!user.signer.nip44) {
throw new Error('NIP-44 encryption not supported by your signer');
}
const otherPubkey = letter.sender === user.pubkey
? letter.recipient
: letter.sender;
try {
const decrypted = await user.signer.nip44.decrypt(otherPubkey, letter.event.content);
const parsed = JSON.parse(decrypted) as LetterContent;
if (!parsed.body) return null;
return {
content: parsed,
stationery: parsed.stationery,
};
} catch {
return null;
}
},
enabled: !!user && !!letter && !!letter.event.content,
retry: false,
});
}
+9 -4
View File
@@ -17,16 +17,21 @@ import { resolveFontUrl } from '@/lib/fontLoader';
/**
* Resolve font URLs for Nostr publishing.
* Bundled fonts get CDN URLs, others keep their existing URL.
* If no title font is set, it falls back to the body font so published
* events always include both font tags when a body font is present.
*/
function resolveThemeForPublishing(config: ThemeConfig): ThemeConfig {
if (!config.font) return config;
const effectiveTitleFont = config.titleFont ?? config.font;
return {
...config,
font: {
font: config.font ? {
family: config.font.family,
url: resolveFontUrl(config.font.family, config.font.url),
},
} : undefined,
titleFont: effectiveTitleFont ? {
family: effectiveTitleFont.family,
url: resolveFontUrl(effectiveTitleFont.family, effectiveTitleFont.url),
} : undefined,
};
}
+66
View File
@@ -0,0 +1,66 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { COLOR_MOMENT_KIND, THEME_KIND } from '@/lib/letterTypes';
/** Validate a color moment event. Returns the event if valid, null otherwise. */
function validateColorMoment(event: NostrEvent): NostrEvent | null {
if (event.kind !== COLOR_MOMENT_KIND) return null;
const colorTags = event.tags.filter(([name]) => name === 'c');
if (colorTags.length < 3 || colorTags.length > 6) return null;
const hexColorRegex = /^#[0-9A-Fa-f]{6}$/;
if (!colorTags.every(([, color]) => hexColorRegex.test(color))) return null;
return event;
}
/** Validate a theme event. Returns the event if valid, null otherwise. */
function validateTheme(event: NostrEvent): NostrEvent | null {
if (event.kind !== THEME_KIND) return null;
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
const title = event.tags.find(([name]) => name === 'title')?.[1];
if (!dTag || !title) return null;
return event;
}
/** Fetch a page of color moments for stationery infinite scroll */
export function useColorMomentsPage(limit = 24, until?: number, authors?: string[]) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['color-moments-page', limit, until, authors ?? null],
queryFn: async () => {
const filter = {
kinds: [COLOR_MOMENT_KIND],
limit,
...(until ? { until } : {}),
...(authors && authors.length > 0 ? { authors } : {}),
};
const events = await nostr.query([filter]);
// Deduplicate by event id
const seen = new Map<string, NostrEvent>();
for (const e of events) seen.set(e.id, e);
return Array.from(seen.values())
.filter((e): e is NostrEvent => validateColorMoment(e) !== null)
.sort((a, b) => b.created_at - a.created_at);
},
});
}
/** Fetch a page of themes for stationery infinite scroll */
export function useThemesPage(limit = 24, until?: number, authors?: string[]) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['themes-page', limit, until, authors ?? null],
queryFn: async () => {
const filter = {
kinds: [THEME_KIND],
limit,
...(until ? { until } : {}),
...(authors && authors.length > 0 ? { authors } : {}),
};
const events = await nostr.query([filter]);
return events
.filter((e): e is NostrEvent => validateTheme(e) !== null)
.sort((a, b) => b.created_at - a.created_at);
},
});
}
+66
View File
@@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { type Stationery, resolveStationery, DEFAULT_STATIONERY_COLOR } from '@/lib/letterTypes';
import {
paletteTextColor,
paletteTextColorFaint,
paletteLineColor,
backgroundTextColor,
backgroundTextColorFaint,
backgroundLineColor,
} from '@/lib/colorUtils';
/** Default stationery when none is provided */
const DEFAULT_STATIONERY: Stationery = { color: DEFAULT_STATIONERY_COLOR };
/**
* useStationeryColors
*
* Returns the correct text colors to render over a given stationery:
*
* - Has textColor: use it directly (theme)
* - Has colors[]: WCAG avg-luminance of palette (color moment)
* - Otherwise: WCAG luminance of the single background color (preset)
*
* Returns { text, faint, line } as CSS color strings for use in `style={{ color }}`.
*/
export function useStationeryColors(stationery?: Stationery): {
text: string;
faint: string;
line: string;
fontFamily?: string;
} {
const raw = stationery ?? DEFAULT_STATIONERY;
return useMemo(() => {
const s = resolveStationery(raw);
// Explicit text color (theme)
if (s.textColor) {
const line = backgroundLineColor(s.color);
return {
text: s.textColor,
faint: s.textColor + '4d',
line,
fontFamily: s.fontFamily,
};
}
// Color moment: avg WCAG luminance of palette
if (s.colors && s.colors.length > 0) {
return {
text: paletteTextColor(s.colors),
faint: paletteTextColorFaint(s.colors),
line: paletteLineColor(s.colors),
fontFamily: s.fontFamily,
};
}
// Preset or fallback: single background color
return {
text: backgroundTextColor(s.color),
faint: backgroundTextColorFaint(s.color),
line: backgroundLineColor(s.color),
fontFamily: s.fontFamily,
};
}, [raw]);
}
+61
View File
@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { useTheme } from '@/hooks/useTheme';
import { useAppContext } from '@/hooks/useAppContext';
import { builtinThemes, resolveTheme, resolveThemeConfig } from '@/themes';
import { hslStringToHex } from '@/lib/colorUtils';
import type { Stationery } from '@/lib/letterTypes';
/**
* Converts the user's currently active Ditto theme into a letter Stationery.
*
* The mapping:
* - Stationery.color theme background color (hex)
* - Stationery.textColor theme text/foreground color (hex)
* - Stationery.primaryColor theme primary color (hex)
* - Stationery.imageUrl theme background image URL (if any)
* - Stationery.imageMode theme background mode (cover/tile)
* - Stationery.fontFamily theme body font-family (if any)
*
* This lets letters inherit the user's full Ditto theme colors, fonts,
* and background image as their default stationery, so letters look
* native to the user's chosen aesthetic.
*/
export function useThemeStationery(): Stationery {
const { theme, customTheme, themes } = useTheme();
const { config } = useAppContext();
return useMemo(() => {
const resolved = resolveTheme(theme);
let bgHex: string;
let textHex: string;
let primaryHex: string;
let fontFamily: string | undefined;
let imageUrl: string | undefined;
let imageMode: 'cover' | 'tile' | undefined;
if (resolved === 'custom' && customTheme) {
const { colors, font, background } = customTheme;
bgHex = hslStringToHex(colors.background);
textHex = hslStringToHex(colors.text);
primaryHex = hslStringToHex(colors.primary);
fontFamily = font?.family ?? undefined;
imageUrl = background?.url ?? undefined;
imageMode = background?.mode ?? undefined;
} else {
const colors = resolveThemeConfig(resolved as 'light' | 'dark', themes ?? config.themes).colors
?? builtinThemes[resolved as 'light' | 'dark'];
bgHex = hslStringToHex(colors.background);
textHex = hslStringToHex(colors.text);
primaryHex = hslStringToHex(colors.primary);
}
return {
color: bgHex,
textColor: textHex,
primaryColor: primaryHex,
...(fontFamily ? { fontFamily } : {}),
...(imageUrl ? { imageUrl, imageMode: imageMode ?? 'cover' } : {}),
};
}, [theme, customTheme, themes, config.themes]);
}
+132 -11
View File
@@ -4,6 +4,9 @@
* Hook for managing NIP-51 Follow Sets (kind 30000).
* Follow Sets are addressable events identified by a `d` tag.
* Each list has a title and contains `p` tags (pubkeys).
*
* Supports NIP-51 encrypted (private) items: pubkeys stored in the
* encrypted `content` field are merged with public `p` tags.
*/
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
@@ -11,7 +14,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { useFollowPacks } from './useFollowPacks';
import type { NostrEvent } from '@nostrify/nostrify';
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
export interface UserList {
/** Unique d-tag identifier */
@@ -22,8 +25,10 @@ export interface UserList {
description?: string;
/** Optional image URL (from `image` or `thumb` tag) */
image?: string;
/** Pubkeys in this list */
/** All pubkeys in this list (public + private, deduplicated) */
pubkeys: string[];
/** Pubkeys that were stored in the encrypted content (private items) */
privatePubkeys: string[];
/** The underlying Nostr event */
event: NostrEvent;
}
@@ -31,7 +36,55 @@ export interface UserList {
/** d-tags reserved by NIP-51 for other purposes — filter these out. */
const DEPRECATED_DTAGS = new Set(['mute', 'pin', 'bookmark', 'communities']);
/** Parse a kind 30000 event into a UserList. */
/**
* Detect whether encrypted content uses NIP-04 (legacy) or NIP-44 encoding.
* NIP-51 says: "Clients can automatically discover if the encryption is NIP-04
* or NIP-44 by searching for 'iv' in the ciphertext."
*/
function isNip04Encrypted(content: string): boolean {
return content.includes('?iv=');
}
/**
* Decrypt encrypted content from a NIP-51 list event, handling both NIP-44 and
* legacy NIP-04 formats for backward compatibility per NIP-51.
*/
async function decryptListContent(
content: string,
signer: NostrSigner,
pubkey: string,
): Promise<string[][] | null> {
if (!content) return null;
try {
let decrypted: string | null = null;
if (isNip04Encrypted(content)) {
if (signer.nip04) {
decrypted = await signer.nip04.decrypt(pubkey, content);
} else {
console.warn('List uses NIP-04 encryption but signer does not support nip04');
return null;
}
} else {
if (signer.nip44) {
decrypted = await signer.nip44.decrypt(pubkey, content);
} else {
console.warn('List uses NIP-44 encryption but signer does not support nip44');
return null;
}
}
if (!decrypted) return null;
const tags = JSON.parse(decrypted) as string[][];
return Array.isArray(tags) ? tags : null;
} catch (error) {
console.error('Failed to decrypt list content:', error);
return null;
}
}
/** Parse a kind 30000 event into a UserList (public tags only, no decryption). */
function parseListEvent(event: NostrEvent): UserList {
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
const id = getTag('d') ?? '';
@@ -39,7 +92,61 @@ function parseListEvent(event: NostrEvent): UserList {
const description = getTag('description') || getTag('summary') || undefined;
const image = getTag('image') || getTag('thumb') || undefined;
const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk);
return { id, title, description, image, pubkeys, event };
return { id, title, description, image, pubkeys, privatePubkeys: [], event };
}
/**
* Parse a kind 30000 event with decryption of private items.
* Private `p` tags from the encrypted content are merged with public tags.
*/
async function parseListEventWithDecryption(
event: NostrEvent,
signer: NostrSigner,
pubkey: string,
): Promise<UserList> {
const base = parseListEvent(event);
if (!event.content) return base;
const privateTags = await decryptListContent(event.content, signer, pubkey);
if (!privateTags) return base;
const privatePubkeys = privateTags
.filter(([n]) => n === 'p')
.map(([, pk]) => pk)
.filter(Boolean);
if (privatePubkeys.length === 0) return base;
// Merge public + private pubkeys, deduplicated
const publicSet = new Set(base.pubkeys);
const allPubkeys = [...base.pubkeys];
for (const pk of privatePubkeys) {
if (!publicSet.has(pk)) {
allPubkeys.push(pk);
}
}
return { ...base, pubkeys: allPubkeys, privatePubkeys };
}
/**
* Re-encrypt private pubkeys back into the content field.
* Returns empty string if there are no private items.
*/
async function encryptPrivateTags(
privatePubkeys: string[],
signer: NostrSigner,
pubkey: string,
): Promise<string> {
if (privatePubkeys.length === 0) return '';
if (!signer.nip44) {
console.warn('Cannot re-encrypt private list items: signer does not support NIP-44');
return '';
}
const tags = privatePubkeys.map((pk) => ['p', pk]);
const plaintext = JSON.stringify(tags);
return signer.nip44.encrypt(pubkey, plaintext);
}
export function useUserLists() {
@@ -77,7 +184,7 @@ export function useUserLists() {
}
}
return listEvents
const filtered = listEvents
.filter((e) => {
const dTag = e.tags.find(([n]) => n === 'd')?.[1] ?? '';
if (DEPRECATED_DTAGS.has(dTag)) return false;
@@ -85,12 +192,20 @@ export function useUserLists() {
const coord = `30000:${user.pubkey}:${dTag}`;
if (deletedCoords.has(coord)) return false;
// Filter out empty replacement events (from deletion step 1)
// (note: events with encrypted content but no public tags are kept)
const hasPTags = e.tags.some(([n]) => n === 'p');
const hasTitle = e.tags.some(([n]) => n === 'title' || n === 'name');
if (!hasPTags && !hasTitle) return false;
const hasContent = !!e.content;
if (!hasPTags && !hasTitle && !hasContent) return false;
return true;
})
.map(parseListEvent);
});
// Decrypt private items in each list event
const parsed = await Promise.all(
filtered.map((e) => parseListEventWithDecryption(e, user.signer, user.pubkey)),
);
return parsed;
},
enabled: !!user,
staleTime: 60 * 1000,
@@ -133,9 +248,10 @@ export function useUserLists() {
if (list.pubkeys.includes(pubkey) || rawPubkeys.includes(pubkey)) return;
const newTags = [...list.event.tags, ['p', pubkey]];
const content = await encryptPrivateTags(list.privatePubkeys, user.signer, user.pubkey);
await publishEvent({
kind: 30000,
content: list.event.content ?? '',
content,
tags: newTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
},
@@ -151,12 +267,16 @@ export function useUserLists() {
const list = lists.find((l) => l.id === listId);
if (!list) throw new Error('List not found');
// Remove from public tags
const newTags = list.event.tags.filter(
([name, pk]) => !(name === 'p' && pk === pubkey),
);
// Remove from private pubkeys too
const newPrivatePubkeys = list.privatePubkeys.filter((pk) => pk !== pubkey);
const content = await encryptPrivateTags(newPrivatePubkeys, user.signer, user.pubkey);
await publishEvent({
kind: 30000,
content: list.event.content ?? '',
content,
tags: newTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
},
@@ -179,9 +299,10 @@ export function useUserLists() {
if (!newTags.find(([n]) => n === 'title')) {
newTags.push(['title', title.trim()]);
}
const content = await encryptPrivateTags(list.privatePubkeys, user.signer, user.pubkey);
await publishEvent({
kind: 30000,
content: list.event.content ?? '',
content,
tags: newTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
},
+94
View File
@@ -248,6 +248,100 @@
50%, 90% { opacity: 0.2; }
}
/* ── Wii Mail envelope grid ─────────────────────────────────────────────────── */
/* Staggered entrance: envelopes pop in one by one */
@keyframes envelope-entrance {
0% { opacity: 0; transform: scale(0.6) translateY(20px) rotate(-4deg); }
60% { opacity: 1; transform: scale(1.05) translateY(-2px) rotate(1deg); }
80% { transform: scale(0.97) translateY(1px) rotate(-0.5deg); }
100% { opacity: 1; transform: scale(1) translateY(0) rotate(0deg); }
}
.envelope-card {
animation: envelope-entrance 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
animation-delay: var(--entrance-delay, 0ms);
transform-origin: center bottom;
cursor: pointer;
}
/* Hover wobble — playful Wii-style oscillation */
@keyframes envelope-wobble {
0% { transform: rotate(0deg); }
15% { transform: rotate(-5deg); }
30% { transform: rotate(4deg); }
45% { transform: rotate(-3deg); }
60% { transform: rotate(2deg); }
75% { transform: rotate(-1deg); }
100% { transform: rotate(0deg); }
}
.envelope-body {
transition: transform 0.15s ease;
}
.envelope-card:hover .envelope-body,
.envelope-card:focus-visible .envelope-body {
animation: envelope-wobble 0.5s ease-in-out;
}
.envelope-card:active .envelope-body {
transform: scale(0.92);
animation: none;
}
/* Envelope skeleton shimmer */
@keyframes envelope-skeleton-shimmer {
0% { opacity: 0.4; }
50% { opacity: 0.7; }
100% { opacity: 0.4; }
}
.envelope-skeleton {
animation: envelope-skeleton-shimmer 1.5s ease-in-out infinite;
}
/* Reduced motion: disable envelope animations */
@media (prefers-reduced-motion: reduce) {
.envelope-card {
animation: none;
opacity: 1;
}
.envelope-card:hover .envelope-body,
.envelope-card:focus-visible .envelope-body {
animation: none;
}
}
/* Drawn SVG stickers — ensure inline SVGs fill their container */
[data-sticker] svg,
.sticker-svg-wrap svg {
width: 100%;
height: 100%;
display: block;
}
/* Letter send animation keyframes */
@keyframes letter-send-fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes letter-send-avatar-in {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.15); opacity: 1; }
70% { transform: scale(0.92); }
85% { transform: scale(1.04); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes letter-send-burst {
0% { transform: translate(0, 0) rotate(0deg) scale(0); opacity: 0; }
15% { opacity: 1; transform: translate(0, 0) rotate(0deg) scale(1); }
60% { opacity: 0.8; transform: translate(var(--burst-tx), var(--burst-ty)) rotate(var(--burst-rot)) scale(1); }
100% { opacity: 0; transform: translate(var(--burst-tx), var(--burst-ty)) rotate(var(--burst-rot)) scale(0.4); }
}
/* Reduced motion: disable all vanish animations */
@media (prefers-reduced-motion: reduce) {
.vanish-stripes {
+130
View File
@@ -0,0 +1,130 @@
import { nip19 } from 'nostr-tools';
/**
* Represents a parsed Nostr URI with its components
*/
export interface NostrURIParts {
/** The public key (hex format) */
pubkey: string;
/** The identifier (d-tag value) */
identifier: string;
/** Optional relay URL */
relay?: string;
}
/**
* NostrURI class for constructing Nostr clone URIs for git repositories.
*
* Produces URIs in the format:
* - nostr://npub/identifier
* - nostr://npub/relay-hostname/identifier
*
* Ported from Shakespeare (src/lib/NostrURI.ts).
*
* @example
* ```ts
* const uri = new NostrURI({
* pubkey: 'abc123...',
* identifier: 'my-repo',
* relay: 'wss://relay.example.com/'
* });
* console.log(uri.toString()); // 'nostr://npub1.../relay.example.com/my-repo'
* ```
*/
export class NostrURI {
public readonly pubkey: string;
public readonly identifier: string;
public readonly relay?: string;
constructor(parts: NostrURIParts) {
this.pubkey = parts.pubkey;
this.identifier = parts.identifier;
this.relay = parts.relay;
}
/**
* Construct a NostrURI from a kind 30617 git repository event.
* Extracts the pubkey, d-tag, and first relay from the event.
*/
static fromEvent(event: { pubkey: string; tags: string[][] }): NostrURI {
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
const relays = event.tags.find(([n]) => n === 'relays');
const relay = relays?.[1]; // First relay URL
return new NostrURI({
pubkey: event.pubkey,
identifier: dTag,
relay,
});
}
/**
* Create a NostrURI from an naddr (NIP-19 addressable event identifier).
*/
static fromNaddr(naddr: string): NostrURI {
const decoded = nip19.decode(naddr);
if (decoded.type !== 'naddr') {
throw new Error('Invalid naddr: must be an addressable event pointer');
}
const data = decoded.data;
return new NostrURI({
pubkey: data.pubkey,
identifier: data.identifier,
relay: data.relays?.[0],
});
}
/**
* Convert to a `nostr://` URI string.
*
* The relay hostname is included between the npub and identifier
* when a relay URL is available, with the `wss://` scheme stripped.
*/
toString(): string {
const npub = nip19.npubEncode(this.pubkey);
if (this.relay) {
try {
const url = new URL(this.relay);
return `nostr://${npub}/${url.hostname}/${this.identifier}`;
} catch {
// fallthrough
}
}
return `nostr://${npub}/${this.identifier}`;
}
/**
* Convert to an naddr (NIP-19 addressable event identifier) for kind 30617.
*/
toNaddr(): string {
const data: {
kind: number;
pubkey: string;
identifier: string;
relays?: string[];
} = {
kind: 30617,
pubkey: this.pubkey,
identifier: this.identifier,
};
if (this.relay) {
data.relays = [this.relay];
}
return nip19.naddrEncode(data);
}
toJSON(): NostrURIParts {
return {
pubkey: this.pubkey,
identifier: this.identifier,
relay: this.relay,
};
}
}
+73
View File
@@ -0,0 +1,73 @@
/** Category types from Keep a Changelog format. */
type ChangelogCategory = 'Added' | 'Changed' | 'Deprecated' | 'Removed' | 'Fixed' | 'Security';
/** A single version entry in the changelog. */
interface ChangelogEntry {
version: string;
date: string;
sections: {
category: ChangelogCategory;
items: string[];
}[];
}
/**
* Parse a Keep a Changelog formatted markdown string into structured data.
* @see https://keepachangelog.com/
*/
function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentCategory: ChangelogCategory | null = null;
for (const line of markdown.split('\n')) {
// Match version heading: ## [X.Y.Z] - YYYY-MM-DD
const versionMatch = line.match(/^## \[([^\]]+)\]\s*-\s*(.+)$/);
if (versionMatch) {
current = { version: versionMatch[1], date: versionMatch[2].trim(), sections: [] };
entries.push(current);
currentCategory = null;
continue;
}
// Match category heading: ### Added, ### Changed, etc.
const categoryMatch = line.match(/^### (.+)$/);
if (categoryMatch && current) {
currentCategory = categoryMatch[1].trim() as ChangelogCategory;
current.sections.push({ category: currentCategory, items: [] });
continue;
}
// Match list item: - Description
const itemMatch = line.match(/^- (.+)$/);
if (itemMatch && current) {
const section = current.sections[current.sections.length - 1];
if (section) {
section.items.push(itemMatch[1]);
} else {
// Item without a category heading — treat as "Changed"
current.sections.push({ category: 'Changed', items: [itemMatch[1]] });
}
continue;
}
// Lines that don't start with "- " but aren't blank may be a continuation or
// freeform text after the version heading (e.g. "Initial release of Ditto 2.0").
const trimmed = line.trim();
if (trimmed && current && !trimmed.startsWith('#')) {
const section = current.sections[current.sections.length - 1];
if (section) {
// Append to last item or add new item
section.items.push(trimmed);
} else {
// Freeform text under version with no category — store in a generic section
current.sections.push({ category: 'Changed', items: [trimmed] });
}
}
}
return entries;
}
export { parseChangelog };
export type { ChangelogEntry, ChangelogCategory };
+85
View File
@@ -206,3 +206,88 @@ export function tokensToCoreColors(tokens: ThemeTokens): CoreThemeColors {
primary: tokens.primary,
};
}
// ─── Hex color manipulation ───────────────────────────────────────────
/** Darken a hex color by a factor (0 = no change, 1 = black). */
export function darkenHex(hex: string, amount: number): string {
const [r, g, b] = hexToRgb(hex);
const dark = (c: number) => Math.max(0, Math.round(c * (1 - amount)));
return rgbToHex(dark(r), dark(g), dark(b));
}
/** Lighten a hex color by a factor (0 = no change, 1 = white). */
export function lightenHex(hex: string, amount: number): string {
const [r, g, b] = hexToRgb(hex);
const light = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount));
return rgbToHex(light(r), light(g), light(b));
}
/** Blend two hex colors by a factor (0 = hex1, 1 = hex2). */
export function blendHex(hex1: string, hex2: string, amount: number): string {
const [r1, g1, b1] = hexToRgb(hex1);
const [r2, g2, b2] = hexToRgb(hex2);
return rgbToHex(
Math.round(r1 + (r2 - r1) * amount),
Math.round(g1 + (g2 - g1) * amount),
Math.round(b1 + (b2 - b1) * amount),
);
}
// ─── Letter stationery color utilities ────────────────────────────────
/** WCAG 2.1 relative luminance of a hex color (0 = black, 1 = white). */
export function hexLuminance(hex: string): number {
if (!hex) return 0.5;
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const toLinear = (c: number) =>
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
/**
* Derive a readable text color for a given palette of hex colors.
* avgLum > 0.5 dark text; avgLum 0.5 light text
*/
export function paletteTextColor(colors: string[]): string {
if (colors.length === 0) return 'rgba(0,0,0,0.75)';
const avg = colors.reduce((sum, c) => sum + hexLuminance(c), 0) / colors.length;
return avg > 0.5 ? 'rgba(0,0,0,0.75)' : 'rgba(255,255,255,0.90)';
}
/** Derive a readable text color for a single background hex color. */
export function backgroundTextColor(bgHex: string): string {
return hexLuminance(bgHex) > 0.5
? 'rgba(0,0,0,0.75)'
: 'rgba(255,255,255,0.90)';
}
/** Faint text color for secondary elements (palette version). */
export function paletteTextColorFaint(colors: string[]): string {
if (colors.length === 0) return 'rgba(0,0,0,0.30)';
const avg = colors.reduce((sum, c) => sum + hexLuminance(c), 0) / colors.length;
return avg > 0.5 ? 'rgba(0,0,0,0.30)' : 'rgba(255,255,255,0.35)';
}
/** Faint text color for secondary elements (single background version). */
export function backgroundTextColorFaint(bgHex: string): string {
return hexLuminance(bgHex) > 0.5
? 'rgba(0,0,0,0.30)'
: 'rgba(255,255,255,0.35)';
}
/** Ruled-line color for letter stationery (palette version). */
export function paletteLineColor(colors: string[]): string {
if (colors.length === 0) return 'rgba(0,0,0,0.08)';
const avg = colors.reduce((sum, c) => sum + hexLuminance(c), 0) / colors.length;
return avg > 0.5 ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.15)';
}
/** Ruled-line color for letter stationery (single background version). */
export function backgroundLineColor(bgHex: string): string {
return hexLuminance(bgHex) > 0.5
? 'rgba(0,0,0,0.08)'
: 'rgba(255,255,255,0.15)';
}
+57 -4
View File
@@ -1,5 +1,7 @@
import type { FeedSettings } from '@/contexts/AppContext';
import type { ComponentType } from 'react';
import { Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert } from 'lucide-react';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
/** A sub-kind that lives under a parent ExtraKindDef. */
@@ -463,13 +465,13 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
id: 'development',
showKey: 'showDevelopment',
feedKey: 'feedIncludeDevelopment',
extraFeedKinds: [1617, 1618, 30817],
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267],
label: 'Development',
description: 'Git repos, patches, PRs, and custom NIPs',
description: 'Git repos, patches, PRs, nsites, apps, and custom NIPs',
route: 'development',
addressable: true,
section: 'development',
blurb: 'Nostr-native git repositories, patches, pull requests, custom NIPs, and published applications.',
blurb: 'Nostr-native git repositories, patches, pull requests, nsite deployments, custom NIPs, and published applications.',
sites: [{ url: 'https://gitworkshop.dev', name: 'Gitworkshop' }, { url: 'https://nostrhub.io', name: 'NostrHub' }],
},
];
@@ -513,6 +515,56 @@ export function getPageKinds(def: ExtraKindDef, feedSettings: FeedSettings): num
.map((sub) => sub.kind);
}
/**
* Specific labels for kinds that don't have their own top-level ExtraKindDef.
* These are kinds buried in `extraFeedKinds` arrays or otherwise needing
* a label more specific than their parent category.
*/
const KIND_SPECIFIC_LABELS: Record<number, string> = {
6: 'repost',
7: 'reaction',
16: 'repost',
1617: 'patch',
1618: 'patch comment',
15128: 'nsite',
35128: 'nsite',
30817: 'repository issue',
32267: 'app',
30063: 'release',
};
/**
* Specific icons for kinds that need a different icon than their parent category.
*/
const KIND_SPECIFIC_ICONS: Partial<Record<number, ComponentType<{ className?: string }>>> = {
6: RepostIcon,
16: RepostIcon,
1617: GitPullRequestArrow,
1618: MessageSquareMore,
15128: Globe,
35128: Globe,
30817: CircleAlert,
};
/**
* Get a human-readable label for a specific kind number.
* Resolution order: subKind label KIND_SPECIFIC_LABELS direct def label.
* Returns undefined if the kind is completely unknown.
*/
export function getKindLabel(kind: number): string | undefined {
// Check subKinds first (they carry their own label)
for (const def of EXTRA_KINDS) {
const sub = def.subKinds?.find((s) => s.kind === kind);
if (sub) return sub.label.toLowerCase();
}
// Check specific overrides (extraFeedKinds items, etc.)
if (KIND_SPECIFIC_LABELS[kind]) return KIND_SPECIFIC_LABELS[kind];
// Check top-level def
const def = EXTRA_KINDS.find((d) => d.kind === kind);
if (def) return def.label.toLowerCase();
return undefined;
}
/** Map from kind number to ExtraKindDef id, for quick icon lookup. */
const KIND_TO_ID = new Map<number, string>();
for (const def of EXTRA_KINDS) {
@@ -540,10 +592,11 @@ export function getKindId(kind: number): string | undefined {
/**
* Get the icon component for a given kind number.
* Looks up the kind in EXTRA_KINDS and resolves to the matching icon from CONTENT_KIND_ICONS.
* Checks KIND_SPECIFIC_ICONS first, then falls back to the parent def's icon via CONTENT_KIND_ICONS.
* Returns undefined if no icon mapping exists (caller provides fallback).
*/
export function getKindIcon(kind: number): ComponentType<{ className?: string }> | undefined {
if (KIND_SPECIFIC_ICONS[kind]) return KIND_SPECIFIC_ICONS[kind];
const id = KIND_TO_ID.get(kind);
if (!id) return undefined;
return CONTENT_KIND_ICONS[id];
+42
View File
@@ -108,6 +108,48 @@ export async function loadAndApplyFont(font: ThemeFont | undefined): Promise<voi
applyFontOverride(font);
}
// ─── Title Font CSS Override ──────────────────────────────────────────
/** Style element ID for title font CSS custom property. */
const TITLE_FONT_OVERRIDE_STYLE_ID = 'theme-title-font-overrides';
/**
* Apply a CSS custom property `--title-font-family` to the document.
* Components that render the profile display name read this variable.
*
* Pass undefined to clear the override.
*/
export function applyTitleFontOverride(font: ThemeFont | undefined): void {
let style = document.getElementById(TITLE_FONT_OVERRIDE_STYLE_ID) as HTMLStyleElement | null;
if (!font) {
style?.remove();
return;
}
if (!style) {
style = document.createElement('style');
style.id = TITLE_FONT_OVERRIDE_STYLE_ID;
document.head.appendChild(style);
}
const cssFamily = resolveCssFamily(font.family);
style.textContent = `:root { --title-font-family: "${cssFamily}", ${DEFAULT_FONT_STACK}; }\n`;
}
/**
* Load a title font and apply the CSS custom property override.
*/
export async function loadAndApplyTitleFont(font: ThemeFont | undefined): Promise<void> {
if (!font) {
applyTitleFontOverride(undefined);
return;
}
await loadFont(font.family, font.url);
applyTitleFontOverride(font);
}
/**
* Resolve font URLs for publishing to Nostr.
* For bundled fonts, returns the CDN URL. For others, preserves the existing URL.
+40
View File
@@ -170,6 +170,46 @@ export const bundledFonts: BundledFont[] = [
load: () => import('@fontsource/press-start-2p/400.css').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2',
},
{
family: 'Fredoka',
cssFamily: 'Fredoka Variable',
category: 'display',
variable: true,
load: () => import('@fontsource-variable/fredoka').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/fredoka:vf@latest/latin-wght-normal.woff2',
},
{
family: 'Caveat',
cssFamily: 'Caveat',
category: 'handwriting',
variable: false,
load: () => import('@fontsource/caveat/400.css').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/caveat@latest/latin-400-normal.woff2',
},
{
family: 'Pacifico',
cssFamily: 'Pacifico',
category: 'handwriting',
variable: false,
load: () => import('@fontsource/pacifico/400.css').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/pacifico@latest/latin-400-normal.woff2',
},
{
family: 'Pirata One',
cssFamily: 'Pirata One',
category: 'display',
variable: false,
load: () => import('@fontsource/pirata-one/400.css').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/pirata-one@latest/latin-400-normal.woff2',
},
{
family: 'Special Elite',
cssFamily: 'Special Elite',
category: 'display',
variable: false,
load: () => import('@fontsource/special-elite/400.css').then(() => {}),
cdnUrl: 'https://cdn.jsdelivr.net/fontsource/fonts/special-elite@latest/latin-400-normal.woff2',
},
{
family: 'Nunito',
cssFamily: 'Nunito Variable',
+293
View File
@@ -0,0 +1,293 @@
import type { NostrEvent } from '@nostrify/nostrify';
export const LETTER_KIND = 8211;
export const COLOR_MOMENT_KIND = 3367;
export const THEME_KIND = 36767;
/** Default stationery background color (parchment). */
export const DEFAULT_STATIONERY_COLOR = '#F5E6D3';
/** Ratio of ruled-line height to card width. Used across letter rendering components. */
export const LINE_HEIGHT_RATIO = 0.084;
/** A sticker placed on top of the letter card */
export interface LetterSticker {
/** Image URL of the sticker (empty string for drawn stickers) */
url: string;
/** NIP-30 shortcode (without colons). "drawing" for hand-drawn SVG stickers. */
shortcode: string;
/** X position as percentage (0100) from left edge of the card */
x: number;
/** Y position as percentage (0100) from top edge of the card */
y: number;
/** Rotation in degrees (-180 to 180) */
rotation: number;
/** Scale multiplier (default 1). Range 0.54. */
scale?: number;
/** Raw SVG markup for hand-drawn stickers. When present, rendered inline instead of url. */
svg?: string;
}
export interface LetterContent {
body: string;
closing?: string;
signature?: string;
/** Stickers placed on the letter card — stored in encrypted content for privacy */
stickers?: LetterSticker[];
/** Visual stationery — all rendering attributes plus optional source event */
stationery?: Stationery;
}
/**
* Visual stationery for a letter the wire format.
*
* User-chosen fields live directly on this object. Event-derived fields
* (colors, layout, imageUrl, textColor, etc.) are read from the source
* `event` at render time via `resolveStationery()`. Old letters that
* predate this change may carry those fields as flat fallbacks.
*/
export interface Stationery {
/** Background color (hex). Always present. */
color: string;
/** Emoji character for backsplash or emblem. */
emoji?: string;
/** Emoji display mode: 'tile' (faint repeating pattern) or 'emblem' (single large centered glyph). */
emojiMode?: 'tile' | 'emblem';
/** Palette colors override. Empty array = "flat" mode (suppress palette). */
colors?: string[];
/** CSS font-family string (e.g. "Caveat, cursive"). Set from the sender's font choice. */
fontFamily?: string;
/** Frame style ID. */
frame?: FrameStyle;
/** When true, color-shift the frame emojis to match the stationery palette. */
frameTint?: boolean;
/** Source Nostr event (kind 36767 theme or kind 3367 color moment). */
event?: NostrEvent;
// --- Legacy flat fallbacks (from old letters, not set by new code) ---
/** @deprecated Read from event tags instead. */
textColor?: string;
/** @deprecated Read from event tags instead. */
primaryColor?: string;
/** @deprecated Read from event tags instead. */
layout?: string;
/** @deprecated Read from event tags instead. */
imageUrl?: string;
/** @deprecated Read from event tags instead. */
imageMode?: 'cover' | 'tile';
}
/** All rendering attributes for a stationery, fully resolved from event + fallbacks. */
export interface ResolvedStationery {
color: string;
textColor?: string;
primaryColor?: string;
emoji?: string;
emojiMode: 'tile' | 'emblem';
colors?: string[];
layout?: string;
imageUrl?: string;
imageMode: 'cover' | 'tile';
fontFamily?: string;
frame?: FrameStyle;
frameTint?: boolean;
event?: NostrEvent;
}
/** Resolve a Stationery into full rendering attributes by reading event tags. */
export function resolveStationery(s: Stationery): ResolvedStationery {
const base: ResolvedStationery = {
color: s.color,
emoji: s.emoji,
emojiMode: s.emojiMode ?? 'tile',
fontFamily: s.fontFamily,
frame: s.frame,
frameTint: s.frameTint,
imageMode: 'cover',
event: s.event,
};
const event = s.event;
if (event?.kind === COLOR_MOMENT_KIND) {
// Colors override: empty array = flat mode, undefined = read from event
if (s.colors !== undefined) {
base.colors = s.colors.length > 0 ? s.colors : undefined;
} else {
const hexRe = /^#[0-9A-Fa-f]{6}$/;
const eventColors = event.tags.filter(([n]) => n === 'c').map(([, c]) => c).filter((c) => hexRe.test(c));
if (eventColors.length >= 2) base.colors = eventColors;
}
base.layout = s.layout ?? event.tags.find(([n]) => n === 'layout')?.[1];
if (!base.emoji) {
const raw = event.content?.trim();
if (raw && [...raw].length <= 2 && /\p{Emoji}/u.test(raw)) base.emoji = raw;
}
return base;
}
if (event?.kind === THEME_KIND) {
const colorTags = event.tags.filter(([n]) => n === 'c');
for (const [, hex, marker] of colorTags) {
if (marker === 'text') base.textColor = hex;
if (marker === 'primary') base.primaryColor = hex;
}
const bgTag = event.tags.find(([n]) => n === 'bg');
if (bgTag) {
for (const slot of bgTag.slice(1)) {
if (slot.startsWith('url ')) base.imageUrl = slot.slice(4);
else if (slot === 'mode tile') base.imageMode = 'tile';
else if (slot === 'mode cover') base.imageMode = 'cover';
}
}
if (!base.imageUrl) {
base.imageUrl = event.tags.find(([n]) => n === 'image')?.[1];
}
return base;
}
// No event or unknown kind — use legacy flat fallbacks (old letters, presets)
base.textColor = s.textColor;
base.primaryColor = s.primaryColor;
base.colors = s.colors;
base.layout = s.layout;
base.imageUrl = s.imageUrl;
base.imageMode = s.imageMode ?? 'cover';
return base;
}
/**
* Frame style presets combinable with any stationery.
* Each frame uses the same emoji-scatter system with different emoji sets
* and default background colors.
*/
export type FrameStyle =
| 'none'
| 'flowers'
| 'autumn'
| 'ocean'
| 'celestial'
| 'hearts'
| 'garden'
| 'winter'
| 'fruit'
| 'sparkle';
export interface FramePreset {
id: FrameStyle;
name: string;
/** Emoji set for the border scatter */
emojis?: string[];
/** Default background color (before tint) */
bgColor?: string;
}
export const FRAME_PRESETS: FramePreset[] = [
{ id: 'none', name: 'None' },
{ id: 'flowers', name: 'Flowers', emojis: ['🌸', '🌺', '🌼', '🌷', '🌻', '🌹'], bgColor: '#3a7a3a' },
{ id: 'autumn', name: 'Autumn', emojis: ['🍂', '🍁', '🍃', '🌾', '🍄', '🌰'], bgColor: '#8b5e3c' },
{ id: 'ocean', name: 'Ocean', emojis: ['🐚', '🌊', '🐠', '🐙', '🦀', '🐬'], bgColor: '#1a5276' },
{ id: 'celestial', name: 'Celestial', emojis: ['🪐', '🌙', '⭐', '🌕', '☄️', '🔭'], bgColor: '#1a1a3e' },
{ id: 'hearts', name: 'Hearts', emojis: ['❤️', '💕', '💗', '💖', '💝', '💘'], bgColor: '#8b2252' },
{ id: 'garden', name: 'Garden', emojis: ['🦋', '🐝', '🌿', '🌱', '🐞', '🍀'], bgColor: '#2d5a27' },
{ id: 'winter', name: 'Winter', emojis: ['❄️', '⛄', '🌨️', '🏔️', '🎿', '🧣'], bgColor: '#4a6d8c' },
{ id: 'fruit', name: 'Fruit', emojis: ['🍊', '🍋', '🍓', '🍑', '🍒', '🫐'], bgColor: '#6b4226' },
{ id: 'sparkle', name: 'Sparkle', emojis: ['✨', '💎', '🔮', '🪩', '⚡', '🌈'], bgColor: '#4a2d6b' },
];
export interface Letter {
event: NostrEvent;
recipient: string;
sender: string;
decrypted: boolean;
timestamp: number;
}
/**
* Built-in stationery presets flat single color + optional emoji backsplash.
* No gradients.
*/
export const STATIONERY_PRESETS: Record<string, { name: string; color: string; emoji?: string }> = {
parchment: { name: 'Parchment', color: DEFAULT_STATIONERY_COLOR, emoji: undefined },
meadow: { name: 'Meadow', color: '#C8E6C9', emoji: '🌿' },
twilight: { name: 'Twilight', color: '#E1BEE7', emoji: '🌙' },
ocean: { name: 'Ocean', color: '#B3E5FC', emoji: '🌊' },
blossom: { name: 'Blossom', color: '#FCE4EC', emoji: '🌸' },
forest: { name: 'Forest', color: '#DCEDC8', emoji: '🌲' },
butter: { name: 'Butter', color: '#FFF9C4', emoji: '🌻' },
peach: { name: 'Peach', color: '#FFE0CC', emoji: '🍑' },
mint: { name: 'Mint', color: '#E0F2E9', emoji: '🍃' },
lavender: { name: 'Lavender', color: '#EDE7F6', emoji: '💜' },
};
export const CLOSING_PRESETS = [
'With Love,',
'Warmly,',
'Yours Truly,',
'XO,',
'Until Next Time,',
'Thinking of You,',
'Forever Yours,',
'With Gratitude,',
];
export const FONT_OPTIONS = [
{ value: 'fredoka', label: 'Fredoka', family: 'Fredoka Variable, Fredoka, sans-serif' },
{ value: 'nunito', label: 'Nunito', family: 'Nunito Variable, Nunito, sans-serif' },
{ value: 'playfair', label: 'Playfair', family: 'Playfair Display Variable, Playfair Display, serif' },
{ value: 'caveat', label: 'Caveat', family: 'Caveat, cursive' },
{ value: 'pacifico', label: 'Pacifico', family: 'Pacifico, cursive' },
{ value: 'pirata', label: 'Pirata', family: 'Pirata One, cursive' },
{ value: 'marker', label: 'Marker', family: 'Permanent Marker, cursive' },
{ value: 'typewriter', label: 'Typewriter', family: 'Special Elite, cursive' },
{ value: 'creepster', label: 'Creepster', family: 'Creepster, cursive' },
{ value: 'pixel', label: 'Pixel', family: 'Silkscreen, monospace' },
{ value: 'mono', label: 'Mono', family: 'ui-monospace, monospace' },
];
/**
* Serializable stationery for localStorage persistence (no raw NostrEvent).
*/
export type SerializableStationery = Omit<Stationery, 'event'>;
/**
* User's default letter preferences persisted per-pubkey in settings.
*/
export interface LetterPreferences {
/** Font value key from FONT_OPTIONS (e.g. 'caveat') */
font?: string;
/** Default stationery (without raw event) */
stationery?: SerializableStationery;
/** Default frame style */
frame?: FrameStyle;
/** Whether frame should match stationery color */
frameTint?: boolean;
/** Default closing line (e.g. 'Warmly,') */
closing?: string;
/** Default signature (e.g. 'Chad') */
signature?: string;
/** Only show letters from friends in the inbox */
friendsOnlyInbox?: boolean;
/** Only show friends in search suggestions */
friendsOnlySearch?: boolean;
}
/** Build a Stationery from a preset key */
export function presetToStationery(key: string): Stationery | undefined {
const preset = STATIONERY_PRESETS[key];
if (!preset) return undefined;
return { color: preset.color, emoji: preset.emoji };
}
/** Build a Stationery from a kind 3367 color moment event. */
export function colorMomentToStationery(event: NostrEvent): Stationery {
const color = event.tags.find(([n]) => n === 'c')?.[1] ?? DEFAULT_STATIONERY_COLOR;
return { color, event };
}
/** Build a Stationery from a kind 36767 theme event. */
export function themeToStationery(event: NostrEvent): Stationery {
const bg = event.tags.filter(([n]) => n === 'c').find(([, , marker]) => marker === 'background');
return { color: bg?.[1] ?? DEFAULT_STATIONERY_COLOR, event };
}
+32
View File
@@ -0,0 +1,32 @@
import { FONT_OPTIONS } from '@/lib/letterTypes';
import { loadBundledFont } from '@/lib/fonts';
/**
* Resolve the effective font-family string for a letter.
*
* If the user has selected a non-default font, that takes priority.
* Otherwise the stationery's themeFont is used.
* Falls back to the default font if neither is set.
*/
export function resolveFont(
selectedFamily: string,
themeFont: string | undefined,
): string {
const defaultFamily = FONT_OPTIONS[0].family;
const rawFont = selectedFamily !== defaultFamily ? selectedFamily : themeFont;
if (!rawFont) return defaultFamily;
return rawFont.includes(',') ? rawFont : `${rawFont}, ${defaultFamily}`;
}
/**
* Ensure all bundled fonts referenced in a CSS font-family string are loaded.
* Parses the comma-separated list, strips quotes/whitespace, and calls
* loadBundledFont for each segment. No-ops for already-loaded or unknown fonts.
*/
export function ensureLetterFonts(cssFontFamily: string | undefined): void {
if (!cssFontFamily) return;
const families = cssFontFamily.split(',').map((s) => s.trim().replace(/^["']|["']$/g, ''));
for (const family of families) {
if (family) loadBundledFont(family);
}
}
+98
View File
@@ -0,0 +1,98 @@
/**
* SVG sanitization prevents XSS from untrusted SVG markup.
*
* The drawing canvas (svgDrawing.ts) produces SVGs containing only <svg> and
* <path> elements with a small set of presentation attributes. A malicious
* sender could craft a letter whose sticker `svg` field contains arbitrary
* HTML/JS (e.g. `<svg onload="…">`, `<foreignObject>`, `<script>`).
*
* This module uses DOMPurify configured for SVG-only output with an additional
* strict allowlist of elements and attributes that matches what the drawing
* canvas actually generates.
*/
import DOMPurify from 'dompurify';
/**
* Elements the drawing canvas can produce, plus structural SVG elements
* that are safe and useful for rendering simple vector graphics.
*/
const ALLOWED_TAGS = [
'svg',
'path',
'circle',
'ellipse',
'rect',
'line',
'polyline',
'polygon',
'g',
];
/**
* Attributes the drawing canvas uses, plus standard safe SVG presentation
* attributes. No event handlers, no `href`/`xlink:href`, no `style`.
*/
const ALLOWED_ATTRS = [
// Structural
'xmlns',
'viewBox',
'width',
'height',
// Path data
'd',
// Presentation
'fill',
'stroke',
'stroke-width',
'stroke-linecap',
'stroke-linejoin',
'stroke-dasharray',
'stroke-dashoffset',
'stroke-opacity',
'fill-opacity',
'opacity',
'transform',
// Geometry attributes for basic shapes
'cx',
'cy',
'r',
'rx',
'ry',
'x',
'y',
'x1',
'y1',
'x2',
'y2',
'points',
];
/** Maximum SVG string length (256 KB). Anything larger is likely malicious or degenerate. */
const MAX_SVG_LENGTH = 256 * 1024;
/**
* Sanitize an SVG string so it is safe to inject via `dangerouslySetInnerHTML`.
*
* Returns a clean SVG string with only allowlisted elements and attributes.
* Any scripts, event handlers, foreignObject, use/image elements, data URIs,
* and other dangerous constructs are stripped.
*
* SVGs exceeding MAX_SVG_LENGTH are rejected outright to prevent denial-of-service
* via oversized payloads that would force DOMPurify to parse megabytes of markup.
*/
export function sanitizeSvg(dirty: string): string {
if (dirty.length > MAX_SVG_LENGTH) return '';
return DOMPurify.sanitize(dirty, {
USE_PROFILES: { svg: true, svgFilters: false },
ALLOWED_TAGS,
ALLOWED_ATTR: ALLOWED_ATTRS,
// Strip <use>, <image>, <foreignObject>, <script>, <style>, etc.
FORBID_TAGS: ['script', 'style', 'foreignObject', 'use', 'image', 'a', 'iframe', 'embed', 'object'],
// Strip all event handler attributes (on*)
FORBID_ATTR: ['onload', 'onerror', 'onclick', 'onmouseover', 'onmouseout', 'onfocus', 'onblur', 'style', 'href', 'xlink:href'],
// Return string, not DOM node
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
});
}
+1
View File
@@ -86,6 +86,7 @@ export const ThemeConfigSchema = z.object({
title: z.string().optional(),
colors: CoreThemeColorsSchema,
font: ThemeFontSchema.optional(),
titleFont: ThemeFontSchema.optional(),
background: ThemeBackgroundSchema.optional(),
});
+43 -8
View File
@@ -15,7 +15,7 @@ import {
Earth,
Film,
HelpCircle,
Mail,
MessageSquare,
MessageSquareMore,
Mic,
@@ -39,6 +39,7 @@ import { ChestIcon } from "@/components/icons/ChestIcon";
import { PlanetIcon } from "@/components/icons/PlanetIcon";
import { WikipediaIcon } from "@/components/icons/WikipediaIcon";
import { BlueskyIcon } from "@/components/icons/BlueskyIcon";
import { MailboxIcon } from "@/components/icons/MailboxIcon";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -108,13 +109,6 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
icon: Bell,
requiresAuth: true,
},
{
id: "messages",
label: "Messages",
path: "/messages",
icon: Mail,
requiresAuth: true,
},
{ id: "search", label: "Search", path: "/search", icon: Search },
{ id: "trends", label: "Trends", path: "/trends", icon: TrendingUp },
{
@@ -139,6 +133,13 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
requiresAuth: true,
},
{ id: "settings", label: "Settings", path: "/settings", icon: Settings },
{
id: "letters",
label: "Letters",
path: "/letters",
icon: MailboxIcon,
requiresAuth: true,
},
{
id: "ai-chat",
label: "AI Chat",
@@ -229,6 +230,40 @@ export function itemPath(
return SIDEBAR_ITEM_MAP.get(id)?.path ?? `/${id}`;
}
/**
* Search sidebar items by label. Matches when the query is a prefix of the
* full label or of any word within the label (e.g. "arch" matches "Archive"
* and "Internet Archive" but not "Search"). Whole-label prefix matches are
* sorted before word-boundary matches. Auth-requiring items are excluded
* when the user is not logged in.
*/
export function searchSidebarItems(
query: string,
isLoggedIn: boolean,
): SidebarItemDef[] {
const q = query.trim().toLowerCase();
if (q.length === 0) return [];
const prefixMatches: SidebarItemDef[] = [];
const wordMatches: SidebarItemDef[] = [];
for (const item of SIDEBAR_ITEMS) {
if (item.requiresAuth && !isLoggedIn) continue;
const label = item.label.toLowerCase();
if (label.startsWith(q)) {
prefixMatches.push(item);
} else {
// Check if query matches the start of any word in the label
const words = label.split(/\s+/);
if (words.some((word) => word.startsWith(q))) {
wordMatches.push(item);
}
}
}
return [...prefixMatches, ...wordMatches];
}
/** Check if a sidebar item is active given the current location. */
export function isItemActive(
id: string,
+132
View File
@@ -0,0 +1,132 @@
/**
* SVG drawing utilities pure functions for converting freehand strokes
* into compact SVG markup.
*/
export interface Stroke {
points: [number, number][];
color: string;
width: number;
}
// ---------------------------------------------------------------------------
// Point simplification (Ramer-Douglas-Peucker)
// ---------------------------------------------------------------------------
/** Perpendicular distance from point p to line segment a-b */
function perpendicularDist(p: [number, number], a: [number, number], b: [number, number]): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
/** Ramer-Douglas-Peucker simplification. Epsilon ~1-2 works well for a 300-unit viewBox. */
export function simplifyPoints(points: [number, number][], epsilon: number): [number, number][] {
if (points.length <= 2) return points;
let maxDist = 0;
let maxIdx = 0;
const last = points.length - 1;
for (let i = 1; i < last; i++) {
const d = perpendicularDist(points[i], points[0], points[last]);
if (d > maxDist) { maxDist = d; maxIdx = i; }
}
if (maxDist <= epsilon) return [points[0], points[last]];
const left = simplifyPoints(points.slice(0, maxIdx + 1), epsilon);
const right = simplifyPoints(points.slice(maxIdx), epsilon);
return [...left.slice(0, -1), ...right];
}
// ---------------------------------------------------------------------------
// Path generation
// ---------------------------------------------------------------------------
/** Round a number to 1 decimal place to reduce SVG string size */
function r(n: number): number {
return Math.round(n * 10) / 10;
}
/** Build an SVG path d-attribute with quadratic bezier smoothing */
export function pointsToPath(points: [number, number][]): string {
if (points.length === 0) return '';
if (points.length === 1) {
return `M${r(points[0][0])},${r(points[0][1])}l0,0`;
}
if (points.length === 2) {
return `M${r(points[0][0])},${r(points[0][1])}L${r(points[1][0])},${r(points[1][1])}`;
}
let d = `M${r(points[0][0])},${r(points[0][1])}`;
for (let i = 1; i < points.length - 1; i++) {
const [cx, cy] = points[i];
const [nx, ny] = points[i + 1];
d += `Q${r(cx)},${r(cy)},${r((cx + nx) / 2)},${r((cy + ny) / 2)}`;
}
const last = points[points.length - 1];
d += `L${r(last[0])},${r(last[1])}`;
return d;
}
// ---------------------------------------------------------------------------
// Bounding box
// ---------------------------------------------------------------------------
export function computeBBox(strokes: Stroke[]): { x: number; y: number; w: number; h: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const stroke of strokes) {
const hw = stroke.width / 2;
for (const [px, py] of stroke.points) {
if (px - hw < minX) minX = px - hw;
if (py - hw < minY) minY = py - hw;
if (px + hw > maxX) maxX = px + hw;
if (py + hw > maxY) maxY = py + hw;
}
}
if (!isFinite(minX)) return null;
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
// ---------------------------------------------------------------------------
// SVG serialization
// ---------------------------------------------------------------------------
const PADDING = 8;
const SIMPLIFY_EPSILON = 1.5;
/** Matches hex colors (#rgb, #rrggbb, #rrggbbaa) and common CSS named colors. */
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
/** Sanitize a color value for safe SVG attribute interpolation. Returns a fallback for invalid values. */
function safeColor(color: string): string {
return HEX_COLOR_RE.test(color) ? color : '#000000';
}
/** Clamp stroke width to a safe finite number. */
function safeWidth(width: number): number {
const n = Number(width);
return Number.isFinite(n) && n > 0 && n <= 100 ? n : 4;
}
/** Generate a tightly-cropped SVG string from strokes. Points are simplified to reduce size. */
export function strokesToSvg(strokes: Stroke[]): string | null {
if (strokes.length === 0) return null;
const bbox = computeBBox(strokes);
if (!bbox || bbox.w < 1 || bbox.h < 1) return null;
const vx = r(Math.max(0, bbox.x - PADDING));
const vy = r(Math.max(0, bbox.y - PADDING));
const vw = r(bbox.w + PADDING * 2);
const vh = r(bbox.h + PADDING * 2);
const paths = strokes.map((s) => {
const simplified = simplifyPoints(s.points, SIMPLIFY_EPSILON);
return `<path d="${pointsToPath(simplified)}" fill="none" stroke="${safeColor(s.color)}" stroke-width="${safeWidth(s.width)}" stroke-linecap="round" stroke-linejoin="round"/>`;
});
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vx} ${vy} ${vw} ${vh}">${paths.join('')}</svg>`;
}
+45 -20
View File
@@ -48,23 +48,44 @@ function parseColorTags(tags: string[][]): CoreThemeColors | null {
// ─── Font Tag Helpers ─────────────────────────────────────────────────
/** Build an `f` tag from a ThemeFont. */
function buildFontTag(font: ThemeFont | undefined): string[][] {
if (!font?.family) return [];
const tag = ['f', font.family];
if (font.url) tag.push(font.url);
return [tag];
/** Build `f` tags from body and title fonts. Body tag is always ordered before title tag. */
function buildFontTags(font: ThemeFont | undefined, titleFont: ThemeFont | undefined): string[][] {
const tags: string[][] = [];
if (font?.family) {
const tag = ['f', font.family];
if (font.url) tag.push(font.url); else tag.push('');
tag.push('body');
tags.push(tag);
}
if (titleFont?.family) {
const tag = ['f', titleFont.family];
if (titleFont.url) tag.push(titleFont.url); else tag.push('');
tag.push('title');
tags.push(tag);
}
return tags;
}
/** Parse the first `f` tag into a ThemeFont. Returns undefined if no f tag. */
function parseFontTag(tags: string[][]): ThemeFont | undefined {
/** Parse `f` tags into body and title ThemeFonts. Legacy tags without a role are treated as body. */
function parseFontTags(tags: string[][]): { font?: ThemeFont; titleFont?: ThemeFont } {
let font: ThemeFont | undefined;
let titleFont: ThemeFont | undefined;
for (const tag of tags) {
if (tag[0] !== 'f' || !tag[1]) continue;
const font: ThemeFont = { family: tag[1] };
if (tag[2]) font.url = tag[2];
return font;
const role = tag[3]; // 4th element: "body", "title", or absent (legacy)
const parsed: ThemeFont = { family: tag[1] };
if (tag[2]) parsed.url = tag[2];
if (role === 'title') {
if (!titleFont) titleFont = parsed;
} else {
// "body" or absent (legacy) — treat as body font
if (!font) font = parsed;
}
}
return undefined;
return { font, titleFont };
}
// ─── Background Tag Helpers ───────────────────────────────────────────
@@ -119,8 +140,10 @@ export interface ThemeDefinition {
description?: string;
/** The 3 core theme colors */
colors: CoreThemeColors;
/** Optional custom font */
/** Optional custom body font */
font?: ThemeFont;
/** Optional title/header font (profile display name) */
titleFont?: ThemeFont;
/** Optional background */
background?: ThemeBackground;
/** The original Nostr event */
@@ -154,10 +177,10 @@ export function parseThemeDefinition(event: NostrEvent): ThemeDefinition | null
if (!colors) return null;
const font = parseFontTag(event.tags);
const { font, titleFont } = parseFontTags(event.tags);
const background = parseBackgroundTag(event.tags);
return { identifier, title, description, colors, font, background, event };
return { identifier, title, description, colors, font, titleFont, background, event };
}
/** Create tags for a kind 36767 theme definition event. */
@@ -170,7 +193,7 @@ export function buildThemeDefinitionTags(
const tags: string[][] = [
['d', identifier],
...buildColorTags(themeConfig.colors),
...buildFontTag(themeConfig.font),
...buildFontTags(themeConfig.font, themeConfig.titleFont),
...buildBackgroundTag(themeConfig.background),
['title', title],
['alt', `Custom theme: ${title}`],
@@ -198,8 +221,10 @@ export function titleToSlug(title: string): string {
export interface ActiveProfileTheme {
/** The 3 core theme colors */
colors: CoreThemeColors;
/** Optional custom font */
/** Optional custom body font */
font?: ThemeFont;
/** Optional title/header font (profile display name) */
titleFont?: ThemeFont;
/** Optional background */
background?: ThemeBackground;
/** naddr-style reference to the source theme definition, if any */
@@ -227,11 +252,11 @@ export function parseActiveProfileTheme(event: NostrEvent): ActiveProfileTheme |
if (!colors) return null;
const font = parseFontTag(event.tags);
const { font, titleFont } = parseFontTags(event.tags);
const background = parseBackgroundTag(event.tags);
const sourceRef = event.tags.find(([n]) => n === 'a')?.[1];
return { colors, font, background, sourceRef, event };
return { colors, font, titleFont, background, sourceRef, event };
}
/** Create tags for a kind 16767 active profile theme event. */
@@ -242,7 +267,7 @@ export function buildActiveThemeTags(
): string[][] {
const tags: string[][] = [
...buildColorTags(themeConfig.colors),
...buildFontTag(themeConfig.font),
...buildFontTags(themeConfig.font, themeConfig.titleFont),
...buildBackgroundTag(themeConfig.background),
['alt', 'Active profile theme'],
];
+1 -1
View File
@@ -575,7 +575,7 @@ function MessageBubble({ message }: { message: DisplayMessage }) {
{isUser ? (
<p className="whitespace-pre-wrap break-words">{message.content}</p>
) : (
<div className="prose prose-sm max-w-none text-foreground prose-headings:text-foreground prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2 prose-code:text-xs prose-a:text-primary">
<div className="prose prose-sm max-w-none text-foreground prose-headings:text-foreground prose-strong:text-foreground prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2 prose-code:text-xs prose-a:text-primary">
<Markdown rehypePlugins={[rehypeSanitize]}>
{message.content}
</Markdown>
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Bug, CalendarDays, ExternalLink, FlaskConical, Minus, Package, Plus, RefreshCw, ScrollText, ShieldAlert, Tag } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { PageHeader } from '@/components/PageHeader';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { parseChangelog } from '@/lib/changelog';
import type { ChangelogCategory } from '@/lib/changelog';
const GITLAB_REPO = 'https://gitlab.com/soapbox-pub/ditto';
/** Per-category badge color + icon. */
const CATEGORY_STYLES: Record<ChangelogCategory, { icon: typeof Plus; className: string }> = {
Added: {
icon: Plus,
className: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
},
Changed: {
icon: RefreshCw,
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
},
Deprecated: {
icon: Package,
className: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
},
Removed: {
icon: Minus,
className: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
},
Fixed: {
icon: Bug,
className: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
},
Security: {
icon: ShieldAlert,
className: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300',
},
};
/** Format "2026-03-26" as a readable date string. */
function formatDate(raw: string): string {
const date = new Date(raw + 'T00:00:00');
if (isNaN(date.getTime())) return raw;
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
}
const commitSha = import.meta.env.COMMIT_SHA;
const commitTag = import.meta.env.COMMIT_TAG;
const isPreRelease = !commitTag;
export function ChangelogPage() {
const { config } = useAppContext();
const [content, setContent] = useState<string | null>(null);
const [error, setError] = useState(false);
useLayoutOptions({});
useSeoMeta({
title: `Changelog | ${config.appName}`,
description: `What's new in ${config.appName}`,
});
useEffect(() => {
fetch('/CHANGELOG.md')
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.text();
})
.then(setContent)
.catch(() => setError(true));
}, []);
const entries = useMemo(() => (content ? parseChangelog(content) : []), [content]);
const latestVersion = entries[0]?.version;
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<PageHeader title="Changelog" icon={<ScrollText className="size-5" />} backTo="/settings" />
<div className="px-4 pt-3 pb-8 space-y-4">
{error ? (
<p className="text-sm text-muted-foreground pt-4">Failed to load changelog.</p>
) : content === null ? (
<ChangelogSkeleton />
) : entries.length === 0 ? (
<p className="text-sm text-muted-foreground pt-4">No releases yet.</p>
) : (
<>
{isPreRelease && latestVersion && <PreReleaseBanner latestVersion={latestVersion} />}
{entries.map((entry) => (
<div key={entry.version} className="rounded-2xl border border-border overflow-hidden">
{/* Version header */}
<div className="flex items-center gap-3 px-4 py-3 bg-secondary/30">
<Tag className="size-4 text-primary shrink-0" />
<span className="font-semibold text-sm">v{entry.version}</span>
<div className="flex items-center gap-3 text-xs text-muted-foreground ml-auto">
<div className="flex items-center gap-1.5">
<CalendarDays className="size-3.5" />
<span>{formatDate(entry.date)}</span>
</div>
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
title={`View v${entry.version} on GitLab`}
>
<ExternalLink className="size-3.5" />
</a>
</div>
</div>
{/* Sections */}
<div className="divide-y divide-border">
{entry.sections.map((section) => {
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
const Icon = style.icon;
return (
<div key={section.category} className="px-4 py-3 space-y-2">
<Badge variant="secondary" className={`gap-1 text-[10px] px-1.5 py-0 ${style.className}`}>
<Icon className="size-3" />
{section.category}
</Badge>
<ul className="space-y-1">
{section.items.map((item, i) => (
<li key={i} className="text-sm text-foreground/90 pl-3 relative before:absolute before:left-0 before:top-[0.6em] before:size-1 before:rounded-full before:bg-muted-foreground/40">
{item}
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
))}
</>
)}
</div>
</main>
);
}
/** Banner shown at the top of the changelog for untagged (pre-release) builds. */
function PreReleaseBanner({ latestVersion }: { latestVersion: string }) {
return (
<div className="rounded-2xl border border-dashed border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20 px-4 py-3 space-y-1.5">
<div className="flex items-center gap-2">
<FlaskConical className="size-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-sm font-medium text-amber-800 dark:text-amber-300">Pre-release build</span>
{commitSha && (
<a
href={`${GITLAB_REPO}/-/commit/${commitSha}`}
target="_blank"
rel="noopener noreferrer"
className="ml-auto text-[11px] font-mono text-amber-600/70 dark:text-amber-400/70 hover:text-amber-800 dark:hover:text-amber-300 transition-colors"
>
{commitSha}
</a>
)}
</div>
<p className="text-xs text-amber-700/80 dark:text-amber-400/70">
This build contains changes not yet included in a release.{' '}
<a
href={`${GITLAB_REPO}/-/compare/v${latestVersion}...main`}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200 transition-colors"
>
View unreleased changes
</a>
</p>
</div>
);
}
function ChangelogSkeleton() {
return (
<div className="space-y-4 pt-1">
{[1, 2].map((i) => (
<div key={i} className="rounded-2xl border border-border overflow-hidden">
<div className="flex items-center gap-3 px-4 py-3 bg-secondary/30">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-4 w-16" />
<div className="ml-auto flex items-center gap-1.5">
<Skeleton className="size-3.5 rounded" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<div className="px-4 py-3 space-y-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-full ml-3" />
<Skeleton className="h-4 w-4/5 ml-3" />
<Skeleton className="h-4 w-3/5 ml-3" />
</div>
</div>
))}
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { useSeoMeta } from '@unhead/react';
import { LetterPreferencesSection } from '@/components/letter/LetterPreferencesSection';
export function LetterPreferencesPage() {
useSeoMeta({
title: 'Letter Preferences',
description: 'Customize your default letter stationery, font, and inbox settings',
});
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<LetterPreferencesSection />
</main>
);
}
+203
View File
@@ -0,0 +1,203 @@
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate } from 'react-router-dom';
import { PenLine, Settings, Loader2 } from 'lucide-react';
import { MailboxIcon } from '@/components/icons/MailboxIcon';
import { Button } from '@/components/ui/button';
import { FabButton } from '@/components/FabButton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useInbox, useSentLetters } from '@/hooks/useLetters';
import { useLetterPreferences } from '@/hooks/useLetterPreferences';
import { useFollowList } from '@/hooks/useFollowActions';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { LoginArea } from '@/components/auth/LoginArea';
import { PageHeader } from '@/components/PageHeader';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { EnvelopeCard } from '@/components/letter/EnvelopeCard';
import { LetterDetailSheet } from '@/components/letter/LetterDetailSheet';
import { ComposeLetterSheet } from '@/components/letter/ComposeLetterSheet';
import type { Letter } from '@/lib/letterTypes';
type Tab = 'inbox' | 'sent';
/** Skeleton envelope matching the grid tile shape. */
function EnvelopeSkeleton({ index }: { index: number }) {
return (
<div
className="envelope-skeleton flex flex-col items-center gap-2"
style={{ animationDelay: `${index * 150}ms` }}
>
<div className="w-full rounded-lg bg-muted/60" style={{ aspectRatio: '4 / 3' }} />
<div className="flex flex-col items-center gap-1 w-full">
<div className="h-3 w-14 rounded-full bg-muted/50" />
<div className="h-2 w-10 rounded-full bg-muted/30" />
</div>
</div>
);
}
export function LettersPage() {
const { user } = useCurrentUser();
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>('inbox');
const [composing, setComposing] = useState(false);
const [selectedLetter, setSelectedLetter] = useState<Letter | null>(null);
useLayoutOptions({ showFAB: false, hasSubHeader: !!user, noOverscroll: composing });
const { prefs } = useLetterPreferences();
const followListData = useFollowList();
const followedPubkeys = followListData.data?.pubkeys;
// If friendsOnlyInbox is enabled, only show letters from followed users
const inboxFilter = prefs.friendsOnlyInbox && followedPubkeys
? followedPubkeys
: undefined;
const inboxQuery = useInbox(inboxFilter);
const sentQuery = useSentLetters();
const inbox = inboxQuery.data;
const inboxLoading = inboxQuery.isLoading;
const sent = sentQuery.data;
const sentLoading = sentQuery.isLoading;
const activeQuery = tab === 'inbox' ? inboxQuery : sentQuery;
useSeoMeta({ title: 'Letters', description: 'Your private encrypted letters' });
if (!user) {
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<PageHeader title="Letters" icon={<MailboxIcon className="size-5" />} backTo="/" />
<div className="flex flex-col items-center justify-center py-24 gap-6 px-6 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center">
<MailboxIcon className="w-10 h-10 text-primary" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold">Your personal inbox</h2>
<p className="text-muted-foreground text-sm max-w-xs">
Send and receive beautiful encrypted letters with stationery, frames, and stickers.
</p>
</div>
<LoginArea />
</div>
</main>
);
}
const activeLetters = tab === 'inbox' ? inbox : sent;
const isLoading = tab === 'inbox' ? inboxLoading : sentLoading;
return (
<main
className={composing ? 'relative h-screen overflow-hidden' : 'relative min-h-screen pb-16 sidebar:pb-0'}
style={composing ? { touchAction: 'none' } : undefined}
>
{composing && (
<ComposeLetterSheet
onClose={() => setComposing(false)}
/>
)}
<PageHeader title="Letters" icon={<MailboxIcon className="size-5" />} backTo="/" alwaysShowBack>
<button
onClick={() => navigate('/settings/letters')}
className="p-2 rounded-full text-muted-foreground hover:text-foreground transition-colors"
title="Letter preferences"
>
<Settings className="w-4 h-4" />
</button>
</PageHeader>
{/* Tabs */}
<SubHeaderBar>
<TabButton label="Inbox" active={tab === 'inbox'} onClick={() => setTab('inbox')} />
<TabButton label="Sent" active={tab === 'sent'} onClick={() => setTab('sent')} />
</SubHeaderBar>
<div style={{ height: ARC_OVERHANG_PX }} />
{/* Envelope grid */}
<div className="px-4 py-3">
{isLoading && (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 sidebar:grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => (
<EnvelopeSkeleton key={i} index={i} />
))}
</div>
)}
{!isLoading && activeLetters && activeLetters.length === 0 && (
<div className="py-16 text-center space-y-3">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto">
<MailboxIcon className="w-8 h-8 text-muted-foreground opacity-50" />
</div>
<p className="text-muted-foreground text-sm">
{tab === 'inbox'
? prefs.friendsOnlyInbox
? 'no letters from friends yet'
: 'no letters yet'
: 'no sent letters yet'
}
</p>
{tab === 'inbox' && (
<p className="text-xs text-muted-foreground opacity-70">
ask a friend to send you a letter
</p>
)}
</div>
)}
{!isLoading && activeLetters && activeLetters.length > 0 && (
<>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 sidebar:grid-cols-3">
{activeLetters.map((letter, i) => (
<EnvelopeCard
key={letter.event.id}
letter={letter}
mode={tab}
index={i}
onClick={() => setSelectedLetter(letter)}
/>
))}
</div>
{activeQuery.hasNextPage && (
<div className="flex justify-center pt-6 pb-2">
<Button
variant="ghost"
size="sm"
onClick={() => activeQuery.fetchNextPage()}
disabled={activeQuery.isFetchingNextPage}
className="gap-2"
>
{activeQuery.isFetchingNextPage && <Loader2 className="size-4 animate-spin" />}
Load more
</Button>
</div>
)}
</>
)}
</div>
{/* Letter detail drawer */}
<LetterDetailSheet
letter={selectedLetter}
onClose={() => setSelectedLetter(null)}
/>
{/* Compose FAB */}
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton onClick={() => setComposing(true)} icon={<PenLine size={18} strokeWidth={3} />} title="Write a letter" />
</div>
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
<div className="pointer-events-auto">
<FabButton onClick={() => setComposing(true)} icon={<PenLine size={18} strokeWidth={3} />} title="Write a letter" />
</div>
</div>
</div>
</main>
);
}
+1 -1
View File
@@ -57,7 +57,7 @@ function parseRemoteList(event: NostrEvent): UserList {
const description = getTag('description') || getTag('summary') || undefined;
const image = getTag('image') || getTag('thumb') || undefined;
const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk);
return { id, title, description, image, pubkeys, event };
return { id, title, description, image, pubkeys, privatePubkeys: [], event };
}
type Tab = 'feed' | 'members';
+147 -10
View File
@@ -50,6 +50,7 @@ import { MagicDeckContent } from "@/components/MagicDeckContent";
import { MusicDetailContent } from "@/components/MusicDetailContent";
import { NoteCard } from "@/components/NoteCard";
import { NoteContent } from "@/components/NoteContent";
import { NsiteCard } from "@/components/NsiteCard";
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
import { PatchCard } from "@/components/PatchCard";
import { PodcastDetailContent } from "@/components/PodcastDetailContent";
@@ -70,6 +71,7 @@ import {
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { EncryptedMessageContent } from "@/components/EncryptedMessageContent";
import { VanishEventContent } from "@/components/VanishEventContent";
import { VideoPlayer } from "@/components/VideoPlayer";
import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer";
@@ -123,7 +125,11 @@ function shellTitleForKind(kind?: number): string {
if (kind === BADGE_PROFILE_KIND) return "Badge Collection";
if (kind === BOOK_REVIEW_KIND) return "Book Review";
if (kind === 32267) return "App Details";
if (kind === 15128 || kind === 35128) return "Nsite";
if (kind === VANISH_KIND) return "Request to Vanish";
if (kind === 4) return "Encrypted Message";
if (kind === 6 || kind === 16) return "Repost";
if (kind === 7) return "Reaction";
return "Post Details";
}
@@ -865,15 +871,18 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
const isTheme = event.kind === 36767 || event.kind === 16767;
const isVoiceMessage = event.kind === 1222 || event.kind === 1244;
const isReaction = event.kind === 7;
const isRepost = event.kind === 6 || event.kind === 16;
const isVideo = event.kind === 21 || event.kind === 22;
const isCommunity = event.kind === 34550;
const isGitRepo = event.kind === 30617;
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 === VANISH_KIND;
const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip;
const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite;
const isTextNote =
!isVine &&
!isPoll &&
@@ -888,10 +897,12 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
!isTheme &&
!isVoiceMessage &&
!isReaction &&
!isRepost &&
!isVideo &&
!isCommunity &&
!isDevKind &&
!isZapstoreApp &&
!isEncryptedDM &&
!isVanish;
const videos = useMemo(
@@ -1100,8 +1111,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
useState<InteractionTab>("reposts");
const parentEventId = useMemo(
() => (isTextNote || isReaction ? getParentEventId(event) : undefined),
[event, isTextNote, isReaction],
() => (isTextNote || isReaction || isRepost ? getParentEventId(event) : undefined),
[event, isTextNote, isReaction, isRepost],
);
// For kind 1111 comments on external content, extract the I tag for the parent preview
@@ -1222,10 +1233,12 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
setInteractionsOpen(true);
};
const interactionQuoteCount = interactions?.quotes.length ?? 0;
const quoteCount = interactionQuoteCount || (stats?.quotes ?? 0);
const repostTotal = (stats?.reposts ?? 0) + (stats?.quotes ?? 0);
const hasStats = !!(
stats?.reposts ||
stats?.quotes ||
quoteCount ||
stats?.reactions ||
stats?.zapCount
);
@@ -1248,7 +1261,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<div ref={ancestorRef}>
<AncestorThread
eventId={parentEventId}
collapseAfter={isReaction ? 0 : undefined}
collapseAfter={isReaction || isRepost ? 0 : undefined}
/>
</div>
)}
@@ -1375,6 +1388,124 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</article>
)}
{/* Repost event (kind 6 / 16) — compact activity-style card */}
{isRepost && (
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
<div className="flex items-center gap-3">
{/* Repost icon bubble — size-10 matches the threaded ancestor avatar column */}
<div className="flex items-center justify-center size-10 rounded-full bg-accent/10 shrink-0">
<RepostIcon className="size-5 text-accent" />
</div>
{/* Author + "reposted" label + timestamp — single line */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-4 w-28" />
</>
) : (
<>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0">
<Avatar shape={avatarShape} className="size-6">
<AvatarImage
src={metadata?.picture}
alt={displayName}
/>
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-bold text-sm hover:underline truncate"
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground">reposted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{formatFullDate(event.created_at)}
</span>
</>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center justify-between py-1 mt-2 border-t border-b border-border -mx-4 px-4">
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Reply"
onClick={() => setReplyOpen(true)}
>
<MessageCircle className="size-5" />
{stats?.replies ? (
<span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span>
) : null}
</button>
<RepostMenu event={event}>
{(isReposted: boolean) => (
<button
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? "text-accent hover:text-accent/80 hover:bg-accent/10" : "text-muted-foreground hover:text-accent hover:bg-accent/10"}`}
title={isReposted ? "Undo repost" : "Repost"}
>
<RepostIcon className="size-5" />
{repostTotal ? (
<span className="text-sm tabular-nums">{formatNumber(repostTotal)}</span>
) : null}
</button>
)}
</RepostMenu>
<ReactionButton
eventId={event.id}
eventPubkey={event.pubkey}
eventKind={event.kind}
reactionCount={stats?.reactions}
/>
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
title="Share"
onClick={handleShare}
>
<Share2 className="size-5" />
</button>
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
onClick={() => setMoreMenuOpen(true)}
>
<MoreHorizontal className="size-5" />
</button>
</div>
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</article>
)}
{/* Kind 62 — Request to Vanish: dramatic full-width display, no author row */}
{isVanish && (
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
@@ -1456,7 +1587,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
)}
{/* Main post — expanded Ditto-style view */}
{!isReaction && !isVanish && (
{!isReaction && !isRepost && !isVanish && (
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
{/* Author row */}
<div className="flex items-center gap-3">
@@ -1555,8 +1686,14 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<div className="mt-3">
<CustomNipCard event={event} preview={false} />
</div>
) : isNsite ? (
<div className="mt-3">
<NsiteCard event={event} />
</div>
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} />
) : isEncryptedDM ? (
<EncryptedMessageContent event={event} />
) : isVine ||
isPoll ||
isGeocache ||
@@ -1628,15 +1765,15 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
Repost{stats.reposts !== 1 ? "s" : ""}
</button>
) : null}
{stats?.quotes ? (
{quoteCount ? (
<button
onClick={() => openInteractions("quotes")}
className="hover:underline transition-colors"
>
<span className="font-bold text-foreground">
{formatNumber(stats.quotes)}
{formatNumber(quoteCount)}
</span>{" "}
Quote{stats.quotes !== 1 ? "s" : ""}
Quote{quoteCount !== 1 ? "s" : ""}
</button>
) : null}
{stats?.reactions ? (
@@ -1721,7 +1858,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</div>
)}
{/* Action buttons — Ditto style: distributed across full width */}
{/* Action buttons — Ditto style: distributed across full width */}
<div className="flex items-center justify-between py-1 border-t border-b border-border -mx-4 px-4">
{/* Reply */}
<button
+37 -14
View File
@@ -81,10 +81,11 @@ import {
} from '@dnd-kit/sortable';
import { CSS as DndCSS } from '@dnd-kit/utilities';
import { buildThemeCssFromCore, coreToTokens, buildThemeCss, resolveTheme, resolveThemeConfig, toThemeVar, type CoreThemeColors, type ThemeConfig, type ThemeFont, type ThemeBackground } from '@/themes';
import { loadAndApplyFont } from '@/lib/fontLoader';
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
import { resolveCssFamily } from '@/lib/fonts';
import { hslStringToHex, hexToHslString } from '@/lib/colorUtils';
import { ColorPicker } from '@/components/ui/color-picker';
import { FontPicker } from '@/components/FontPicker';
import { FontSection } from '@/components/FontPicker';
import { BackgroundPicker } from '@/components/BackgroundPicker';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { formatNumber } from '@/lib/formatNumber';
@@ -1327,6 +1328,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
primary: '258 70% 60%',
});
const [localProfileFont, setLocalProfileFont] = useState<ThemeFont | undefined>();
const [localProfileTitleFont, setLocalProfileTitleFont] = useState<ThemeFont | undefined>();
const [localProfileBg, setLocalProfileBg] = useState<ThemeBackground | undefined>();
// Initialize local state from profile theme when dialog opens
@@ -1334,6 +1336,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
if (editProfileThemeOpen && profileTheme) {
setLocalProfileColors(profileTheme.colors);
setLocalProfileFont(profileTheme.font);
setLocalProfileTitleFont(profileTheme.titleFont);
setLocalProfileBg(profileTheme.background);
}
}, [editProfileThemeOpen, profileTheme]);
@@ -1347,6 +1350,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
const ownThemeRef = useRef({ ownTheme, ownCustomTheme, configuredThemes });
ownThemeRef.current = { ownTheme, ownCustomTheme, configuredThemes };
const profileThemeFont = (showCustomProfileThemes || isOwnProfile) ? profileTheme?.font : undefined;
const profileThemeTitleFont = (showCustomProfileThemes || isOwnProfile) ? profileTheme?.titleFont : undefined;
const profileThemeBackground = (showCustomProfileThemes || isOwnProfile) ? profileTheme?.background : undefined;
// Whether we need to override the custom theme on this profile.
@@ -1367,11 +1371,12 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
`${hslStringToHex(c.primary)}${hslStringToHex(c.text)}${hslStringToHex(c.background)}`;
const fontFamily = (f?: { family: string }) => f?.family ?? '';
const ownCustomThemeSnapshot = ownCustomTheme
? colorsToHex(ownCustomTheme.colors) + fontFamily(ownCustomTheme.font) + JSON.stringify(ownCustomTheme.background ?? '')
? colorsToHex(ownCustomTheme.colors) + fontFamily(ownCustomTheme.font) + fontFamily(ownCustomTheme.titleFont) + JSON.stringify(ownCustomTheme.background ?? '')
: null;
const profileThemeDiffers = profileHasTheme && ownCustomThemeSnapshot && profileTheme && ownCustomTheme
? (colorsToHex(profileTheme.colors) !== colorsToHex(ownCustomTheme.colors)
|| fontFamily(profileTheme.font) !== fontFamily(ownCustomTheme.font)
|| fontFamily(profileTheme.titleFont) !== fontFamily(ownCustomTheme.titleFont)
|| JSON.stringify(profileTheme.background ?? '') !== JSON.stringify(ownCustomTheme.background ?? ''))
: false;
@@ -1397,6 +1402,12 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
() => profileThemeColors ? (profileThemeFont ?? { family: 'Inter' }) : undefined,
[profileThemeColors, profileThemeFont],
);
// Title font falls back to the body font when not explicitly set,
// so the display name inherits the theme's body font rather than the default.
const effectiveProfileTitleFont = useMemo(
() => profileThemeColors ? (profileThemeTitleFont ?? effectiveProfileFont) : undefined,
[profileThemeColors, profileThemeTitleFont, effectiveProfileFont],
);
const effectiveProfileBackground = profileThemeColors ? profileThemeBackground : undefined;
useLayoutEffect(() => {
@@ -1416,6 +1427,9 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
// Apply profile font (if any)
loadAndApplyFont(effectiveProfileFont);
// Apply profile title font (if any)
loadAndApplyTitleFont(effectiveProfileTitleFont);
// Apply profile background image (if any)
const bgStyleId = 'theme-background';
const previousBgEl = document.getElementById(bgStyleId) as HTMLStyleElement | null;
@@ -1461,6 +1475,9 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
// Restore own font or clear override
loadAndApplyFont(ownActiveConfig?.font);
// Restore own title font or clear override
loadAndApplyTitleFont(ownActiveConfig?.titleFont);
// Restore own background or remove override
const bgEl = document.getElementById(bgStyleId) as HTMLStyleElement | null;
const ownBgUrl = ownActiveConfig?.background?.url;
@@ -1485,7 +1502,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
bgEl?.remove();
}
};
}, [effectiveProfileColors, effectiveProfileFont, effectiveProfileBackground]);
}, [effectiveProfileColors, effectiveProfileFont, effectiveProfileTitleFont, effectiveProfileBackground]);
const pinnedIds = useMemo(() => supplementary?.pinnedIds ?? [], [supplementary?.pinnedIds]);
@@ -1984,7 +2001,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
{/* NIP-38 thought bubble — floats beside the avatar over the banner */}
{feedSettings.showUserStatuses !== false && profileStatus.status && (
<div className="absolute -top-2 left-[calc(100%+8px)] z-10 max-w-[280px] md:max-w-[360px] animate-in fade-in slide-in-from-left-1 duration-300">
<div className="absolute top-3 md:top-4 left-[calc(100%+8px)] z-10 max-w-[280px] md:max-w-[360px] animate-in fade-in slide-in-from-left-1 duration-300">
<div className="relative bg-background/90 backdrop-blur-sm border border-border rounded-xl px-3 py-1.5 shadow-lg">
<p className="text-xs md:text-sm text-foreground italic truncate pr-1">
{profileStatus.url ? (
@@ -1995,9 +2012,9 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
profileStatus.status
)}
</p>
{/* Speech bubble triangle tail — slightly angled toward avatar */}
<div className="absolute -bottom-[6px] left-3 size-0 border-l-[4px] border-l-transparent border-r-[8px] border-r-transparent border-t-[6px] border-t-border" />
<div className="absolute -bottom-[5px] left-3 size-0 border-l-[4px] border-l-transparent border-r-[8px] border-r-transparent border-t-[6px] border-t-background" />
{/* Speech bubble triangle tail — bottom-left corner, points diagonally down-left toward avatar */}
<div className="absolute -bottom-[7px] left-1 size-0 border-t-[8px] border-t-border border-r-[8px] border-r-transparent" />
<div className="absolute -bottom-[5.5px] left-1 size-0 border-t-[7px] border-t-background border-r-[7px] border-r-transparent" />
</div>
</div>
)}
@@ -2060,7 +2077,10 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
</div>
</div>
<h2 className="text-xl font-bold truncate">
<h2
className="text-xl font-bold truncate"
style={effectiveProfileTitleFont ? { fontFamily: 'var(--title-font-family)' } : undefined}
>
{metadataEvent ? (
<EmojifiedText tags={metadataEvent.tags}>{displayName}</EmojifiedText>
) : displayName}
@@ -2681,7 +2701,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
<PortalContainerProvider value={editThemePortalContainer}>
<div className="overflow-y-auto max-h-[85vh] p-6 space-y-4">
<DialogHeader>
<DialogTitle>Edit Profile Theme</DialogTitle>
<DialogTitle style={localProfileTitleFont?.family ? { fontFamily: `"${resolveCssFamily(localProfileTitleFont.family)}", inherit` } : undefined}>Edit Profile Theme</DialogTitle>
<DialogDescription>
Customize the theme visitors see on your profile
</DialogDescription>
@@ -2705,10 +2725,12 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
))}
</div>
{/* Font */}
<FontPicker
value={localProfileFont}
onChange={setLocalProfileFont}
{/* Fonts (body + title) */}
<FontSection
bodyFont={localProfileFont}
onBodyFontChange={setLocalProfileFont}
titleFont={localProfileTitleFont}
onTitleFontChange={setLocalProfileTitleFont}
/>
{/* Background */}
@@ -2727,6 +2749,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
themeConfig: {
colors: localProfileColors,
font: localProfileFont,
titleFont: localProfileTitleFont,
background: localProfileBg,
},
});
+13 -1
View File
@@ -1,7 +1,7 @@
import { useSeoMeta } from '@unhead/react';
import { useState, useEffect, useRef } from 'react';
import { ChevronRight, Settings } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
@@ -57,6 +57,13 @@ const settingsSections: SettingsSection[] = [
path: '/settings/notifications',
requiresAuth: true,
},
{
id: 'letters',
label: 'Letters',
description: 'Default stationery, font, frame, and inbox preferences for encrypted letters',
path: '/settings/letters',
requiresAuth: true,
},
{
id: 'advanced',
label: 'Advanced',
@@ -171,6 +178,11 @@ export function SettingsPage() {
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-primary/20 to-primary/30" />
</div>
{/* Version footer */}
<Link to="/changelog" className="block text-center text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors select-none pt-1 pb-2">
v{import.meta.env.VERSION}{import.meta.env.COMMIT_TAG ? '' : '+'} ({new Date(import.meta.env.BUILD_DATE).toLocaleDateString()})
</Link>
{/* Magic sigil — appears after 2 min inactivity, only when magic is locked */}
{!config.magicMouse && sigilVisible && (<div className="flex justify-center pt-16 pb-12">
<button
+4
View File
@@ -54,6 +54,8 @@ export interface ThemeConfig {
colors: CoreThemeColors;
/** Optional custom font (applies globally to all text) */
font?: ThemeFont;
/** Optional title/header font (applies to profile display name) */
titleFont?: ThemeFont;
/** Optional background media */
background?: ThemeBackground;
}
@@ -125,6 +127,8 @@ export interface ThemePreset {
colors: CoreThemeColors;
/** Optional custom font for this preset. */
font?: ThemeFont;
/** Optional title/header font for this preset. */
titleFont?: ThemeFont;
/** Optional background for this preset. */
background?: ThemeBackground;
}
+8
View File
@@ -7,6 +7,14 @@ declare module '@fontsource/comic-relief/*';
interface ImportMetaEnv {
/** Hex pubkey of the nostr-push server for Web Push notifications. */
readonly VITE_NOSTR_PUSH_PUBKEY?: string;
/** Semver version from package.json (e.g., "2.0.0"). */
readonly VERSION: string;
/** ISO 8601 timestamp of when the app was built (e.g., "2026-03-26T19:42:00.000Z"). */
readonly BUILD_DATE: string;
/** Short git commit SHA (e.g., "c1266823"). Empty string if unavailable. */
readonly COMMIT_SHA: string;
/** Git tag for the current commit (e.g., "v2.0.0"). Empty string if untagged (pre-release build). */
readonly COMMIT_TAG: string;
}
/**

Some files were not shown because too many files have changed in this diff Show More