Compare commits

...

55 Commits

Author SHA1 Message Date
Alex Gleason 213bbb21c1 release: v2.3.0 2026-04-02 03:57:37 -05:00
Alex Gleason dd3ae4da4e npm audit fix 2026-04-02 03:52:24 -05:00
Alex Gleason 681d2ab90b Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 03:51:09 -05:00
Alex Gleason 24a645277e Fix custom emoji stretching by adding object-contain to all emoji images
Custom emoji images with non-1:1 aspect ratios were being stretched
into a square. Added object-contain to preserve natural aspect ratio
within the bounding box. Moved text sizing classes to parent containers
for reaction emoji bubbles so unicode emojis still size correctly.
2026-04-02 03:50:50 -05:00
Chad Curtis fa34922cce refactor: harden article editor — encryption, mobile UX, deduplication, source toggle
- Encrypt drafts with NIP-44 via NIP-37 (kind 31234) instead of
  plaintext kind 30024
- Fix slug auto-generation overwriting manual edits
- Guard auto-save state setters against unmount
- Deduplicate save logic, load handlers, tag extraction, and types
  via shared ArticleFields/parseArticleEvent helpers
- Replace derived state (wordCount/readingTime) with useMemo
- Mobile UX: sticky toolbar, touch-friendly header image swap,
  adaptive tooltips (pointer:fine only), FAB bottom clearance,
  responsive editor min-height
- Editor placeholder: hide on focus, handle trailing whitespace
- Tighten editor padding and paragraph spacing
- Add raw markdown source toggle (Eye/EyeOff) in toolbar
- Shrink slug/tag fields, consistent sizing
2026-04-02 03:48:10 -05:00
Chad Curtis 89c71ed073 Merge branch 'feat/article-editor' into 'main'
feat: add in-app article editor with Milkdown WYSIWYG

See merge request soapbox-pub/ditto!150
2026-04-02 08:47:37 +00:00
Alex Gleason f49909dedf Close mobile drawer when clicking footer links (Changelog, Privacy) 2026-04-02 03:23:13 -05:00
Alex Gleason ab43225f0c Remove Nostr protocol jargon from changelog and add rule to release skill 2026-04-02 03:14:01 -05:00
Alex Gleason 2bb1b07dd6 release: v2.2.11 2026-04-02 03:05:10 -05:00
Alex Gleason f93c759bf2 Fix VersionCheck crash: move VersionCheck and Toaster inside BrowserRouter
VersionCheck and Toaster were rendering outside the BrowserRouter in App.tsx,
so the <Link> in the version update toast had no Router context. Moved both
into AppRouter.tsx inside BrowserRouter. Also truncate changelog excerpt
to 60 chars with ellipsis for cleaner toast display.
2026-04-02 03:01:32 -05:00
Alex Gleason ef4ac2e3f4 release: v2.2.10 2026-04-02 02:48:34 -05:00
Alex Gleason 32b36b2f54 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 02:40:44 -05:00
Alex Gleason dee5c82fa8 Add 'deployed an nsite' action header to nsite detail page 2026-04-02 02:39:40 -05:00
Alex Gleason 22d66a28d7 Add Open App button to compact view, fix stopPropagation on all buttons 2026-04-02 02:37:13 -05:00
Alex Gleason 984a56c412 Add 'published an app' action header to app detail page 2026-04-02 02:34:14 -05:00
Alex Gleason 207e7a13a2 Move Shakespeare badge above Open App button, restore h-6 size 2026-04-02 02:25:44 -05:00
Alex Gleason cc7feebbb0 Replace small external link icon with prominent Visit Website button 2026-04-02 02:22:25 -05:00
Alex Gleason 925619b13c Add background color to app icon for transparent images 2026-04-02 02:09:58 -05:00
Alex Gleason ceb7bbc718 Fix app icon z-index so it renders above the og:image hero 2026-04-02 02:06:41 -05:00
Alex Gleason 53a607fa53 Overlay app icon over og:image like a profile avatar 2026-04-02 01:57:56 -05:00
Alex Gleason e9eeebc4b1 Rename 'App Handler' to 'App' in UI labels 2026-04-02 01:48:08 -05:00
Alex Gleason b42d241882 Fix Shakespeare clone URL to use NostrURI class 2026-04-02 01:43:33 -05:00
Alex Gleason 68da609a9e Hide app handler screenshot hero when no og:image, reduce image height 2026-04-02 01:38:07 -05:00
Chad Curtis 1afa78ae39 Merge branch 'fix/disappearing-post-box' into 'main'
Fix disappearing compose box after posting

See merge request soapbox-pub/ditto!141
2026-04-02 06:29:56 +00:00
Alex Gleason e0ff462f12 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 01:06:25 -05:00
Alex Gleason f4e38123e4 Add display for kind 31990 NIP-89 app handler events 2026-04-02 01:00:32 -05:00
Alex Gleason eb1c873b9a Show 'What's new' with changelog excerpt in version update toast 2026-04-01 23:50:22 -05:00
Alex Gleason 22f13c1505 Replace __DITTO_CONFIG__ global with import.meta.env.DITTO_CONFIG and remove ThemeSchemaCompat
Move build-time ditto.json injection from a Vite define global to
import.meta.env.DITTO_CONFIG (a JSON string parsed and validated at
runtime via DittoConfigSchema). Remove the global type declaration
from vite-env.d.ts.

Drop ThemeSchemaCompat and its legacy "black"/"pink" migration code
from AppProvider and NostrSync — invalid theme values now simply fail
Zod validation.

Fix a latent bug where a partial feedSettings from ditto.json would
replace the full hardcoded defaults; defaultConfig now deep-merges
feedSettings.
2026-04-01 23:16:33 -05:00
Alex Gleason cbfc8f149f Redesign changelog page: hero latest release, collapsible entries, flat item list with category icons, tooltips, typography fixes, and search integration 2026-04-01 22:33:18 -05:00
Alex Gleason 2e41859747 Show update toast with changelog link when app version changes 2026-04-01 21:26:23 -05:00
Alex Gleason 3b176a3e8f release: v2.2.9 2026-04-01 21:12:42 -05:00
Alex Gleason a1e1e1d57f Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-01 20:55:01 -05:00
Alex Gleason eb973cc20b Redesign shop/items dialog: tile layout, instant buy, remove accessories and categories 2026-04-01 20:53:56 -05:00
Alex Gleason f66ab92e51 Unify Shop/Inventory, remove Info modal and visibility, consolidate dev tools
- Combine Shop and Inventory into a single tabbed dialog (Shop tab
  with category sub-tabs, Inventory tab with item list and use flow)
- Remove BlobbiInfoModal entirely
- Move dev tools (Dev Hatch/Evolve, State Editor, Emotion Tester) into
  the bottom bar 'More' dropdown with yellow text, remove floating
  dev tools panel
- Remove 'visibleToOthers' / 'visible_to_others' concept from the
  entire codebase: types, interfaces, tag schemas, event construction,
  parsing, UI badges, dev editor, and documentation
2026-04-01 20:14:34 -05:00
Alex Gleason 4d573ffaa8 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-01 20:13:37 -05:00
Alex Gleason 081189886a Hide emoji packs without any valid emoji tags from feeds 2026-04-01 20:07:43 -05:00
Alex Gleason 1efc8de880 Add description field to emoji pack create/edit dialog
Parse and publish the 'about' tag for kind 30030 emoji sets.
EmojiPackContent already displays it.
2026-04-01 20:02:01 -05:00
Alex Gleason 8bf9db382e Fix emoji pack drag reorder, expand truncated grid, resolve shortcode collisions
- Replace chevron up/down buttons with @dnd-kit SortableList/SortableItem
  for proper drag-and-drop reorder in EmojiPackDialog
- Remove 'N emojis' badge from emoji pack display
- Make '+N' overflow indicator clickable to expand full emoji grid
- Stop click propagation on expand button to prevent feed navigation
- Resolve shortcode collisions across emoji packs by prefixing with pack
  d-tag identifier when two packs define the same shortcode with different URLs
2026-04-01 19:57:29 -05:00
Alex Gleason 103b9c71bf Move right-side floating controls into bottom bar 3-dots menu
Replace the cluster of floating action buttons on the right side of the
Blobbi dashboard with a single 'More' dropdown in the bottom control bar.
The menu contains: Inventory, Take a Photo, Set as Companion, Evolve/Hatch,
Blobbi Info, and View Blobbi. The floating controls now only show the back
button (left) and dev tools (right, localhost only).
2026-04-01 19:29:15 -05:00
Alex Gleason e27057788b Replace drag reorder with move buttons, remove colons and parenthetical count
- Swap native HTML drag-and-drop reorder for chevron up/down buttons,
  fixing scroll conflicts inside ScrollArea and drop zone interference
- Remove the colon decorations around shortcode inputs
- Show plain 'N emojis' count without parenthetical upload note
2026-04-01 19:20:30 -05:00
Alex Gleason 4983b3c1ef Use single upload icon in emoji drop zone 2026-04-01 19:14:35 -05:00
Alex Gleason 197ab6c28a Simplify BlobbiStateCard to show just the character and name 2026-04-01 19:14:01 -05:00
Alex Gleason fd0d47160d Defer all Blossom uploads and event signing until submit
Files are held as local blob previews while editing. Nothing is
uploaded or signed until the user clicks the publish button, at which
point all pending files are uploaded in parallel and then the event
is published in a single batch.
2026-04-01 19:10:09 -05:00
Alex Gleason 4697d269bc Put Title and ID side-by-side, auto-slug ID from Title, remove NIP jargon 2026-04-01 19:02:39 -05:00
Alex Gleason 73bf03cfab Add Blobbi view link and register kind 31124 in feed/detail views
Add a 3-dots menu to the Blobbi dashboard with a 'View Blobbi' link that
navigates to the naddr detail page. Register kind 31124 (Blobbi Pet State)
across all UI registration points so Blobbi events render properly in
feeds, detail pages, comment contexts, and embedded previews.
2026-04-01 19:01:47 -05:00
Alex Gleason c3d4d5f06e Add emoji pack create/edit dialog with drag-and-drop upload
Introduce EmojiPackDialog for publishing and editing kind 30030 custom
emoji sets (NIP-30). The dialog supports multi-file and folder
drag-and-drop, automatically extracting shortcodes from filenames. The
d-tag identifier is locked after initial publish. Existing packs show
an Edit button on the feed card for the author. The /emojis page FAB
now opens the create dialog.
2026-04-01 18:53:51 -05:00
Derek Ross 2d1a3ff6f5 chore: update package-lock.json after rebase 2026-04-01 14:03:17 -04:00
Derek Ross 90bd10d87a fix: remove unused imports and variables in ArticleEditor 2026-04-01 14:01:46 -04:00
Derek Ross 280bcbd5ab fix: prevent Save Draft button wrapping to second line on mobile 2026-04-01 14:01:46 -04:00
Derek Ross 65ecfca05e fix: show bottom navigation bar on article editor page 2026-04-01 14:01:46 -04:00
Derek Ross 91f5afc110 fix: default logged-out users to global tab on kind-specific feed pages
Kind-specific pages (articles, photos, videos, etc.) clamped the feed tab
to 'follows' for all users, but the follows query requires a logged-in
user. Logged-out users saw infinite skeleton loading with no way to switch
tabs. Now defaults to 'global' when no user is present.
2026-04-01 14:01:46 -04:00
Derek Ross 1c980fb039 refactor: simplify article editor to New/My Articles tabs with inline metadata
Remove Details tab and Save header icon. Metadata (image, summary, slug,
tags) now sits inline between title and editor body like Medium. Save Draft
button moved to bottom of compose form. Header tabs renamed to New and
My Articles.
2026-04-01 14:01:46 -04:00
Derek Ross e93c665123 feat: add in-app article editor with Milkdown WYSIWYG
Replace external Inkwell link with a built-in article creation experience.
Uses Milkdown editor with tabbed UI (Write/Details/Drafts) matching the
letters compose pattern, FAB publish button, relay+local draft support,
and kind 30023/30024 publishing.
2026-04-01 14:01:46 -04:00
Lemon a80b306248 Reset feed composer to collapsed state after posting 2026-03-28 23:11:47 -07:00
Lemon c8c294a8ad Match ComposeBox background opacity with header and subheader (bg-background/85) 2026-03-28 23:11:47 -07:00
73 changed files with 6320 additions and 1023 deletions
+1
View File
@@ -108,6 +108,7 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
- 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
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
- **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.
+37
View File
@@ -716,6 +716,43 @@ await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newT
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
### D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
#### When to Check for Collisions
**Must check before publishing** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.). **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
#### Implementation Pattern
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
```typescript
// Before publishing a new addressable event:
const slug = slugify(title, { lower: true, strict: true });
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
toast({
title: 'Slug already in use',
description: 'Change the slug or edit the existing item.',
variant: 'destructive',
});
return;
}
// Safe to publish
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
```
**Skip the check in edit mode** -- when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
### Nostr Login
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
+44 -6
View File
@@ -1,5 +1,43 @@
# Changelog
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
- Blobbi companions now appear in feeds and post detail pages
### Changed
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
- Emoji packs without any valid emojis are now hidden from feeds
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
## [2.2.8] - 2026-04-01
### Added
@@ -25,7 +63,7 @@
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
@@ -80,11 +118,11 @@
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- Dedicated photo upload flow for sharing photos
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring kind 10008 profile badge lists
- Badge list recovery dialog for restoring profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
@@ -103,7 +141,7 @@
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- Event links now route correctly for all event types
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
@@ -130,10 +168,10 @@
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- Relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
- Remote signer UX improvements for Amber and NIP-46 users on Android
- Remote signer UX improvements for Amber users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.2.8"
versionName "2.3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -1
View File
@@ -60,7 +60,7 @@ const builtinThemes = {
};
```
Self-hosters can override these at build time via `ditto.json` (injected through `__DITTO_CONFIG__` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
### ThemeConfig
+1 -2
View File
@@ -127,7 +127,6 @@ User preferences and computed flags.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `visible_to_others` | No | egg, baby, adult | Yes | user | `true\|false` | true | Public visibility |
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
### 10. Evolution Tags
@@ -192,7 +191,7 @@ These tags are from legacy versions and MUST be removed when republishing events
- All visual tags (colors, pattern, size)
- All personality tags (if present)
- All progression tags (`experience`, `care_streak`)
- All social tags (`visible_to_others`, `breeding_ready`)
- All social tags (`breeding_ready`)
- All extension tags (`theme`, `crossover_app`)
### Evolve (baby → adult)
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.8;
MARKETING_VERSION = 2.3.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 = 2.2.8;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+1853 -20
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.2.8",
"version": "2.3.0",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -53,6 +53,17 @@
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@milkdown/core": "^7.20.0",
"@milkdown/ctx": "^7.20.0",
"@milkdown/plugin-clipboard": "^7.20.0",
"@milkdown/plugin-history": "^7.20.0",
"@milkdown/plugin-listener": "^7.20.0",
"@milkdown/plugin-upload": "^7.20.0",
"@milkdown/preset-commonmark": "^7.20.0",
"@milkdown/preset-gfm": "^7.20.0",
"@milkdown/prose": "^7.20.0",
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.0",
"@nostrify/react": "^0.4.0",
"@nostrify/types": "^0.36.9",
@@ -117,6 +128,7 @@
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"rehype-sanitize": "^6.0.0",
"slugify": "^1.6.8",
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
+44 -6
View File
@@ -1,5 +1,43 @@
# Changelog
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
- Blobbi companions now appear in feeds and post detail pages
### Changed
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
- Emoji packs without any valid emojis are now hidden from feeds
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
## [2.2.8] - 2026-04-01
### Added
@@ -25,7 +63,7 @@
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
@@ -80,11 +118,11 @@
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- Dedicated photo upload flow for sharing photos
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring kind 10008 profile badge lists
- Badge list recovery dialog for restoring profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
@@ -103,7 +141,7 @@
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- Event links now route correctly for all event types
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
@@ -130,10 +168,10 @@
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- Relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
- Remote signer UX improvements for Amber and NIP-46 users on Android
- Remote signer UX improvements for Amber users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
+23 -5
View File
@@ -16,12 +16,14 @@ import NostrProvider from "@/components/NostrProvider";
import { NostrSync } from "@/components/NostrSync";
import { PlausibleProvider } from "@/components/PlausibleProvider";
import { SentryProvider } from "@/components/SentryProvider";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
@@ -114,6 +116,7 @@ const hardcodedConfig: AppConfig = {
feedIncludeBadgeDefinitions: true,
feedIncludeProfileBadges: true,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
followsFeedShowReplies: true,
},
sidebarOrder: [
@@ -147,15 +150,30 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
};
/**
* Parse and validate build-time ditto.json overrides from the env string.
* Returns an empty object when no config file was provided or validation fails.
*/
function parseDittoConfig(): DittoConfig {
try {
const json = JSON.parse(import.meta.env.DITTO_CONFIG);
if (!json) return {};
return DittoConfigSchema.parse(json);
} catch {
return {};
}
}
/**
* Merge hardcoded defaults with build-time ditto.json overrides.
* Deep-merges feedSettings so a partial override doesn't erase defaults.
* Precedence (handled by AppProvider): user localStorage > build-time > hardcoded.
*/
const dittoConfig = parseDittoConfig();
const defaultConfig: AppConfig = {
...hardcodedConfig,
...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__
? __DITTO_CONFIG__
: {}),
...dittoConfig,
feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings },
};
export function App() {
@@ -183,11 +201,11 @@ export function App() {
<NostrProvider>
<NostrSync />
<NativeNotifications />
<NWCProvider>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
+32 -10
View File
@@ -6,8 +6,10 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { Toaster } from "./components/ui/toaster";
import { MainLayout } from "./components/MainLayout";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { getExtraKindDef } from "./lib/extraKinds";
@@ -22,6 +24,9 @@ const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => (
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
// Lazy-loaded emoji pack dialog
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
// HomePage eagerly imported all page components; now lazy-loaded
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
@@ -29,6 +34,7 @@ const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.H
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
const BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
@@ -101,6 +107,26 @@ function PollsFeedPage() {
);
}
/** Emoji feed page with a FAB that opens the emoji pack creation dialog. */
function EmojiFeedPage() {
const [composeOpen, setComposeOpen] = useState(false);
return (
<>
<KindFeedPage
kind={emojisDef.kind}
title={emojisDef.label}
icon={sidebarItemIcon("emojis", "size-5")}
onFabClick={() => setComposeOpen(true)}
/>
{composeOpen && (
<Suspense fallback={null}>
<EmojiPackDialog open={composeOpen} onOpenChange={setComposeOpen} />
</Suspense>
)}
</>
);
}
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
function ProfileRedirect() {
const { user, metadata } = useCurrentUser();
@@ -113,6 +139,8 @@ export function AppRouter() {
return (
<AudioPlayerProvider>
<BrowserRouter>
<Toaster />
<VersionCheck />
<MinimizedAudioBar />
<AudioNavigationGuard />
<DeepLinkHandler />
@@ -184,6 +212,8 @@ export function AppRouter() {
}
/>
<Route path="/webxdc" element={<WebxdcFeedPage />} />
<Route path="/articles/new" element={<ArticleEditorPage />} />
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
<Route
path="/articles"
element={
@@ -191,6 +221,7 @@ export function AppRouter() {
kind={articlesDef.kind}
title={articlesDef.label}
icon={sidebarItemIcon("articles", "size-5")}
fabHref="/articles/new"
/>
}
/>
@@ -204,16 +235,7 @@ export function AppRouter() {
/>
}
/>
<Route
path="/emojis"
element={
<KindFeedPage
kind={emojisDef.kind}
title={emojisDef.label}
icon={sidebarItemIcon("emojis", "size-5")}
/>
}
/>
<Route path="/emojis" element={<EmojiFeedPage />} />
<Route
path="/development"
element={
@@ -217,11 +217,6 @@ export function canUseItemForStage(
return { canUse: true };
}
// Accessories are disabled
if (shopItem.type === 'accessory') {
return { canUse: false, reason: 'Accessories are not usable yet' };
}
return { canUse: true };
}
@@ -66,7 +66,6 @@ const CATEGORY_TO_PRIMARY_STAT: Record<ShopItemCategory, (keyof BlobbiStats)[]>
toy: ['happiness'],
hygiene: ['hygiene'],
medicine: ['health'],
accessory: [], // Accessories don't address needs
};
// ─── Need Detection Functions ─────────────────────────────────────────────────
@@ -82,7 +82,6 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
toy: 'play',
medicine: 'medicine',
hygiene: 'clean',
accessory: null, // Accessories are cosmetic, not usable
};
/**
+1 -14
View File
@@ -34,7 +34,7 @@ export type TagCategory =
| 'state' // Lifecycle state (stage, state, timestamps)
| 'progression' // Progress tracking (experience, care_streak)
| 'task' // Task system (task, task_completed, state_started_at)
| 'social' // Social flags (visible_to_others, breeding_ready)
| 'social' // Social flags (breeding_ready)
| 'evolution' // Evolution-specific (adult_type)
| 'extension'; // Extension tags (theme, crossover_app)
@@ -509,19 +509,6 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [
// ═══════════════════════════════════════════════════════════════════════════
// SOCIAL / FLAG TAGS
// ═══════════════════════════════════════════════════════════════════════════
{
tag: 'visible_to_others',
description: 'Whether the Blobbi is publicly visible to other users',
category: 'social',
required: false,
stages: ['egg', 'baby', 'adult'],
persistent: true,
source: 'user',
regenerable: false,
format: 'true | false',
defaultValue: 'true',
notes: 'User preference. Persists across stages.',
},
{
tag: 'breeding_ready',
description: 'Whether the Blobbi is eligible for breeding',
+2 -6
View File
@@ -253,8 +253,6 @@ export interface BlobbiCompanion {
lastDecayAt: number | undefined;
/** Stats (0-100) */
stats: Partial<BlobbiStats>;
/** Whether the Blobbi is publicly visible */
visibleToOthers: boolean;
/** Generation number */
generation: number | undefined;
/** Breeding eligibility */
@@ -939,7 +937,6 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
hygiene: parseNumericTag(tags, 'hygiene'),
energy: parseNumericTag(tags, 'energy'),
},
visibleToOthers: parseBooleanTag(tags, 'visible_to_others', true),
generation: parseNumericTag(tags, 'generation'),
breedingReady: parseBooleanTag(tags, 'breeding_ready', false),
experience: parseNumericTag(tags, 'experience'),
@@ -1036,7 +1033,6 @@ export function buildEggTags(
['stage', 'egg'],
['state', 'active'],
['seed', seed],
['visible_to_others', 'true'],
['generation', '1'],
['breeding_ready', 'false'],
['experience', '0'],
@@ -1084,7 +1080,7 @@ export const MANAGED_BLOBBI_STATE_TAG_NAMES = new Set([
// Progression tags
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
// Social/flag tags
'visible_to_others', 'breeding_ready',
'breeding_ready',
// Task system tags (removed after stage transitions)
'state_started_at', 'task', 'task_completed',
// Evolution tags (adult only)
@@ -1463,7 +1459,7 @@ export function buildMigrationTags(
// Progression tags
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
// Social/flag tags
'visible_to_others', 'generation', 'breeding_ready',
'generation', 'breeding_ready',
// Personality tags (preserve if they exist, do NOT generate)
'personality', 'trait', 'favorite_food', 'voice_type', 'mood',
// Evolution tags
-5
View File
@@ -149,10 +149,6 @@ export interface Blobbi extends BlobbiVisualTraits {
generation?: number;
careStreak?: number;
/**
* Visibility / social.
*/
visibleToOthers?: boolean;
crossoverApp?: string | null;
themeVariant?: string;
@@ -215,7 +211,6 @@ export function createDefaultBlobbi(overrides: Partial<Blobbi> = {}): Blobbi {
generation: overrides.generation ?? 1,
careStreak: overrides.careStreak ?? 0,
visibleToOthers: overrides.visibleToOthers ?? true,
crossoverApp: overrides.crossoverApp ?? null,
themeVariant: overrides.themeVariant,
tags: overrides.tags ?? [],
+3 -16
View File
@@ -58,8 +58,6 @@ export interface BlobbiDevUpdates {
breedingReady?: boolean;
/** Generation number */
generation?: number;
/** Visibility to others */
visibleToOthers?: boolean;
}
// ─── Stat Presets ─────────────────────────────────────────────────────────────
@@ -189,7 +187,6 @@ export function BlobbiDevEditor({
const [careStreak, setCareStreak] = useState(companion.careStreak ?? 0);
const [breedingReady, setBreedingReady] = useState(companion.breedingReady);
const [generation, setGeneration] = useState(companion.generation ?? 1);
const [visibleToOthers, setVisibleToOthers] = useState(companion.visibleToOthers);
// Reset state when companion changes or modal opens
const resetToCompanion = useCallback(() => {
@@ -207,7 +204,6 @@ export function BlobbiDevEditor({
setCareStreak(companion.careStreak ?? 0);
setBreedingReady(companion.breedingReady);
setGeneration(companion.generation ?? 1);
setVisibleToOthers(companion.visibleToOthers);
}, [companion]);
// Check if there are any changes
@@ -224,10 +220,9 @@ export function BlobbiDevEditor({
experience !== (companion.experience ?? 0) ||
careStreak !== (companion.careStreak ?? 0) ||
breedingReady !== companion.breedingReady ||
generation !== (companion.generation ?? 1) ||
visibleToOthers !== companion.visibleToOthers
generation !== (companion.generation ?? 1)
);
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, visibleToOthers, companion]);
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, companion]);
// Apply preset
const applyPreset = useCallback((preset: StatPreset) => {
@@ -270,11 +265,10 @@ export function BlobbiDevEditor({
if (careStreak !== (companion.careStreak ?? 0)) updates.careStreak = careStreak;
if (breedingReady !== companion.breedingReady) updates.breedingReady = breedingReady;
if (generation !== (companion.generation ?? 1)) updates.generation = generation;
if (visibleToOthers !== companion.visibleToOthers) updates.visibleToOthers = visibleToOthers;
await onApply(updates);
onClose();
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, visibleToOthers, companion, onApply, onClose]);
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, companion, onApply, onClose]);
// Handle close
const handleClose = useCallback(() => {
@@ -533,13 +527,6 @@ export function BlobbiDevEditor({
onCheckedChange={setBreedingReady}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-sm">Visible to Others</Label>
<Switch
checked={visibleToOthers}
onCheckedChange={setVisibleToOthers}
/>
</div>
</div>
</div>
</div>
-4
View File
@@ -140,10 +140,6 @@ export function useBlobbiDevUpdate({
tagUpdates.generation = updates.generation.toString();
changedFields.push('generation');
}
if (updates.visibleToOthers !== undefined) {
tagUpdates.visible = updates.visibleToOthers ? 'true' : 'false';
changedFields.push('visible');
}
// Always update last_interaction and last_decay_at
tagUpdates.last_interaction = now.toString();
@@ -135,7 +135,6 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] {
['stage', preview.stage],
['state', preview.state],
['seed', preview.seed],
['visible_to_others', 'true'],
['generation', '1'],
['breeding_ready', 'false'],
['experience', '0'],
@@ -181,7 +180,6 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) {
isLegacy: false,
lastInteraction: preview.createdAt,
lastDecayAt: preview.createdAt,
visibleToOthers: true,
generation: 1,
breedingReady: false,
experience: 0,
@@ -45,20 +45,25 @@ interface ResolvedInventoryItem extends ShopItem {
reason?: string;
}
export function BlobbiInventoryModal({
open,
onOpenChange,
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
interface BlobbiInventoryContentProps {
profile: BlobbonautProfile | null;
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
isUsingItem?: boolean;
}
export function BlobbiInventoryContent({
profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryModalProps) {
// State for use confirmation dialog
}: BlobbiInventoryContentProps) {
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showUseDialog, setShowUseDialog] = useState(false);
// Resolve storage items with their metadata and usability from the shop catalog
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
@@ -68,7 +73,6 @@ export function BlobbiInventoryModal({
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
// Check if item can be used for current stage
const usability = canUseItemForStage(storageItem.itemId, stage);
result.push({
@@ -84,7 +88,6 @@ export function BlobbiInventoryModal({
const isEmpty = inventoryItems.length === 0;
// Handlers for use dialog
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem) return;
setSelectedItem(item);
@@ -95,7 +98,6 @@ export function BlobbiInventoryModal({
const handleConfirmUse = () => {
if (!selectedItem || !onUseItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
// Reset state
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
@@ -109,7 +111,6 @@ export function BlobbiInventoryModal({
}
};
// Quantity controls
const maxQuantity = selectedItem?.quantity ?? 1;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
@@ -123,142 +124,117 @@ export function BlobbiInventoryModal({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
<Package className="size-4 sm:size-5 text-primary" />
</div>
<div className="min-w-0">
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
<p className="text-xs sm:text-sm text-muted-foreground">
{isEmpty ? 'No items yet' : `${inventoryItems.length} ${inventoryItems.length === 1 ? 'item' : 'items'}`}
</p>
</div>
<>
<div className="px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
<p className="text-sm text-muted-foreground max-w-sm">
Visit the Shop tab to purchase items for your Blobbi. Items you buy will appear here.
</p>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
<p className="text-sm text-muted-foreground max-w-sm">
Visit the shop to purchase items for your Blobbi. Items you buy will appear here.
</p>
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className={cn(
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
!item.canUse && "grayscale"
)}>
{item.icon}
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className={cn(
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
!item.canUse && "grayscale"
)}>
{item.icon}
</div>
{/* Item Info - Name and Type */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
{/* Quantity Badge */}
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
×{item.quantity}
</Badge>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleSelectItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
Use
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{/* Item Info - Name and Type */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason on mobile */}
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
{/* Quantity Badge */}
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
×{item.quantity}
</Badge>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleSelectItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
Use
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
))}
</div>
)}
</div>
</DialogContent>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{item.type}
</Badge>
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Use Item Confirmation Dialog */}
{selectedItem && companion && (
@@ -276,6 +252,49 @@ export function BlobbiInventoryModal({
isUsing={isUsingItem}
/>
)}
</>
);
}
// ── Standalone Inventory Modal (kept for backwards compatibility) ──
export function BlobbiInventoryModal({
open,
onOpenChange,
profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
<Package className="size-4 sm:size-5 text-primary" />
</div>
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto">
<BlobbiInventoryContent
profile={profile}
companion={companion}
onUseItem={onUseItem}
isUsingItem={isUsingItem}
/>
</div>
</DialogContent>
</Dialog>
);
}
@@ -309,15 +328,12 @@ function InventoryUseConfirmDialog({
onConfirm,
isUsing,
}: InventoryUseConfirmDialogProps) {
// Calculate total effect for the selected quantity by simulating sequential application
// This matches the actual behavior when items are used (clamping at each step)
const totalEffect = useMemo(() => {
if (!item.effect) return null;
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
// Apply effects N times in sequence with clamping at each step
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
@@ -327,7 +343,6 @@ function InventoryUseConfirmDialog({
}
}
// Calculate actual deltas (may be less than effect * quantity due to clamping)
const result: Record<string, number> = {};
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
@@ -12,7 +12,6 @@ import {
import { Input } from '@/components/ui/input';
import type { ShopItem } from '../types/shop.types';
import { ItemEffectDisplay } from './ItemEffectDisplay';
import { formatCompactNumber } from '@/lib/utils';
interface BlobbiPurchaseDialogProps {
@@ -150,13 +149,6 @@ export function BlobbiPurchaseDialog({
)}
</div>
{/* Effects Summary */}
{item.effect && Object.keys(item.effect).length > 0 && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">Effects per item</h4>
<ItemEffectDisplay effect={item.effect} variant="grid" />
</div>
)}
</div>
<DialogFooter>
+321 -135
View File
@@ -1,178 +1,364 @@
import { useState } from 'react';
import { ShoppingBag, Utensils, Gamepad2, Heart, Droplets, Palette, X } from 'lucide-react';
import { useState, useMemo } from 'react';
import { ShoppingBag, Package, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { BlobbiShopItemRow } from './BlobbiShopItemRow';
import { BlobbiPurchaseDialog } from './BlobbiPurchaseDialog';
import type { ShopItem, ShopItemCategory } from '../types/shop.types';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { getShopItemsByType } from '../lib/blobbi-shop-items';
import type { ShopItem } from '../types/shop.types';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { getLiveShopItems, getShopItemById } from '../lib/blobbi-shop-items';
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn, formatCompactNumber } from '@/lib/utils';
type TopTab = 'items' | 'shop';
/** Resolved inventory item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
canUse: boolean;
reason?: string;
}
interface BlobbiShopModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
profile: BlobbonautProfile | null;
/** Initial tab to open on. Defaults to "items". */
initialTab?: TopTab;
// ── Inventory props (passed through) ──
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
isUsingItem?: boolean;
}
const CATEGORIES: Array<{
type: ShopItemCategory;
label: string;
icon: React.ReactNode;
}> = [
{ type: 'food', label: 'Food', icon: <Utensils className="size-4" /> },
{ type: 'toy', label: 'Toys', icon: <Gamepad2 className="size-4" /> },
{ type: 'medicine', label: 'Medicine', icon: <Heart className="size-4" /> },
{ type: 'hygiene', label: 'Hygiene', icon: <Droplets className="size-4" /> },
{ type: 'accessory', label: 'Accessories', icon: <Palette className="size-4" /> },
];
export function BlobbiShopModal({ open, onOpenChange, profile }: BlobbiShopModalProps) {
const [activeCategory, setActiveCategory] = useState<ShopItemCategory>('food');
const [selectedItem, setSelectedItem] = useState<ShopItem | null>(null);
const [showPurchaseDialog, setShowPurchaseDialog] = useState(false);
export function BlobbiShopModal({
open,
onOpenChange,
profile,
initialTab = 'items',
companion,
onUseItem,
isUsingItem,
}: BlobbiShopModalProps) {
const [topTab, setTopTab] = useState<TopTab>(initialTab);
const { mutate: purchaseItem, isPending: isPurchasing } = useBlobbiPurchaseItem(profile);
const [purchasingItemId, setPurchasingItemId] = useState<string | null>(null);
const availableCoins = profile?.coins ?? 0;
const items = getShopItemsByType(activeCategory);
const allItems = getLiveShopItems();
const handlePurchaseClick = (item: ShopItem) => {
setSelectedItem(item);
setShowPurchaseDialog(true);
// Reset to initialTab when modal re-opens
const handleOpenChange = (isOpen: boolean) => {
if (isOpen) {
setTopTab(initialTab);
}
onOpenChange(isOpen);
};
const handlePurchase = (quantity: number) => {
if (!selectedItem) return;
// Instant purchase — one tap = one item
const handleBuyItem = (item: ShopItem) => {
if (isPurchasing || availableCoins < item.price) return;
setPurchasingItemId(item.id);
purchaseItem(
{
itemId: selectedItem.id,
price: selectedItem.price,
quantity,
},
{
onSuccess: () => {
setShowPurchaseDialog(false);
setSelectedItem(null);
},
}
{ itemId: item.id, price: item.price, quantity: 1 },
{ onSettled: () => setPurchasingItemId(null) },
);
};
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
// ── Inventory items resolution ──
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
const result: ResolvedInventoryItem[] = [];
for (const storageItem of profile.storage) {
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
const usability = canUseItemForStage(storageItem.itemId, stage);
result.push({
...item,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [profile, companion?.stage]);
// ── Inventory use item handler ──
const [usingItemId, setUsingItemId] = useState<string | null>(null);
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
setUsingItemId(item.itemId);
onUseItem(item.itemId, 1);
};
// Clear usingItemId when isUsingItem goes false
const effectiveUsingItemId = isUsingItem ? usingItemId : null;
const inventoryEmpty = inventoryItems.length === 0;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-pink-500/20 flex items-center justify-center shrink-0">
<ShoppingBag className="size-4 sm:size-5 text-primary" />
</div>
<DialogTitle className="text-xl sm:text-2xl truncate">Blobbi Shop</DialogTitle>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 text-sm sm:text-base px-3 sm:px-4 py-1">
{formatCompactNumber(availableCoins)} coins
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[80vh] flex flex-col p-0 gap-0 overflow-hidden rounded-2xl [&>button:last-child]:hidden">
{/* Tab Bar (replaces header) */}
<div className="flex items-center border-b bg-muted/30">
{/* Tabs */}
<button
onClick={() => setTopTab('items')}
className={cn(
'flex-1 flex items-center justify-center gap-2 px-4 py-3.5 text-sm font-medium transition-colors relative',
topTab === 'items'
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground/70'
)}
>
<Package className="size-4" />
Items
{!inventoryEmpty && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 min-w-4">
{inventoryItems.reduce((sum, i) => sum + i.quantity, 0)}
</Badge>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</div>
</DialogHeader>
)}
{topTab === 'items' && (
<span className="absolute bottom-0 inset-x-4 h-0.5 bg-primary rounded-full" />
)}
</button>
<button
onClick={() => setTopTab('shop')}
className={cn(
'flex-1 flex items-center justify-center gap-2 px-4 py-3.5 text-sm font-medium transition-colors relative',
topTab === 'shop'
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground/70'
)}
>
<ShoppingBag className="size-4" />
Shop
{topTab === 'shop' && (
<span className="absolute bottom-0 inset-x-4 h-0.5 bg-primary rounded-full" />
)}
</button>
{/* Category Tabs - Part of sticky header area */}
<div className="sticky top-[60px] sm:top-[72px] z-10 bg-background px-4 sm:px-6 pt-3 sm:pt-4 pb-2 border-b">
<div className="flex gap-1.5 sm:gap-2 overflow-x-auto pb-1 -mx-1 px-1">
{CATEGORIES.map(category => {
const isActive = activeCategory === category.type;
const itemCount = getShopItemsByType(category.type).length;
return (
<button
key={category.type}
onClick={() => setActiveCategory(category.type)}
className={cn(
'flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1.5 sm:py-2 rounded-lg transition-all whitespace-nowrap',
'border text-sm sm:text-base',
isActive
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted/50 text-muted-foreground border-transparent hover:bg-muted'
)}
>
{category.icon}
<span className="font-medium hidden xs:inline">{category.label}</span>
<Badge variant="secondary" className="ml-0.5 sm:ml-1 text-xs">
{itemCount}
</Badge>
</button>
);
})}
{/* Coin badge + Close */}
<div className="flex items-center gap-1.5 pr-3 pl-2">
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 text-xs px-2 py-0.5">
<span className="mr-1">🪙</span>{formatCompactNumber(availableCoins)}
</Badge>
<DialogClose className="rounded-full p-1 opacity-60 hover:opacity-100 transition-opacity">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</div>
{/* Scrollable Content Area */}
{/* Scrollable Content */}
<div className="flex-1 min-h-0 overflow-y-auto">
{/* Accessories Coming Soon Banner */}
{activeCategory === 'accessory' && (
<div className="mx-4 sm:mx-6 mt-3 sm:mt-4 p-4 sm:p-6 rounded-xl bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20">
<div className="flex items-start gap-3 sm:gap-4">
<div className="size-12 sm:size-16 rounded-xl sm:rounded-2xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center text-2xl sm:text-3xl relative shrink-0">
🎨
<div className="absolute -top-1 -right-1 text-base sm:text-xl"></div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold mb-1">Accessories Coming Soon!</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Get ready to customize your Blobbi's appearance with amazing accessories and cosmetic items.
</p>
</div>
</div>
</div>
{topTab === 'shop' ? (
<ShopGrid
items={allItems}
availableCoins={availableCoins}
onBuy={handleBuyItem}
purchasingItemId={effectivePurchasingId}
/>
) : (
<ItemsGrid
items={inventoryItems}
onUseItem={handleUseItem}
isUsingItem={isUsingItem}
usingItemId={effectiveUsingItemId}
onGoToShop={() => setTopTab('shop')}
/>
)}
{/* Items List */}
<div className="px-4 sm:px-6 py-3 sm:py-4">
<div className="space-y-2">
{items.map(item => (
<BlobbiShopItemRow
key={item.id}
item={item}
availableCoins={availableCoins}
onPurchaseClick={handlePurchaseClick}
/>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* Purchase Dialog */}
{selectedItem && (
<BlobbiPurchaseDialog
open={showPurchaseDialog}
onOpenChange={setShowPurchaseDialog}
item={selectedItem}
availableCoins={availableCoins}
onPurchase={handlePurchase}
isPurchasing={isPurchasing}
/>
)}
</>
);
}
// ─── Shop Grid (tile layout, all items, cost in button) ───────────────────────
interface ShopGridProps {
items: ShopItem[];
availableCoins: number;
onBuy: (item: ShopItem) => void;
purchasingItemId: string | null;
}
function ShopGrid({ items, availableCoins, onBuy, purchasingItemId }: ShopGridProps) {
return (
<div className="p-3">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{items.map(item => {
const isDisabled = item.status === 'disabled';
const isAffordable = !isDisabled && availableCoins >= item.price;
const isBuying = purchasingItemId === item.id;
return (
<div
key={item.id}
className={cn(
'flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all text-center',
'bg-card/60 backdrop-blur-sm',
isDisabled && 'opacity-50',
!isDisabled && !isAffordable && 'opacity-70',
)}
>
{/* Icon */}
<div className="text-3xl leading-none mt-1">{item.icon}</div>
{/* Name */}
<span className="text-xs font-medium truncate w-full">{item.name}</span>
{/* Buy button with integrated cost */}
<button
onClick={() => onBuy(item)}
disabled={isDisabled || !isAffordable || !!purchasingItemId}
className={cn(
'w-full rounded-lg px-2 py-1.5 text-xs font-medium transition-colors',
isDisabled
? 'bg-muted text-muted-foreground cursor-not-allowed'
: isAffordable
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 active:scale-95 transition-transform'
: 'bg-muted text-muted-foreground cursor-not-allowed'
)}
>
{isDisabled ? (
'Soon'
) : isBuying ? (
<span className="flex items-center justify-center">
<Loader2 className="size-3 animate-spin" />
</span>
) : (
<span className="flex items-center justify-center gap-1">
<span>🪙</span> {formatCompactNumber(item.price)}
</span>
)}
</button>
</div>
);
})}
</div>
</div>
);
}
// ─── Items Grid (inventory, tile layout) ──────────────────────────────────────
interface ItemsGridProps {
items: ResolvedInventoryItem[];
onUseItem: (item: ResolvedInventoryItem) => void;
isUsingItem?: boolean;
usingItemId: string | null;
onGoToShop: () => void;
}
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: ItemsGridProps) {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-8 text-muted-foreground/60" />
</div>
<p className="text-sm text-muted-foreground mb-4">
No items yet. Visit the shop to stock up!
</p>
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
<ShoppingBag className="size-3.5" />
Browse Shop
</Button>
</div>
);
}
return (
<div className="p-3">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{items.map(item => {
const isThisUsing = isUsingItem && usingItemId === item.itemId;
return (
<div
key={item.itemId}
className={cn(
'flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all text-center relative',
'bg-card/60 backdrop-blur-sm',
item.canUse ? 'hover:border-primary/40 hover:bg-accent/40' : 'opacity-60',
)}
>
{/* Quantity badge */}
<Badge
className="absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0 h-4 min-w-4 bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0"
>
{item.quantity}
</Badge>
{/* Icon */}
<div className={cn('text-3xl leading-none mt-1', !item.canUse && 'grayscale')}>{item.icon}</div>
{/* Name */}
<span className="text-xs font-medium truncate w-full">{item.name}</span>
{/* Use button */}
{item.canUse ? (
<Button
size="sm"
variant="outline"
className="w-full h-7 text-xs"
onClick={() => onUseItem(item)}
disabled={isUsingItem}
>
{isThisUsing ? (
<Loader2 className="size-3 animate-spin" />
) : (
'Use'
)}
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="w-full">
<Button
size="sm"
variant="outline"
className="w-full h-7 text-xs"
disabled
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)}
</div>
);
})}
</div>
</div>
);
}
+1 -34
View File
@@ -177,39 +177,6 @@ export const BLOBBI_SHOP_ITEMS: ShopItem[] = [
status: 'live',
},
// ─── Accessory Items (Disabled) ─────────────────────────────────────────────
{
id: 'acc_hat',
name: 'Party Hat',
type: 'accessory',
price: 75,
icon: '🎩',
status: 'disabled',
},
{
id: 'acc_glasses',
name: 'Cool Glasses',
type: 'accessory',
price: 60,
icon: '🕶️',
status: 'disabled',
},
{
id: 'acc_bow',
name: 'Bow Tie',
type: 'accessory',
price: 50,
icon: '🎀',
status: 'disabled',
},
{
id: 'acc_crown',
name: 'Crown',
type: 'accessory',
price: 100,
icon: '👑',
status: 'disabled',
},
];
/**
@@ -237,7 +204,7 @@ export function getLiveShopItems(): ShopItem[] {
* Get all shop item categories with their counts
*/
export function getShopCategories(): Array<{ type: ShopItemCategory; count: number; label: string }> {
const categories: ShopItemCategory[] = ['food', 'toy', 'medicine', 'hygiene', 'accessory'];
const categories: ShopItemCategory[] = ['food', 'toy', 'medicine', 'hygiene'];
return categories.map(type => ({
type,
+1 -2
View File
@@ -7,8 +7,7 @@ export type ShopItemCategory =
| 'food'
| 'toy'
| 'medicine'
| 'hygiene'
| 'accessory';
| 'hygiene';
/**
* Stat effects that items can apply to Blobbi
+316
View File
@@ -0,0 +1,316 @@
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { ExternalLink, GitFork, Package } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Skeleton } from '@/components/ui/skeleton';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { NostrURI } from '@/lib/NostrURI';
import { cn } from '@/lib/utils';
/** Get a tag value by name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Get all values for a tag name. */
function getAllTags(tags: string[][], name: string): string[] {
return tags.filter(([n]) => n === name).map(([, v]) => v);
}
/** Parse kind-0-style metadata from the content field. */
function parseHandlerMetadata(content: string): NostrMetadata {
if (!content) return {};
try {
return JSON.parse(content) as NostrMetadata;
} catch {
return {};
}
}
/** Get the website URL from web handler tags or metadata. */
function getWebsiteUrl(tags: string[][], metadata: NostrMetadata): string | undefined {
const webTags = tags.filter(([n]) => n === 'web');
for (const tag of webTags) {
const url = tag[1];
if (url) {
const base = url.replace(/<bech32>/g, '').replace(/\/+$/, '');
return base;
}
}
return metadata.website;
}
/** Extract the display domain from a URL. */
function displayDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
/** Build a Shakespeare "Edit with Shakespeare" URL from a kind 30617 `a` tag, if present. */
function getShakespeareUrl(tags: string[][]): string | undefined {
for (const tag of tags) {
if (tag[0] !== 'a') continue;
const parts = tag[1]?.split(':');
if (!parts || parts[0] !== '30617' || parts.length < 3) continue;
const pubkey = parts[1];
const identifier = parts.slice(2).join(':');
const nostrUri = new NostrURI({ pubkey, identifier }).toString();
return `https://shakespeare.diy/clone?url=${encodeURIComponent(nostrUri)}`;
}
return undefined;
}
interface AppHandlerContentProps {
event: NostrEvent;
/** If true, show compact preview (used in NoteCard feed). */
compact?: boolean;
}
/** Renders a kind 31990 NIP-89 application handler event as a showcase-style card. */
export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
const metadata = useMemo(() => parseHandlerMetadata(event.content), [event.content]);
const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App';
const about = metadata.about;
const picture = metadata.picture;
const websiteUrl = getWebsiteUrl(event.tags, metadata);
const hashtags = getAllTags(event.tags, 't');
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
const { data: preview, isLoading: previewLoading } = useLinkPreview(websiteUrl ?? null);
const thumbnailUrl = preview?.thumbnail_url;
const [imgError, setImgError] = useState(false);
const showThumbnail = thumbnailUrl && !imgError;
if (compact) {
return (
<div className="mt-2">
<div className="rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
{(previewLoading || showThumbnail) && (
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
{previewLoading ? (
<Skeleton className="absolute inset-0" />
) : (
<img
src={thumbnailUrl}
alt={name}
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
onError={() => setImgError(true)}
/>
)}
</div>
)}
{/* Content */}
<div className="relative z-10 px-3.5 pb-3.5 space-y-2">
{/* App icon — overlaps the screenshot hero like a profile avatar */}
<div className={showThumbnail || previewLoading ? '-mt-7' : 'pt-3.5'}>
{picture ? (
<img
src={picture}
alt={name}
className="size-14 rounded-xl object-cover shrink-0 border-3 border-background bg-background shadow-sm"
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
) : (
<div className="size-14 rounded-xl bg-primary/10 flex items-center justify-center shrink-0 border-3 border-background shadow-sm">
<Package className="size-6 text-primary/50" />
</div>
)}
</div>
{/* Name + domain */}
<div className="min-w-0">
<h3 className="font-semibold text-[15px] leading-snug truncate">{name}</h3>
{websiteUrl && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<ExternalFavicon url={websiteUrl} size={12} />
<span className="truncate">{displayDomain(websiteUrl)}</span>
</div>
)}
</div>
{/* Description */}
{about && (
<p className="text-sm text-muted-foreground line-clamp-2">{about}</p>
)}
{/* Tags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
#{tag}
</Link>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
{websiteUrl && (
<Button asChild size="sm" className="h-7 text-xs">
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Open App
<ExternalLink className="size-3 ml-1.5" />
</a>
</Button>
)}
{shakespeareUrl && (
<Button asChild variant="secondary" size="sm" className="h-7 text-xs">
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Fork
<GitFork className="size-3 ml-1" />
</a>
</Button>
)}
</div>
</div>
</div>
</div>
);
}
// Full detail view
return (
<div className="mt-3">
<div className="rounded-xl border border-border overflow-hidden">
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
{(previewLoading || showThumbnail) && (
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
{previewLoading ? (
<Skeleton className="absolute inset-0" />
) : (
<img
src={thumbnailUrl}
alt={name}
className="size-full object-cover"
loading="lazy"
onError={() => setImgError(true)}
/>
)}
</div>
)}
{/* Content */}
<div className="relative z-10 px-4 pb-4 space-y-3">
{/* App icon — overlaps the screenshot hero like a profile avatar */}
<div className={cn(
'flex items-end justify-between',
showThumbnail || previewLoading ? '-mt-10' : 'pt-4',
)}>
{picture ? (
<img
src={picture}
alt={name}
className="size-20 rounded-2xl object-cover shrink-0 border-4 border-background bg-background shadow-sm"
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
) : (
<div className="size-20 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0 border-4 border-background shadow-sm">
<Package className="size-8 text-primary/50" />
</div>
)}
</div>
{/* Name + domain */}
<div className="min-w-0">
<h2 className="text-xl font-semibold leading-snug truncate">{name}</h2>
{websiteUrl && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<ExternalFavicon url={websiteUrl} size={14} />
<span className="truncate">{displayDomain(websiteUrl)}</span>
</div>
)}
</div>
{/* Description */}
{about && (
<p className="text-sm text-muted-foreground leading-relaxed">{about}</p>
)}
{/* Tags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
#{tag}
</Link>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
{websiteUrl && (
<Button asChild size="sm">
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Open App
<ExternalLink className="size-3 ml-1.5" />
</a>
</Button>
)}
{shakespeareUrl && (
<Button asChild variant="secondary" size="sm">
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Fork
<GitFork className="size-3.5 ml-1.5" />
</a>
</Button>
)}
</div>
</div>
</div>
</div>
);
}
/** Skeleton loading state for AppHandlerContent. */
export function AppHandlerSkeleton() {
return (
<div className="mt-3">
<div className="rounded-xl border border-border overflow-hidden">
<Skeleton className="aspect-[2/1] w-full" />
<div className="px-4 pb-4 space-y-3">
<div className="-mt-10">
<Skeleton className="size-20 rounded-2xl border-4 border-background" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
</div>
</div>
);
}
+1 -8
View File
@@ -1,7 +1,7 @@
import { ReactNode, useLayoutEffect, useEffect, useRef } from 'react';
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 { builtinThemes, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
import { AppConfigSchema } from '@/lib/schemas';
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
import { hslToRgb, parseHsl, rgbToHex } from '@/lib/colorUtils';
@@ -47,13 +47,6 @@ export function AppProvider(props: AppProviderProps) {
}
}
// Migrate legacy theme values ("black", "pink") to "custom" + customTheme
const legacyTheme = result.theme as string | undefined;
if (legacyTheme && legacyTheme in themePresets) {
result.theme = 'custom';
result.customTheme = { colors: themePresets[legacyTheme].colors };
}
// Migrate legacy blossomServers (string[]) to blossomServerMetadata
if (!result.blossomServerMetadata) {
const legacyServers = parsed.blossomServers;
+37
View File
@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { parseBlobbiEvent } from '@/blobbi/core/lib/blobbi';
export function BlobbiStateCard({ event }: { event: NostrEvent }) {
const companion = useMemo(() => parseBlobbiEvent(event), [event]);
if (!companion) return null;
const isSleeping = companion.state === 'sleeping';
return (
<div className="flex flex-col items-center py-4">
{/* Blobbi visual — same as /blobbi hero */}
<div className="relative">
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
companion={companion}
size="lg"
animated={!isSleeping}
lookMode="forward"
className="size-48 sm:size-56"
/>
</div>
{/* Name */}
<h3
className="mt-3 text-xl font-bold text-center"
style={{ color: companion.visualTraits.baseColor }}
>
{companion.name}
</h3>
</div>
);
}
+15 -1
View File
@@ -3,7 +3,7 @@ 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,
Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film,
GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music,
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles,
Users, Zap,
@@ -115,6 +115,7 @@ const KIND_LABELS: Record<number, string> = {
30817: 'a custom NIP',
31922: 'a calendar event',
31923: 'a calendar event',
31990: 'an app',
32267: 'an app',
34139: 'a playlist',
34236: 'a divine',
@@ -126,6 +127,7 @@ const KIND_LABELS: Record<number, string> = {
37516: 'a treasure',
39089: 'a follow pack',
9735: 'a zap',
31124: 'a Blobbi',
};
/** Kind-specific icons — matches sidebar and NoteCard icons. */
@@ -156,6 +158,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
30063: Package,
30311: Radio,
30617: GitBranch,
31990: Package,
32267: Package,
34236: Clapperboard,
36767: Sparkles,
@@ -168,6 +171,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
39089: PartyPopper,
3367: Palette,
9735: Zap,
31124: Egg,
};
/**
@@ -226,6 +230,16 @@ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.Co
return { text: siteName ? `${siteName} nsite` : 'an nsite', icon };
}
// NIP-89 apps: name lives in JSON content, not in tags
if (event.kind === 31990) {
try {
const meta = JSON.parse(event.content);
const appName = meta?.name || event.tags.find(([n]) => n === 'name')?.[1];
if (appName) return { text: `${appName} app`, icon };
} catch { /* fall through */ }
return { text: 'an app', 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];
+23 -16
View File
@@ -224,6 +224,26 @@ export function ComposeBox({
const voiceRecorder = useVoiceRecorder();
const [isPublishingVoice, setIsPublishingVoice] = useState(false);
const resetComposeState = useCallback(() => {
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setPickerOpen(false);
setTrayOpen(false);
setInternalPreviewMode(false);
setMode(initialMode);
setPollQuestion('');
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
setPollType('singlechoice');
setPollDuration(7);
setRemovedEmbeds(new Set());
setUploadedFileTags([]);
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
}, [initialMode]);
// Use controlled preview mode if provided, otherwise use internal state
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
@@ -928,15 +948,7 @@ export function ComposeBox({
});
}
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setRemovedEmbeds(new Set());
setUploadedFileTags([]);
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
resetComposeState();
// Optimistically bump the reply count on the parent event
if (replyTo && !(replyTo instanceof URL)) {
queryClient.setQueryData<EventStats>(['event-stats', replyTo.id], (prev) =>
@@ -984,12 +996,7 @@ export function ComposeBox({
try {
await createEvent({ kind: 1068, content: pollQuestion.trim(), tags });
// Reset poll state
setMode('post');
setPollQuestion('');
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
setPollType('singlechoice');
setPollDuration(7);
resetComposeState();
queryClient.invalidateQueries({ queryKey: ['feed'] });
toast({ title: 'Poll published!' });
onSuccess?.();
@@ -1007,7 +1014,7 @@ export function ComposeBox({
if (!user && compact) return null;
return (
<div className={cn("px-4 py-3 bg-background/50")}>
<div className={cn("px-4 py-3 bg-background/85")}>
{/* Preview toggle at top when not controlled and has previewable content */}
{hasPreviewableContent && controlledPreviewMode === undefined && (
<div className="flex items-center justify-end mb-3">
+3 -3
View File
@@ -15,7 +15,7 @@ interface CustomEmojiImgProps {
/**
* Renders a single custom emoji as an inline image.
*/
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] align-text-bottom' }: CustomEmojiImgProps) {
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom' }: CustomEmojiImgProps) {
return (
<img
src={url}
@@ -52,7 +52,7 @@ export function ReactionEmoji({ content, tags, className }: ReactionEmojiProps)
const url = getCustomEmojiUrl(emoji, tags);
if (url) {
const name = emoji.slice(1, -1);
return <CustomEmojiImg name={name} url={url} className={className ?? 'inline h-[1.2em] w-[1.2em] align-text-bottom'} />;
return <CustomEmojiImg name={name} url={url} className={className ?? 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom'} />;
}
}
@@ -70,7 +70,7 @@ export function ReactionEmoji({ content, tags, className }: ReactionEmojiProps)
*/
export function RenderResolvedEmoji({ emoji, className }: { emoji: ResolvedEmoji; className?: string }) {
if (emoji.url && emoji.name) {
return <CustomEmojiImg name={emoji.name} url={emoji.url} className={className ?? 'inline h-[1.2em] w-[1.2em] align-middle'} />;
return <CustomEmojiImg name={emoji.name} url={emoji.url} className={className ?? 'inline h-[1.2em] w-[1.2em] object-contain align-middle'} />;
}
return <span className={cn('inline-block leading-none', className)}>{emoji.content}</span>;
}
+35 -13
View File
@@ -1,16 +1,17 @@
import { useMemo, useState, useCallback } from 'react';
import { Plus, Check, Loader2 } from 'lucide-react';
import { useMemo, useState, useCallback, lazy, Suspense } from 'react';
import { Plus, Check, Loader2, Pencil } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
const EmojiPackDialog = lazy(() => import('@/components/EmojiPackDialog').then(m => ({ default: m.EmojiPackDialog })));
/** Maximum emojis to show in the preview grid before truncating. */
const PREVIEW_LIMIT = 24;
@@ -60,6 +61,10 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [isPending, setIsPending] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const isOwnPack = user?.pubkey === event.pubkey;
// Fetch the user's kind 10030 emoji list to check if this pack is already added
const emojiListQuery = useQuery({
@@ -135,8 +140,8 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
if (!pack) return null;
const showEmojis = pack.emojis.slice(0, PREVIEW_LIMIT);
const remaining = pack.emojis.length - PREVIEW_LIMIT;
const showEmojis = expanded ? pack.emojis : pack.emojis.slice(0, PREVIEW_LIMIT);
const remaining = expanded ? 0 : pack.emojis.length - PREVIEW_LIMIT;
return (
<div className="mt-3 space-y-3">
@@ -150,12 +155,7 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[15px] truncate">{pack.name}</h3>
<Badge variant="secondary" className="text-[10px] shrink-0">
{pack.emojis.length} emoji{pack.emojis.length !== 1 ? 's' : ''}
</Badge>
</div>
<h3 className="font-semibold text-[15px] truncate">{pack.name}</h3>
{pack.about && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">{pack.about}</p>
)}
@@ -182,9 +182,13 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
</div>
))}
{remaining > 0 && (
<div className="flex items-center justify-center size-8 rounded bg-muted text-muted-foreground text-xs font-medium">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setExpanded(true); }}
className="flex items-center justify-center size-8 rounded bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
>
+{remaining}
</div>
</button>
)}
</div>
</div>
@@ -213,7 +217,25 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
{isAdded ? 'Added' : 'Add to Collection'}
</Button>
)}
{isOwnPack && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-1.5"
onClick={() => setEditOpen(true)}
>
<Pencil className="size-3.5" />
Edit
</Button>
)}
</div>
{/* Edit dialog (lazy loaded) */}
{editOpen && (
<Suspense fallback={null}>
<EmojiPackDialog open={editOpen} onOpenChange={setEditOpen} editEvent={event} />
</Suspense>
)}
</div>
);
}
+559
View File
@@ -0,0 +1,559 @@
import { useState, useCallback, useRef, useMemo } from 'react';
import { Smile, Upload, Loader2, X } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { SortableList, SortableItem } from '@/components/SortableList';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
/** A single emoji entry in the pack being edited. */
interface EmojiEntry {
/** Client-side key for React list rendering. */
id: string;
shortcode: string;
/** Display URL -- either a remote Blossom URL (for already-uploaded emojis) or a local blob URL (for pending files). */
url: string;
/** When set, this file still needs to be uploaded on submit. */
file?: File;
}
/** Convert a filename to a shortcode (alphanumeric, hyphens, underscores only). */
function filenameToShortcode(filename: string): string {
const dotIndex = filename.lastIndexOf('.');
const base = dotIndex > 0 ? filename.slice(0, dotIndex) : filename;
return base
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.toLowerCase();
}
/** Validate a shortcode against NIP-30 rules: alphanumeric, hyphens, underscores. */
function isValidShortcode(s: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(s);
}
/** Convert a title into a URL-safe slug for the identifier. */
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
let entryCounter = 0;
function nextEntryId(): string {
return `entry-${++entryCounter}-${Date.now()}`;
}
interface EmojiPackDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** When provided, the dialog opens in edit mode for an existing pack. */
editEvent?: NostrEvent;
}
export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
const { mutateAsync: uploadFile } = useUploadFile();
const queryClient = useQueryClient();
const { toast } = useToast();
const isEditMode = !!editEvent;
// Parse initial values from editEvent
const initialData = useMemo(() => {
if (!editEvent) return null;
const identifier = editEvent.tags.find(([n]) => n === 'd')?.[1] ?? '';
const name = editEvent.tags.find(([n]) => n === 'name')?.[1] ?? '';
const emojis: EmojiEntry[] = [];
for (const tag of editEvent.tags) {
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
emojis.push({ id: nextEntryId(), shortcode: tag[1], url: tag[2] });
}
}
const about = editEvent.tags.find(([n]) => n === 'about')?.[1] ?? '';
return { identifier, name, about, emojis };
}, [editEvent]);
// Form state
const [identifier, setIdentifier] = useState(initialData?.identifier ?? '');
const [name, setName] = useState(initialData?.name ?? '');
const [about, setAbout] = useState(initialData?.about ?? '');
const [idTouched, setIdTouched] = useState(isEditMode);
const [emojis, setEmojis] = useState<EmojiEntry[]>(initialData?.emojis ?? []);
const [isDragOver, setIsDragOver] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const effectiveIdentifier = idTouched ? identifier : slugify(name);
const resetForm = useCallback(() => {
setIdentifier(initialData?.identifier ?? '');
setName(initialData?.name ?? '');
setAbout(initialData?.about ?? '');
setIdTouched(isEditMode);
setEmojis((prev) => {
for (const e of prev) {
if (e.file) URL.revokeObjectURL(e.url);
}
return initialData?.emojis ?? [];
});
setIsDragOver(false);
setIsSubmitting(false);
}, [initialData, isEditMode]);
const handleNameChange = useCallback((value: string) => {
setName(value);
if (!idTouched) {
setIdentifier(slugify(value));
}
}, [idTouched]);
const handleIdChange = useCallback((value: string) => {
setIdTouched(true);
setIdentifier(value);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
/** Add image files to the emoji list as local previews (no upload yet). */
const addFiles = useCallback((files: File[]) => {
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length === 0) {
toast({ title: 'No images', description: 'No image files found.', variant: 'destructive' });
return;
}
const newEntries: EmojiEntry[] = imageFiles.map((file) => ({
id: nextEntryId(),
shortcode: filenameToShortcode(file.name),
url: URL.createObjectURL(file),
file,
}));
setEmojis((prev) => [...prev, ...newEntries]);
}, [toast]);
/** Handle drop zone events. */
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const items = e.dataTransfer.items;
const files: File[] = [];
if (items) {
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.();
if (entry) {
entries.push(entry);
} else {
const file = items[i].getAsFile();
if (file) files.push(file);
}
}
if (entries.length > 0) {
const readAllEntries = async (dirEntries: FileSystemEntry[]): Promise<File[]> => {
const allFiles: File[] = [];
const readEntry = (entry: FileSystemEntry): Promise<void> => {
return new Promise((resolve) => {
if (entry.isFile) {
(entry as FileSystemFileEntry).file((file) => {
allFiles.push(file);
resolve();
}, () => resolve());
} else if (entry.isDirectory) {
const dirReader = (entry as FileSystemDirectoryEntry).createReader();
dirReader.readEntries(async (childEntries) => {
await Promise.all(childEntries.map(readEntry));
resolve();
}, () => resolve());
} else {
resolve();
}
});
};
await Promise.all(dirEntries.map(readEntry));
return allFiles;
};
readAllEntries(entries).then(addFiles);
return;
}
}
for (let i = 0; i < e.dataTransfer.files.length; i++) {
files.push(e.dataTransfer.files[i]);
}
addFiles(files);
}, [addFiles]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
/** File input handler. */
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (!fileList) return;
const files: File[] = [];
for (let i = 0; i < fileList.length; i++) {
files.push(fileList[i]);
}
addFiles(files);
e.target.value = '';
}, [addFiles]);
/** Update a single emoji's shortcode. */
const updateShortcode = useCallback((id: string, newShortcode: string) => {
setEmojis((prev) =>
prev.map((e) => (e.id === id ? { ...e, shortcode: newShortcode } : e)),
);
}, []);
/** Remove an emoji entry. */
const removeEmoji = useCallback((id: string) => {
setEmojis((prev) => {
const removed = prev.find((e) => e.id === id);
if (removed?.file) URL.revokeObjectURL(removed.url);
return prev.filter((e) => e.id !== id);
});
}, []);
/** Reorder emojis after a drag completes (from SortableList). */
const handleReorder = useCallback((reordered: EmojiEntry[]) => {
setEmojis(reordered);
}, []);
/** Upload all pending files, then publish the emoji pack event. */
const handlePublish = useCallback(async () => {
const resolvedId = effectiveIdentifier.trim();
if (!user || !resolvedId || emojis.length === 0) return;
// Validate all shortcodes
const invalid = emojis.find((e) => !isValidShortcode(e.shortcode));
if (invalid) {
toast({
title: 'Invalid shortcode',
description: `"${invalid.shortcode}" contains invalid characters. Use only letters, numbers, hyphens, and underscores.`,
variant: 'destructive',
});
return;
}
// Check for duplicate shortcodes
const seen = new Set<string>();
for (const e of emojis) {
if (seen.has(e.shortcode)) {
toast({
title: 'Duplicate shortcode',
description: `"${e.shortcode}" appears more than once. Each shortcode must be unique.`,
variant: 'destructive',
});
return;
}
seen.add(e.shortcode);
}
setIsSubmitting(true);
try {
// Upload all pending files in parallel
const pendingEntries = emojis.filter((e) => e.file);
const uploadResults = new Map<string, string>();
if (pendingEntries.length > 0) {
const results = await Promise.allSettled(
pendingEntries.map(async (entry) => {
const [[, url]] = await uploadFile(entry.file!);
return { id: entry.id, url };
}),
);
for (const result of results) {
if (result.status === 'fulfilled') {
uploadResults.set(result.value.id, result.value.url);
}
}
const failedCount = pendingEntries.length - uploadResults.size;
if (failedCount > 0) {
toast({
title: 'Some uploads failed',
description: `${failedCount} file${failedCount !== 1 ? 's' : ''} failed to upload. Please try again.`,
variant: 'destructive',
});
setIsSubmitting(false);
return;
}
}
// Build the final emoji list with resolved URLs
const resolvedEmojis = emojis.map((e) => ({
shortcode: e.shortcode,
url: uploadResults.get(e.id) ?? e.url,
}));
// For edit mode, fetch fresh event to preserve any tags we don't manage
let preservedTags: string[][] = [];
if (isEditMode) {
const fresh = await fetchFreshEvent(nostr, {
kinds: [30030],
authors: [user.pubkey],
'#d': [resolvedId],
});
if (fresh) {
preservedTags = fresh.tags.filter(
([n]) => n !== 'd' && n !== 'name' && n !== 'about' && n !== 'emoji',
);
}
}
const tags: string[][] = [
['d', resolvedId],
...(name.trim() ? [['name', name.trim()]] : []),
...(about.trim() ? [['about', about.trim()]] : []),
...preservedTags,
...resolvedEmojis.map((e) => ['emoji', e.shortcode, e.url]),
];
await publishEvent({
kind: 30030,
content: '',
tags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Clean up blob URLs
for (const e of emojis) {
if (e.file) URL.revokeObjectURL(e.url);
}
queryClient.invalidateQueries({ queryKey: ['feed'] });
queryClient.invalidateQueries({ queryKey: ['custom-emojis'] });
queryClient.invalidateQueries({ queryKey: ['emoji-list'] });
toast({
title: isEditMode ? 'Emoji pack updated!' : 'Emoji pack created!',
description: `"${name.trim() || resolvedId}" with ${resolvedEmojis.length} emoji${resolvedEmojis.length !== 1 ? 's' : ''}.`,
});
handleOpenChange(false);
} catch {
toast({
title: 'Failed to publish',
description: 'Could not publish the emoji pack. Please try again.',
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
}, [user, effectiveIdentifier, name, about, emojis, isEditMode, nostr, publishEvent, uploadFile, queryClient, toast, handleOpenChange]);
// Validation
const pendingCount = emojis.filter((e) => e.file).length;
const hasValidEmojis = emojis.length > 0 && emojis.every((e) => e.shortcode);
const canPublish = effectiveIdentifier.trim() && hasValidEmojis && !isSubmitting;
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Smile className="size-5 text-primary" />
{isEditMode ? 'Edit Emoji Pack' : 'Create Emoji Pack'}
</DialogTitle>
<DialogDescription>
{isEditMode
? 'Update your custom emoji set. Drag and drop images to add more emojis.'
: 'Create a custom emoji set. Drag and drop images or a folder to populate it.'}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh]">
<div className="px-5 pb-5 space-y-4">
{/* Title & ID side-by-side */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="emoji-pack-name">Title</Label>
<Input
id="emoji-pack-name"
placeholder="e.g. Blobcats"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="emoji-pack-identifier">ID *</Label>
<Input
id="emoji-pack-identifier"
placeholder="e.g. blobcats"
value={idTouched ? identifier : effectiveIdentifier}
onChange={(e) => handleIdChange(e.target.value)}
disabled={isEditMode || isSubmitting}
className={`font-mono text-sm ${isEditMode ? 'text-muted-foreground' : ''}`}
/>
{isEditMode && (
<p className="text-xs text-muted-foreground">Cannot be changed.</p>
)}
</div>
</div>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="emoji-pack-about">Description</Label>
<Textarea
id="emoji-pack-about"
placeholder="What's in this emoji pack?"
value={about}
onChange={(e) => setAbout(e.target.value)}
disabled={isSubmitting}
className="min-h-[60px] resize-none text-sm"
rows={2}
/>
</div>
{/* Drop zone for adding emojis */}
<div className="space-y-1.5">
<Label>Emojis</Label>
<div
role="button"
tabIndex={0}
onClick={() => !isSubmitting && fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onKeyDown={(e) => { if (!isSubmitting && (e.key === 'Enter' || e.key === ' ')) fileInputRef.current?.click(); }}
className={`relative flex flex-col items-center justify-center w-full border-2 border-dashed rounded-xl transition-colors cursor-pointer overflow-hidden ${
isDragOver
? 'border-primary bg-primary/5'
: 'border-border bg-secondary/5 hover:bg-secondary/10'
} ${emojis.length > 0 ? 'h-20' : 'h-28'} ${isSubmitting ? 'pointer-events-none opacity-50' : ''}`}
>
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
<Upload className="size-4 opacity-50" />
<span className="text-xs text-center px-4">
Drop images or a folder here, or click to browse
</span>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileInputChange}
/>
</div>
</div>
{/* Emoji list */}
{emojis.length > 0 && (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">
{emojis.length} emoji{emojis.length !== 1 ? 's' : ''}
</span>
<div className="rounded-xl border border-border overflow-hidden divide-y divide-border">
<SortableList
items={emojis}
getItemId={(emoji) => emoji.id}
onReorder={handleReorder}
renderItem={(emoji) => (
<SortableItem
key={emoji.id}
id={emoji.id}
enabled={!isSubmitting}
className="items-center bg-background"
draggingClassName="z-10 opacity-80 shadow-lg ring-2 ring-primary/20"
gripClassName="w-7"
>
<div className="flex items-center gap-2 pr-2 py-1.5">
<div className="size-8 shrink-0 rounded-md overflow-hidden bg-secondary/30 flex items-center justify-center">
<img
src={emoji.url}
alt={emoji.shortcode}
className="size-8 object-contain"
loading="lazy"
/>
</div>
<div className="flex-1 min-w-0">
<Input
value={emoji.shortcode}
onChange={(e) => updateShortcode(emoji.id, e.target.value)}
className="h-7 text-xs font-mono px-1.5 border-none shadow-none focus-visible:ring-1"
placeholder="shortcode"
disabled={isSubmitting}
/>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeEmoji(emoji.id)}
disabled={isSubmitting}
>
<X className="size-3.5" />
</Button>
</div>
</SortableItem>
)}
/>
</div>
</div>
)}
{/* Publish button */}
<Button
onClick={handlePublish}
disabled={!canPublish}
className="w-full gap-2"
>
{isSubmitting ? (
<><Loader2 className="size-4 animate-spin" /> {pendingCount > 0 ? `Uploading ${pendingCount} file${pendingCount !== 1 ? 's' : ''}...` : 'Publishing...'}</>
) : (
<><Smile className="size-4" /> {isEditMode ? 'Update Emoji Pack' : 'Create Emoji Pack'}</>
)}
</Button>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
+3 -1
View File
@@ -1082,10 +1082,12 @@ function hasVideo(tags: string[][]): boolean {
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
31990: 'App',
32267: 'App',
30063: 'Release',
15128: 'Nsite',
35128: 'Nsite',
31124: 'Blobbi',
};
export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) {
@@ -1108,7 +1110,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey
const KindIcon = useMemo(() => {
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 === 31990 || addr.kind === 32267 || addr.kind === 30063) return Package;
if (addr.kind === 15128 || addr.kind === 35128) return Globe;
return FileText;
}, [kindDef, addr.kind]);
+8 -4
View File
@@ -108,10 +108,14 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const { startSignup } = useOnboarding();
// Kind-specific pages only support Follows + Global. Clamp any other
// persisted tab (e.g. 'ditto', 'communities') back to 'follows'.
const activeTab: FeedTab = kinds && rawActiveTab !== 'follows' && rawActiveTab !== 'global'
? 'follows'
: rawActiveTab;
// persisted tab (e.g. 'ditto', 'communities') back to the appropriate default.
// Logged-out users must land on 'global' since 'follows' requires a user.
const activeTab: FeedTab = (() => {
if (!kinds) return rawActiveTab; // Home feed: no clamping
if (rawActiveTab === 'global') return 'global';
if (rawActiveTab === 'follows' && user) return 'follows';
return user ? 'follows' : 'global';
})();
// Is the active tab a saved feed?
const activeSavedFeed = useMemo(
+1
View File
@@ -359,6 +359,7 @@ function SetupQuestionnaire({
feedIncludeBadgeDefinitions: false,
feedIncludeProfileBadges: false,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
followsFeedShowReplies: true,
};
+1 -1
View File
@@ -184,7 +184,7 @@ function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
{/* Emoji group header */}
<div className="flex items-center gap-2 px-4 py-2 bg-secondary/30 sticky top-0 z-[1]">
{customUrl && customName ? (
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6" />
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6 object-contain" />
) : (
<span className="text-lg">{emoji}</span>
)}
+8 -3
View File
@@ -1,7 +1,12 @@
import { Link } from 'react-router-dom';
interface LinkFooterProps {
/** Optional callback fired when an internal (React Router) link is clicked. */
onNavigate?: () => void;
}
/** Shared footer links used in both sidebars. */
export function LinkFooter() {
export function LinkFooter({ onNavigate }: LinkFooterProps) {
return (
<footer className="mt-auto pt-4 pb-4 text-left bg-background/85 rounded-xl p-3 -mx-1">
<p className="text-xs text-muted-foreground">
@@ -23,7 +28,7 @@ export function LinkFooter() {
Docs
</a>
{' · '}
<Link to="/privacy" className="text-primary hover:underline">
<Link to="/privacy" className="text-primary hover:underline" onClick={onNavigate}>
Privacy
</Link>
{' · '}
@@ -36,7 +41,7 @@ export function LinkFooter() {
Source
</a>
{' · '}
<Link to="/changelog" className="text-primary hover:underline">
<Link to="/changelog" className="text-primary hover:underline" onClick={onNavigate}>
Changelog
</Link>
{' · '}
+2 -2
View File
@@ -319,7 +319,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</nav>
<div className="px-2">
<LinkFooter />
<LinkFooter onNavigate={handleClose} />
</div>
</div>
) : (
@@ -363,7 +363,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</nav>
<div className="px-2">
<LinkFooter />
<LinkFooter onNavigate={handleClose} />
</div>
</div>
)}
+2 -14
View File
@@ -9,7 +9,7 @@ import { isSyncDone } from "@/hooks/useInitialSync";
import { parseBlossomServerList } from "@/lib/appBlossom";
import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent";
import type { ThemeConfig } from "@/themes";
import { themePresets } from "@/themes";
/**
* NostrSync - Syncs user's Nostr data
@@ -285,19 +285,7 @@ export function NostrSync() {
const updates = { ...current };
if (encryptedSettings.theme) {
// Migrate legacy theme values ("black", "pink") from older encrypted settings
const remoteTheme = encryptedSettings.theme as string;
if (remoteTheme in themePresets) {
if (
current.theme !== "custom" ||
JSON.stringify(current.customTheme?.colors) !==
JSON.stringify(themePresets[remoteTheme].colors)
) {
updates.theme = "custom";
updates.customTheme = { colors: themePresets[remoteTheme].colors };
changed = true;
}
} else if (encryptedSettings.theme !== current.theme) {
if (encryptedSettings.theme !== current.theme) {
updates.theme = encryptedSettings.theme;
changed = true;
}
+28 -5
View File
@@ -2,6 +2,7 @@ import type { NostrEvent } from "@nostrify/nostrify";
import {
Award,
Camera,
Egg,
FileCode,
FileText,
GitBranch,
@@ -24,6 +25,7 @@ import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState
import { Link } from "react-router-dom";
/** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the main feed bundle. */
const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent })));
const BlobbiStateCard = lazy(() => import("@/components/BlobbiStateCard").then(m => ({ default: m.BlobbiStateCard })));
import {
MusicPlaylistContent,
MusicTrackContent,
@@ -72,6 +74,7 @@ import { EncryptedMessageContent } from "@/components/EncryptedMessageContent";
import { EncryptedLetterContent } from "@/components/EncryptedLetterContent";
import { VanishCardCompact } from "@/components/VanishEventContent";
import { ZapstoreAppContent } from "@/components/ZapstoreAppContent";
import { AppHandlerContent } from "@/components/AppHandlerContent";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getAvatarShape } from "@/lib/avatarShape";
import { Badge } from "@/components/ui/badge";
@@ -304,11 +307,13 @@ export const NoteCard = memo(function NoteCard({
const isCustomNip = event.kind === 30817;
const isNsite = event.kind === 15128 || event.kind === 35128;
const isZapstoreApp = event.kind === 32267;
const isAppHandler = event.kind === 31990;
const isEncryptedDM = event.kind === 4;
const isLetter = event.kind === 8211;
const isVanish = event.kind === 62;
const isZap = event.kind === 9735;
const isProfile = event.kind === 0;
const isBlobbiState = event.kind === 31124;
const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite;
const isTextNote =
!isVine &&
@@ -333,11 +338,13 @@ export const NoteCard = memo(function NoteCard({
!isAudioKind &&
!isDevKind &&
!isZapstoreApp &&
!isAppHandler &&
!isEncryptedDM &&
!isLetter &&
!isVanish &&
!isZap &&
!isProfile;
!isProfile &&
!isBlobbiState;
// Kind 1 specific — images now render inline in NoteContent, only videos go to NoteMedia
const videos = useMemo(
@@ -528,12 +535,18 @@ export const NoteCard = memo(function NoteCard({
<NsiteCard event={event} />
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} compact />
) : isAppHandler ? (
<AppHandlerContent event={event} compact />
) : isEncryptedDM ? (
<EncryptedMessageContent event={event} compact />
) : isLetter ? (
<EncryptedLetterContent event={event} compact />
) : isProfile ? (
<ProfileCardContent event={event} />
) : isBlobbiState ? (
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
<BlobbiStateCard event={event} />
</Suspense>
) : (
<TruncatedNoteContent
event={event}
@@ -781,11 +794,11 @@ export const NoteCard = memo(function NoteCard({
<div className="flex gap-3">
<div className="flex flex-col items-center">
{/* Reaction emoji bubble instead of avatar */}
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0">
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0 text-lg leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="text-lg leading-none"
className="h-5 w-5 object-contain"
/>
</div>
{threaded && (
@@ -862,11 +875,11 @@ export const NoteCard = memo(function NoteCard({
>
<div className="flex items-center gap-3">
{/* Large reaction emoji */}
<div className="flex items-center justify-center size-11 rounded-full bg-pink-500/10 shrink-0">
<div className="flex items-center justify-center size-11 rounded-full bg-pink-500/10 shrink-0 text-xl leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="text-xl leading-none"
className="h-6 w-6 object-contain"
/>
</div>
@@ -1994,6 +2007,10 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
icon: Package,
action: "published an app",
},
31990: {
icon: Package,
action: "published an app",
},
30617: {
icon: GitBranch,
action: "shared a",
@@ -2034,6 +2051,12 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
icon: Zap,
action: "zapped",
},
31124: {
icon: Egg,
action: "updated their",
noun: "Blobbi",
nounRoute: "/blobbi",
},
};
/** Generic action header: icon · [author name] [action] [linked noun] */
+1 -1
View File
@@ -545,7 +545,7 @@ export function NoteContent({
{groupedTokens.map((token, i) => {
switch (token.type) {
case 'text':
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 align-text-bottom' : undefined))}</span>;
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 object-contain align-text-bottom' : undefined))}</span>;
case 'image-embed': {
if (disableEmbeds) {
// In preview contexts (e.g. triple-dot menu), replace image URLs
+1 -1
View File
@@ -186,7 +186,7 @@ export function ReactionButton({
{filledHeart ? (
<Heart className="size-6" fill={hasReacted ? 'currentColor' : 'none'} />
) : hasReacted && userReaction ? (
<RenderResolvedEmoji emoji={userReaction} className="size-5 leading-none translate-y-px" />
<RenderResolvedEmoji emoji={userReaction} className="h-5 w-5 object-contain leading-none translate-y-px" />
) : (
<Heart className="size-5" />
)}
+70
View File
@@ -0,0 +1,70 @@
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { toast } from '@/hooks/useToast';
import { ToastAction } from '@/components/ui/toast';
import { parseChangelog } from '@/lib/changelog';
const STORAGE_KEY = 'ditto:app-version';
/** Fetch the first changelog item for the given version (or the latest entry). */
async function fetchChangelogExcerpt(version: string): Promise<string | undefined> {
try {
const res = await fetch('/CHANGELOG.md');
if (!res.ok) return undefined;
const markdown = await res.text();
const entries = parseChangelog(markdown);
// Try to find the entry matching the current version, otherwise use the first entry.
const entry = entries.find((e) => e.version === version) ?? entries[0];
if (!entry) return undefined;
// Return a truncated first item from the first section.
const item = entry.sections[0]?.items[0];
if (!item) return undefined;
if (item.length <= 60) return item;
return item.slice(0, 60).trimEnd() + '…';
} catch {
return undefined;
}
}
/** Compares the running app version against localStorage and shows a toast when the version changes. */
export function VersionCheck() {
useEffect(() => {
const currentVersion = import.meta.env.VERSION;
if (!currentVersion) return;
const storedVersion = localStorage.getItem(STORAGE_KEY);
localStorage.setItem(STORAGE_KEY, currentVersion);
if (storedVersion && storedVersion !== currentVersion) {
// Show the toast immediately, then enrich it with a changelog excerpt.
const { update, id } = toast({
title: `What's new in v${currentVersion}`,
action: (
<ToastAction altText="View changelog" asChild>
<Link to="/changelog">See all</Link>
</ToastAction>
),
});
fetchChangelogExcerpt(currentVersion).then((excerpt) => {
if (excerpt) {
update({
id,
title: `What's new in v${currentVersion}`,
description: excerpt,
action: (
<ToastAction altText="View changelog" asChild>
<Link to="/changelog">See all</Link>
</ToastAction>
),
});
}
});
}
}, []);
return null;
}
+904
View File
@@ -0,0 +1,904 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import {
ArrowLeft,
Image,
Save,
Send,
Loader2,
Hash,
FileText,
X,
Clock,
Cloud,
HardDrive,
Trash2,
ChevronRight,
} from 'lucide-react';
import slugify from 'slugify';
import { useNostr } from '@nostrify/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { FabButton } from '@/components/FabButton';
import { toast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDrafts, type Draft } from '@/hooks/useDrafts';
import { usePublishedArticles } from '@/hooks/usePublishedArticles';
import { saveDraft as saveLocalDraft, deleteDraftBySlug, deleteLocalDraftById, getLocalDrafts } from '@/lib/localDrafts';
import type { ArticleFields } from '@/lib/articleHelpers';
import { MilkdownEditor } from './MilkdownEditor';
export type ArticleData = ArticleFields;
interface ArticleEditorProps {
/** Pre-filled data for editing an existing article or loading a draft. */
initialData?: Partial<ArticleData> & { publishedAt?: number };
/** Whether the editor is in edit mode (updating an existing article). */
editMode?: boolean;
}
type EditorTab = 'write' | 'drafts';
export function ArticleEditor({ initialData, editMode = false }: ArticleEditorProps) {
const navigate = useNavigate();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutate: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { drafts: relayDrafts, isLoading: isDraftsLoading, saveDraft: saveRelayDraft, deleteDraft: deleteRelayDraft, isDeleting } = useDrafts();
const { articles: publishedArticles } = usePublishedArticles();
const fileInputRef = useRef<HTMLInputElement>(null);
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [activeTab, setActiveTab] = useState<EditorTab>('write');
const [localDrafts, setLocalDrafts] = useState<Draft[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; slug: string; isLocal: boolean } | null>(null);
const [tagInput, setTagInput] = useState('');
const slugManuallyEdited = useRef(!!initialData?.slug);
const [isPublished, setIsPublished] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
const [isEditMode, setIsEditMode] = useState(editMode);
const [originalPublishedAt, setOriginalPublishedAt] = useState<number | null>(
initialData?.publishedAt ?? null,
);
const [article, setArticle] = useState<ArticleData>({
title: initialData?.title || '',
summary: initialData?.summary || '',
content: initialData?.content || '',
image: initialData?.image || '',
tags: initialData?.tags || [],
slug: initialData?.slug || '',
});
// Keep a ref to the latest article data so the auto-save timer doesn't
// need `article` in its dependency array (which would reset it on every keystroke).
const articleRef = useRef(article);
articleRef.current = article;
const mountedRef = useRef(true);
useEffect(() => () => { mountedRef.current = false; }, []);
/** Save draft to relay (with localStorage fallback). Shared by manual save + auto-save. */
const persistDraft = useCallback(async (data: ArticleData, { silent }: { silent?: boolean } = {}) => {
if (user) {
try {
await saveRelayDraft(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (!silent) {
toast({ title: 'Draft saved', description: 'Your article has been saved to Nostr relays.' });
}
} catch (error) {
console.error('Failed to save draft to relay:', error);
saveLocalDraft(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (!silent) {
toast({ title: 'Draft saved locally', description: 'Could not sync to relays. Saved to your browser.', variant: 'destructive' });
}
}
} else {
saveLocalDraft(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (!silent) {
toast({ title: 'Draft saved', description: 'Your article has been saved locally.' });
}
}
}, [user, saveRelayDraft]);
// Auto-save 30s after the first unsaved change. The timer starts once and
// is only reset when `hasUnsavedChanges` transitions, not on every keystroke.
useEffect(() => {
if (!hasUnsavedChanges) return;
autoSaveTimeoutRef.current = setTimeout(() => {
const current = articleRef.current;
if (current.content.length === 0) return;
persistDraft(current, { silent: true });
}, 30000);
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, [hasUnsavedChanges, persistDraft]);
// Reference to handlers for keyboard shortcuts
const handlePublishRef = useRef<(() => void) | null>(null);
const handleSaveDraftRef = useRef<(() => void) | null>(null);
// Warn before leaving page with unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges && (article.title || article.content)) {
e.preventDefault();
e.returnValue = '';
return '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges, article.title, article.content]);
// Auto-generate slug from title (skip if user manually edited the slug)
useEffect(() => {
if (article.title && !slugManuallyEdited.current) {
const newSlug = slugify(article.title, {
lower: true,
strict: true,
trim: true,
});
setArticle((prev) => ({ ...prev, slug: newSlug }));
}
}, [article.title]);
// Derived stats
const wordCount = useMemo(() => article.content.trim().split(/\s+/).filter(Boolean).length, [article.content]);
const readingTime = Math.ceil(wordCount / 200);
// Load local drafts when drafts tab is shown
useEffect(() => {
if (activeTab === 'drafts') {
setLocalDrafts(getLocalDrafts());
}
}, [activeTab]);
// Combine relay and local drafts, avoiding duplicates by slug
const combinedDrafts = useMemo(() => {
const drafts: (Draft & { isLocal: boolean })[] = [];
const seenSlugs = new Set<string>();
for (const draft of relayDrafts) {
if (draft.slug) seenSlugs.add(draft.slug);
drafts.push({ ...draft, isLocal: false });
}
for (const draft of localDrafts) {
if (!draft.slug || !seenSlugs.has(draft.slug)) {
drafts.push({ ...draft, isLocal: true });
}
}
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
}, [relayDrafts, localDrafts]);
/** Load a draft or published article into the editor. */
const handleLoadItem = useCallback((item: ArticleData & { publishedAt?: number }, isPublishedArticle: boolean) => {
setArticle({
title: item.title,
summary: item.summary,
content: item.content,
image: item.image,
tags: item.tags,
slug: item.slug,
});
slugManuallyEdited.current = !!item.slug;
setIsEditMode(isPublishedArticle);
setOriginalPublishedAt(item.publishedAt ?? null);
setHasUnsavedChanges(false);
setActiveTab('write');
toast({
title: isPublishedArticle ? 'Article loaded for editing' : 'Draft loaded',
description: isPublishedArticle
? 'Make changes and publish to update your article.'
: 'Your draft has been loaded into the editor.',
});
}, []);
const handleDeleteDraft = useCallback(async () => {
if (!deleteTarget) return;
if (deleteTarget.isLocal) {
setLocalDrafts(deleteLocalDraftById(deleteTarget.id));
toast({ title: 'Draft deleted', description: 'Removed from your browser.' });
} else {
try {
await deleteRelayDraft(deleteTarget.slug);
toast({ title: 'Draft deleted', description: 'Deletion published to relays.' });
} catch (error) {
const message = error instanceof Error ? error.message : '';
toast({ title: 'Delete failed', description: message || 'Could not delete draft.', variant: 'destructive' });
}
}
setDeleteTarget(null);
}, [deleteTarget, deleteRelayDraft]);
const updateArticle = useCallback(
(field: keyof ArticleData, value: string | string[]) => {
setArticle((prev) => ({ ...prev, [field]: value }));
setHasUnsavedChanges(true);
},
[],
);
const handleAddTag = useCallback(() => {
const newTag = tagInput.trim().toLowerCase().replace(/^#/, '');
if (newTag && !article.tags.includes(newTag)) {
setArticle((prev) => ({ ...prev, tags: [...prev.tags, newTag] }));
setTagInput('');
}
}, [tagInput, article.tags]);
const handleRemoveTag = useCallback((tagToRemove: string) => {
setArticle((prev) => ({
...prev,
tags: prev.tags.filter((tag) => tag !== tagToRemove),
}));
}, []);
const handleImageUpload = useCallback(
async (file: File) => {
try {
const [[, url]] = await uploadFile(file);
return url;
} catch (error) {
console.error('Upload failed:', error);
toast({
title: 'Upload failed',
description: 'Could not upload the image. Please try again.',
variant: 'destructive',
});
return null;
}
},
[uploadFile],
);
const handleHeaderImageUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const url = await handleImageUpload(file);
if (url) {
updateArticle('image', url);
toast({
title: 'Image uploaded',
description: 'Header image has been set successfully.',
});
}
},
[handleImageUpload, updateArticle],
);
const handleSaveDraft = useCallback(async () => {
await persistDraft(article);
}, [article, persistDraft]);
/** Perform the actual publish (called directly or after overwrite confirmation). */
const doPublish = useCallback(() => {
if (!user) return;
// Use original published_at when editing, current time for new articles
const publishedAtTimestamp =
isEditMode && originalPublishedAt
? Math.floor(originalPublishedAt / 1000)
: Math.floor(Date.now() / 1000);
const tags: string[][] = [
['d', article.slug || slugify(article.title, { lower: true, strict: true })],
['title', article.title],
['published_at', publishedAtTimestamp.toString()],
];
if (article.summary) {
tags.push(['summary', article.summary]);
}
if (article.image) {
tags.push(['image', article.image]);
}
article.tags.forEach((tag) => {
tags.push(['t', tag]);
});
publishEvent(
{
kind: 30023,
content: article.content,
tags,
},
{
onSuccess: async () => {
setIsPublished(true);
setHasUnsavedChanges(false);
// Remove draft after publishing
if (article.slug) {
deleteDraftBySlug(article.slug);
try {
await deleteRelayDraft(article.slug);
} catch (error) {
console.error('Failed to delete draft from relay:', error);
}
}
toast({
title: isEditMode ? 'Article updated' : 'Article published',
description: isEditMode
? 'Your article has been updated on Nostr.'
: 'Your article is now live on Nostr.',
});
// Navigate back to articles feed
navigate('/articles');
},
onError: (error) => {
toast({
title: 'Publishing failed',
description:
error.message || 'Could not publish your article. Please try again.',
variant: 'destructive',
});
},
},
);
}, [
user,
article,
publishEvent,
deleteRelayDraft,
isEditMode,
originalPublishedAt,
navigate,
]);
const handlePublish = useCallback(async () => {
if (!user) {
toast({
title: 'Login required',
description: 'Please login to publish your article.',
variant: 'destructive',
});
return;
}
if (!article.title.trim()) {
toast({
title: 'Title required',
description: 'Please add a title to your article.',
variant: 'destructive',
});
return;
}
if (!article.content.trim()) {
toast({
title: 'Content required',
description: 'Please write some content for your article.',
variant: 'destructive',
});
return;
}
// In edit mode we're intentionally overwriting, so skip the collision check
if (!isEditMode) {
const slug = article.slug || slugify(article.title, { lower: true, strict: true });
try {
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
toast({
title: 'Slug already in use',
description: 'You already have a published article with this slug. Change the slug or edit the existing article from My Articles.',
variant: 'destructive',
});
return;
}
} catch {
// If the check fails (e.g. relay timeout), proceed anyway
}
}
doPublish();
}, [user, article, isEditMode, nostr, doPublish]);
// Set refs for keyboard shortcuts
handlePublishRef.current = handlePublish;
handleSaveDraftRef.current = handleSaveDraft;
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
handleSaveDraftRef.current?.();
}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && user) {
e.preventDefault();
handlePublishRef.current?.();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [user]);
// Handle back navigation with unsaved changes check
const handleBack = useCallback(() => {
if (hasUnsavedChanges && (article.title || article.content)) {
setShowLeaveDialog(true);
} else {
navigate('/articles');
}
}, [hasUnsavedChanges, article.title, article.content, navigate]);
const handleLeaveWithoutSaving = useCallback(() => {
setShowLeaveDialog(false);
navigate('/articles');
}, [navigate]);
const handleSaveAndLeave = useCallback(async () => {
await handleSaveDraft();
setShowLeaveDialog(false);
navigate('/articles');
}, [handleSaveDraft, navigate]);
const statusLabel = isPublished ? (
<span className="text-green-600 dark:text-green-400 text-sm">
{isEditMode ? 'Updated' : 'Published'}
</span>
) : isEditMode ? (
hasUnsavedChanges ? (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
Editing
</span>
) : (
<span className="text-blue-600 dark:text-blue-400 text-sm">Editing</span>
)
) : hasUnsavedChanges ? (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
Unsaved
</span>
) : lastSaved ? (
<span className="text-sm text-muted-foreground">Saved</span>
) : null;
const totalDrafts = combinedDrafts.length;
return (
<div className="flex flex-col">
{/* Sticky header */}
<div className="sticky top-0 z-20">
<SubHeaderBar pinned className="relative !top-0">
<button
onClick={handleBack}
className="pl-3 pr-1 py-1.5 text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<ArrowLeft className="size-5" />
</button>
<TabButton
label="New"
active={activeTab === 'write'}
onClick={() => setActiveTab('write')}
/>
<TabButton
label="My Articles"
active={activeTab === 'drafts'}
onClick={() => setActiveTab('drafts')}
/>
</SubHeaderBar>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleHeaderImageUpload}
className="hidden"
/>
{/* ── New article tab ──────────────────────────────────────── */}
{activeTab === 'write' && (
<div className="px-4 py-6 pb-24 space-y-6">
{/* Header Image */}
{article.image ? (
<div className="relative rounded-xl overflow-hidden group">
<img
src={article.image}
alt="Header"
className="w-full h-48 sm:h-64 object-cover"
/>
{/* Desktop: centered overlay on hover */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors hidden sm:flex items-center justify-center opacity-0 group-hover:opacity-100">
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Image className="w-4 h-4 mr-2" />
)}
Change Image
</Button>
</div>
{/* Mobile: persistent corner button */}
<Button
variant="secondary"
size="icon"
className="absolute top-2 right-2 h-8 w-8 rounded-full shadow-md sm:hidden"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Image className="w-4 h-4" />
)}
</Button>
</div>
) : (
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full h-32 border-2 border-dashed border-border rounded-xl flex flex-col items-center justify-center text-muted-foreground hover:border-primary/50 hover:text-primary/70 transition-colors"
>
{isUploading ? (
<Loader2 className="w-8 h-8 animate-spin mb-2" />
) : (
<Image className="w-8 h-8 mb-2" />
)}
<span className="text-sm">Add a header image</span>
</button>
)}
{/* Title */}
<input
type="text"
value={article.title}
onChange={(e) => updateArticle('title', e.target.value)}
placeholder="Your article title..."
className="w-full text-3xl sm:text-4xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/40"
/>
{/* Metadata — inline between title and body */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="summary" className="text-muted-foreground text-sm">Summary</Label>
<Textarea
id="summary"
value={article.summary}
onChange={(e) => updateArticle('summary', e.target.value)}
placeholder="A brief description of your article..."
rows={2}
className="resize-none"
/>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="space-y-1.5 flex-1">
<Label htmlFor="slug" className="text-muted-foreground text-xs leading-none">URL Slug</Label>
<Input
id="slug"
value={article.slug}
onChange={(e) => {
slugManuallyEdited.current = true;
updateArticle('slug', e.target.value);
}}
placeholder="article-url-slug"
className="h-8 font-mono text-xs"
/>
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-muted-foreground text-xs inline-flex items-center gap-1 leading-none">
<Hash className="w-3 h-3 shrink-0" />
Tags
</Label>
<div className="flex gap-1.5">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) =>
e.key === 'Enter' && (e.preventDefault(), handleAddTag())
}
placeholder="Add a tag..."
className="h-8 text-xs flex-1"
/>
<Button type="button" variant="secondary" size="icon" className="h-8 w-8 shrink-0" onClick={handleAddTag}>
<span className="text-base leading-none">+</span>
</Button>
</div>
</div>
</div>
{article.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{article.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1 px-2 py-1">
#{tag}
<button onClick={() => handleRemoveTag(tag)} className="ml-1 hover:text-destructive">
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* Editor */}
<MilkdownEditor
value={article.content}
onChange={(value) => updateArticle('content', value || '')}
onUploadImage={handleImageUpload}
placeholder="Start writing your article..."
className="rounded-xl border border-border bg-card min-h-[250px] sm:min-h-[400px]"
/>
{/* Stats + Save */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<span className="shrink-0">{wordCount} words</span>
<span>·</span>
<span className="shrink-0">{readingTime} min read</span>
{statusLabel && (
<>
<span>·</span>
{statusLabel}
</>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={handleSaveDraft}
className="rounded-full gap-1.5 shrink-0"
>
<Save className="size-3.5" />
Save Draft
</Button>
</div>
</div>
)}
{/* ── Drafts tab ───────────────────────────────────────────── */}
{activeTab === 'drafts' && (
<div className="px-4 py-4">
{isDraftsLoading && user ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground mb-4" />
<p className="text-muted-foreground">Loading drafts...</p>
</div>
) : totalDrafts === 0 && publishedArticles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-muted-foreground" />
</div>
<p className="text-muted-foreground">No drafts or articles yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Save a draft or publish to see content here
</p>
</div>
) : (
<div className="space-y-6">
{/* Drafts section */}
{totalDrafts > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground px-1">Drafts ({totalDrafts})</h3>
{combinedDrafts.map((draft) => (
<div
key={draft.id}
className="group p-4 rounded-xl border border-border hover:border-primary/30 hover:bg-card transition-all cursor-pointer"
onClick={() => handleLoadItem(draft, false)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">
{draft.title || 'Untitled Draft'}
</h3>
{draft.summary && (
<p className="text-sm text-muted-foreground truncate mt-1">
{draft.summary}
</p>
)}
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-1" />
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
{draft.isLocal ? (
<HardDrive className="w-3 h-3 shrink-0" />
) : (
<Cloud className="w-3 h-3 text-primary shrink-0" />
)}
<Clock className="w-3 h-3 shrink-0" />
<span>{formatDistanceToNow(draft.updatedAt, { addSuffix: true })}</span>
{draft.tags.length > 0 && (
<>
<span>·</span>
<span>{draft.tags.length} tags</span>
</>
)}
<span className="flex-1" />
<button
className="p-1 rounded-full text-muted-foreground hover:text-destructive transition-colors"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget({ id: draft.id, slug: draft.slug, isLocal: draft.isLocal });
}}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
{/* Published articles section */}
{publishedArticles.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground px-1">Published ({publishedArticles.length})</h3>
{publishedArticles.map((pub) => (
<div
key={pub.id}
className="group p-4 rounded-xl border border-border hover:border-green-500/30 hover:bg-card transition-all cursor-pointer"
onClick={() => handleLoadItem(pub, true)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">
{pub.title || 'Untitled Article'}
</h3>
{pub.summary && (
<p className="text-sm text-muted-foreground truncate mt-1">
{pub.summary}
</p>
)}
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-1" />
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3 shrink-0" />
<span>Published {formatDistanceToNow(pub.publishedAt, { addSuffix: true })}</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* Publish FAB — mobile: fixed bottom right */}
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton
onClick={handlePublish}
disabled={isPublishing || !user}
title={isEditMode ? 'Update article' : 'Publish article'}
icon={isPublishing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
{/* Publish FAB — desktop: 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={handlePublish}
disabled={isPublishing || !user}
title={isEditMode ? 'Update article' : 'Publish article'}
icon={isPublishing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
</div>
</div>
{/* Leave Confirmation Dialog */}
<AlertDialog open={showLeaveDialog} onOpenChange={setShowLeaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Would you like to save your draft before
leaving?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
<AlertDialogCancel onClick={() => setShowLeaveDialog(false)}>
Cancel
</AlertDialogCancel>
<Button variant="outline" onClick={handleLeaveWithoutSaving}>
Discard
</Button>
<AlertDialogAction onClick={handleSaveAndLeave}>
Save Draft
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Draft Confirmation Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete draft?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget?.isLocal
? 'This draft will be permanently deleted from your browser.'
: 'This draft will be deleted from Nostr relays.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteDraft}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Deleting...
</>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface LinkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedText?: string;
onSubmit: (text: string, url: string) => void;
}
export function LinkDialog({ open, onOpenChange, selectedText, onSubmit }: LinkDialogProps) {
const [text, setText] = useState('');
const [url, setUrl] = useState('');
// Reset form when dialog opens
useEffect(() => {
if (open) {
setText(selectedText || '');
setUrl('');
}
}, [open, selectedText]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (url.trim()) {
const finalText = text.trim() || url.trim();
let finalUrl = url.trim();
// Add https:// if no protocol specified
if (!/^https?:\/\//i.test(finalUrl)) {
finalUrl = 'https://' + finalUrl;
}
onSubmit(finalText, finalUrl);
onOpenChange(false);
}
};
const hasSelectedText = !!selectedText;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Insert Link</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
{!hasSelectedText && (
<div className="space-y-2">
<Label htmlFor="link-text">Link Text</Label>
<Input
id="link-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter link text..."
autoFocus={!hasSelectedText}
/>
</div>
)}
{hasSelectedText && (
<div className="space-y-2">
<Label className="text-muted-foreground">Link Text</Label>
<p className="text-sm bg-muted px-3 py-2 rounded-md">{selectedText}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="link-url">URL</Label>
<Input
id="link-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
autoFocus={hasSelectedText}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!url.trim()}>
Insert Link
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+374
View File
@@ -0,0 +1,374 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBlockquoteCommand, insertHrCommand, turnIntoTextCommand, wrapInHeadingCommand, toggleInlineCodeCommand, wrapInBulletListCommand, wrapInOrderedListCommand } from '@milkdown/preset-commonmark';
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm';
import { history } from '@milkdown/plugin-history';
import { clipboard } from '@milkdown/plugin-clipboard';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import { upload, uploadConfig } from '@milkdown/plugin-upload';
import { Decoration } from '@milkdown/prose/view';
import { replaceAll, callCommand } from '@milkdown/utils';
import { MilkdownToolbar } from './MilkdownToolbar';
import { LinkDialog } from './LinkDialog';
interface MilkdownEditorInnerProps {
value: string;
onChange: (markdown: string) => void;
onUploadImage?: (file: File) => Promise<string | null>;
placeholder?: string;
showToolbar?: boolean;
sourceMode?: boolean;
onToggleSource?: () => void;
}
function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, showToolbar = true, sourceMode, onToggleSource }: MilkdownEditorInnerProps) {
const initialValueRef = useRef(value);
const editorRef = useRef<Editor | null>(null);
const lastExternalValue = useRef(value);
const onUploadImageRef = useRef(onUploadImage);
const fileInputRef = useRef<HTMLInputElement>(null);
// Link dialog state
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [selectedTextForLink, setSelectedTextForLink] = useState<string>('');
const selectionRef = useRef<{ from: number; to: number } | null>(null);
// Keep ref updated
useEffect(() => {
onUploadImageRef.current = onUploadImage;
}, [onUploadImage]);
const { get } = useEditor((root) => {
const editor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
ctx.set(defaultValueCtx, initialValueRef.current);
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
lastExternalValue.current = markdown;
onChange(markdown);
});
// Configure upload plugin
ctx.set(uploadConfig.key, {
uploader: async (files, schema) => {
const images: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files.item(i);
if (!file) continue;
// Only handle images
if (!file.type.includes('image')) continue;
images.push(file);
}
const nodes: ReturnType<typeof schema.nodes.image.createAndFill>[] = [];
for (const image of images) {
try {
// Use the upload handler if provided
if (onUploadImageRef.current) {
const url = await onUploadImageRef.current(image);
if (url) {
const node = schema.nodes.image.createAndFill({
src: url,
alt: image.name,
});
if (node) nodes.push(node);
}
} else {
// Fallback to base64 if no upload handler
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve) => {
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(image);
});
const node = schema.nodes.image.createAndFill({
src: dataUrl,
alt: image.name,
});
if (node) nodes.push(node);
}
} catch (error) {
console.error('Failed to upload image:', error);
}
}
return nodes.filter((node): node is NonNullable<typeof node> => node !== null);
},
enableHtmlFileUploader: true,
uploadWidgetFactory: (pos, spec) => {
// Create a placeholder widget while uploading
const widgetEl = document.createElement('span');
widgetEl.className = 'milkdown-upload-placeholder';
widgetEl.textContent = 'Uploading...';
return Decoration.widget(pos, widgetEl, spec);
},
});
})
.use(commonmark)
.use(gfm)
.use(history)
.use(clipboard)
.use(listener)
.use(upload);
return editor;
});
// Store editor reference
useEffect(() => {
editorRef.current = get() ?? null;
}, [get]);
// Toggle `has-content` class on blur so CSS can hide the placeholder
// when the editor has real content (including trailing whitespace that
// ProseMirror collapses out of the DOM).
useEffect(() => {
const editor = get();
if (!editor) return;
let dom: HTMLElement;
try {
dom = editor.ctx.get(editorViewCtx).dom;
} catch {
return;
}
const check = () => {
const hasContent = !!lastExternalValue.current.replace(/\n/g, '');
dom.classList.toggle('has-content', hasContent);
};
// Set initial state
check();
dom.addEventListener('blur', check);
return () => dom.removeEventListener('blur', check);
}, [get]);
// Handle external value changes (e.g., loading a draft)
useEffect(() => {
const editor = get();
if (editor && value !== lastExternalValue.current) {
// Only update if the value changed externally (not from user typing)
editor.action(replaceAll(value));
lastExternalValue.current = value;
}
}, [value, get]);
// Handle link dialog open
const handleLinkButtonClick = useCallback(() => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state } = view;
const { from, to } = state.selection;
const selectedText = state.doc.textBetween(from, to);
// Store selection for later use
selectionRef.current = { from, to };
setSelectedTextForLink(selectedText);
setLinkDialogOpen(true);
} catch (error) {
console.error('Failed to get selection:', error);
}
}, [get]);
// Handle link insertion from dialog
const handleLinkSubmit = useCallback((text: string, url: string) => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state, dispatch } = view;
const { schema } = state;
// Create a link mark
const linkMark = schema.marks.link.create({ href: url });
// Create text node with link mark
const linkNode = schema.text(text, [linkMark]);
const tr = state.tr;
if (selectionRef.current) {
const { from, to } = selectionRef.current;
// Replace selection with linked text
tr.replaceWith(from, to, linkNode);
} else {
// Insert at current position
const { from } = state.selection;
tr.insert(from, linkNode);
}
dispatch(tr);
view.focus();
} catch (error) {
console.error('Failed to insert link:', error);
}
}, [get]);
// Handle image upload via file picker + ProseMirror insertion
const handleImageButtonClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleImageFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !onUploadImageRef.current) return;
const url = await onUploadImageRef.current(file);
if (!url) return;
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state, dispatch } = view;
const { schema } = state;
const node = schema.nodes.image.createAndFill({ src: url, alt: file.name });
if (node) {
const { from } = state.selection;
dispatch(state.tr.insert(from, node));
view.focus();
}
} catch (error) {
console.error('Failed to insert image:', error);
}
// Reset so the same file can be re-selected
e.target.value = '';
}, [get]);
// Handle toolbar commands
const handleCommand = useCallback((command: string) => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
switch (command) {
case 'toggleBold':
editor.action(callCommand(toggleStrongCommand.key));
break;
case 'toggleItalic':
editor.action(callCommand(toggleEmphasisCommand.key));
break;
case 'toggleStrikethrough':
editor.action(callCommand(toggleStrikethroughCommand.key));
break;
case 'toggleInlineCode':
editor.action(callCommand(toggleInlineCodeCommand.key));
break;
case 'heading1':
editor.action(callCommand(wrapInHeadingCommand.key, 1));
break;
case 'heading2':
editor.action(callCommand(wrapInHeadingCommand.key, 2));
break;
case 'heading3':
editor.action(callCommand(wrapInHeadingCommand.key, 3));
break;
case 'bulletList':
editor.action(callCommand(wrapInBulletListCommand.key));
break;
case 'orderedList':
editor.action(callCommand(wrapInOrderedListCommand.key));
break;
case 'blockquote':
editor.action(callCommand(wrapInBlockquoteCommand.key));
break;
case 'link':
handleLinkButtonClick();
return; // Don't refocus, dialog will handle it
case 'hr':
editor.action(callCommand(insertHrCommand.key));
break;
case 'paragraph':
editor.action(callCommand(turnIntoTextCommand.key));
break;
}
// Refocus the editor
view.focus();
} catch (error) {
console.error('Command failed:', error);
}
}, [get, handleLinkButtonClick]);
return (
<>
{showToolbar && (
<MilkdownToolbar
onCommand={handleCommand}
onImageUpload={onUploadImage ? handleImageButtonClick : undefined}
sourceMode={sourceMode}
onToggleSource={onToggleSource}
/>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageFileChange}
className="hidden"
/>
{sourceMode ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full min-h-[250px] sm:min-h-[350px] p-3 bg-transparent font-mono text-sm resize-y outline-none"
placeholder={placeholder}
spellCheck={false}
/>
) : (
<div
className="milkdown-content"
style={placeholder ? { '--ph': `"${placeholder.replace(/"/g, '\\"')}"` } as React.CSSProperties : undefined}
>
<Milkdown />
</div>
)}
<LinkDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
selectedText={selectedTextForLink}
onSubmit={handleLinkSubmit}
/>
</>
);
}
interface MilkdownEditorProps {
value: string;
onChange: (markdown: string) => void;
onUploadImage?: (file: File) => Promise<string | null>;
placeholder?: string;
className?: string;
showToolbar?: boolean;
}
export function MilkdownEditor({ value, onChange, onUploadImage, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
const [sourceMode, setSourceMode] = useState(false);
return (
<div className={`milkdown-editor ${className || ''}`}>
<MilkdownProvider>
<MilkdownEditorInner
value={value}
onChange={onChange}
onUploadImage={onUploadImage}
placeholder={placeholder}
showToolbar={showToolbar}
sourceMode={sourceMode}
onToggleSource={() => setSourceMode((s) => !s)}
/>
</MilkdownProvider>
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Link,
Image,
Minus,
HelpCircle,
Eye,
EyeOff,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
function MarkdownHelpPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<HelpCircle className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<div className="space-y-2">
<h4 className="font-medium text-sm">Markdown Quick Reference</h4>
<div className="text-xs space-y-1.5 font-mono text-muted-foreground">
<div className="flex justify-between"><span>**bold**</span><span className="font-sans font-bold">bold</span></div>
<div className="flex justify-between"><span>*italic*</span><span className="font-sans italic">italic</span></div>
<div className="flex justify-between"><span># Heading 1</span><span className="font-sans">H1</span></div>
<div className="flex justify-between"><span>## Heading 2</span><span className="font-sans">H2</span></div>
<div className="flex justify-between"><span>- list item</span><span className="font-sans">* item</span></div>
<div className="flex justify-between"><span>1. numbered</span><span className="font-sans">1. item</span></div>
<div className="flex justify-between"><span>[text](url)</span><span className="font-sans text-primary">link</span></div>
<div className="flex justify-between"><span>![alt](url)</span><span className="font-sans">image</span></div>
<div className="flex justify-between"><span>&gt; quote</span><span className="font-sans border-l-2 pl-1">quote</span></div>
<div className="flex justify-between"><span>`code`</span><span className="font-sans bg-muted px-1 rounded">code</span></div>
</div>
<p className="text-xs text-muted-foreground pt-2 border-t">
Drag & drop or paste images to upload
</p>
</div>
</PopoverContent>
</Popover>
);
}
const hasPointerFine = typeof window !== 'undefined'
&& window.matchMedia('(pointer: fine)').matches;
interface ToolbarButtonProps {
icon: React.ReactNode;
label: string;
shortcut?: string;
onClick: () => void;
active?: boolean;
}
function ToolbarButton({ icon, label, shortcut, onClick, active }: ToolbarButtonProps) {
const button = (
<Button
variant="ghost"
size="icon"
onClick={onClick}
aria-label={label}
className={cn(
"h-8 w-8 text-muted-foreground hover:text-foreground",
active && "bg-muted text-foreground"
)}
>
{icon}
</Button>
);
if (!hasPointerFine) return button;
return (
<Tooltip>
<TooltipTrigger asChild>
{button}
</TooltipTrigger>
<TooltipContent>
<span>{label}</span>
{shortcut && <span className="ml-2 text-muted-foreground text-xs">{shortcut}</span>}
</TooltipContent>
</Tooltip>
);
}
interface MilkdownToolbarProps {
onCommand: (command: string) => void;
onImageUpload?: () => void;
sourceMode?: boolean;
onToggleSource?: () => void;
className?: string;
}
export function MilkdownToolbar({ onCommand, onImageUpload, sourceMode, onToggleSource, className }: MilkdownToolbarProps) {
return (
<div className={cn(
"flex items-center gap-0.5 p-1.5 border-b border-border bg-card/95 backdrop-blur-sm flex-wrap sticky top-0 z-10 rounded-t-xl",
className
)}>
{!sourceMode && (
<>
{/* Text formatting */}
<ToolbarButton
icon={<Bold className="h-4 w-4" />}
label="Bold"
shortcut="Ctrl+B"
onClick={() => onCommand('toggleBold')}
/>
<ToolbarButton
icon={<Italic className="h-4 w-4" />}
label="Italic"
shortcut="Ctrl+I"
onClick={() => onCommand('toggleItalic')}
/>
<ToolbarButton
icon={<Strikethrough className="h-4 w-4" />}
label="Strikethrough"
onClick={() => onCommand('toggleStrikethrough')}
/>
<ToolbarButton
icon={<Code className="h-4 w-4" />}
label="Inline Code"
onClick={() => onCommand('toggleInlineCode')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Headings */}
<ToolbarButton
icon={<Heading1 className="h-4 w-4" />}
label="Heading 1"
onClick={() => onCommand('heading1')}
/>
<ToolbarButton
icon={<Heading2 className="h-4 w-4" />}
label="Heading 2"
onClick={() => onCommand('heading2')}
/>
<ToolbarButton
icon={<Heading3 className="h-4 w-4" />}
label="Heading 3"
onClick={() => onCommand('heading3')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Lists */}
<ToolbarButton
icon={<List className="h-4 w-4" />}
label="Bullet List"
onClick={() => onCommand('bulletList')}
/>
<ToolbarButton
icon={<ListOrdered className="h-4 w-4" />}
label="Numbered List"
onClick={() => onCommand('orderedList')}
/>
<ToolbarButton
icon={<Quote className="h-4 w-4" />}
label="Blockquote"
onClick={() => onCommand('blockquote')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Links and media */}
<ToolbarButton
icon={<Link className="h-4 w-4" />}
label="Insert Link"
onClick={() => onCommand('link')}
/>
{onImageUpload && (
<ToolbarButton
icon={<Image className="h-4 w-4" />}
label="Insert Image"
onClick={onImageUpload}
/>
)}
<ToolbarButton
icon={<Minus className="h-4 w-4" />}
label="Horizontal Rule"
onClick={() => onCommand('hr')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
<MarkdownHelpPopover />
</>
)}
{sourceMode && (
<>
<span className="text-xs text-muted-foreground px-1.5">Markdown Source</span>
<span className="flex-1" />
</>
)}
{onToggleSource && (
<ToolbarButton
icon={sourceMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
label={sourceMode ? 'Rich text editor' : 'Markdown source'}
active={sourceMode}
onClick={onToggleSource}
/>
)}
</div>
);
}
+2
View File
@@ -148,6 +148,8 @@ export interface FeedSettings {
feedIncludeProfileBadges: boolean;
/** Include Request to Vanish events (kind 62) in the follows/global feed */
feedIncludeVanish: boolean;
/** Include Blobbi pet updates (kind 31124) in the follows/global feed */
feedIncludeBlobbi: boolean;
/** Include replies in the follows feed (default: true) */
followsFeedShowReplies: boolean;
}
+47 -10
View File
@@ -34,14 +34,16 @@ export function useCustomEmojis() {
if (listEvents.length === 0) return [];
const listEvent = listEvents[0];
const emojis: CustomEmoji[] = [];
const seen = new Set<string>();
// Step 2: Extract inline emoji tags
// Collect all emojis with their source pack identifier so we can
// detect shortcode collisions across packs and prefix them.
interface RawEmoji { shortcode: string; url: string; packId: string }
const raw: RawEmoji[] = [];
// Step 2: Extract inline emoji tags (no pack, so packId is empty)
for (const tag of listEvent.tags) {
if (tag[0] === 'emoji' && tag[1] && tag[2] && !seen.has(tag[1])) {
seen.add(tag[1]);
emojis.push({ shortcode: tag[1], url: tag[2] });
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
raw.push({ shortcode: tag[1], url: tag[2], packId: '' });
}
}
@@ -62,7 +64,6 @@ export function useCustomEmojis() {
}
if (packRefs.length > 0) {
// Build filters for all referenced packs
const filters = packRefs.map((ref) => ({
kinds: [30030 as number],
authors: [ref.pubkey],
@@ -74,10 +75,10 @@ export function useCustomEmojis() {
const packEvents = await nostr.query(filters, { signal });
for (const packEvent of packEvents) {
const packId = packEvent.tags.find(([n]) => n === 'd')?.[1] ?? '';
for (const tag of packEvent.tags) {
if (tag[0] === 'emoji' && tag[1] && tag[2] && !seen.has(tag[1])) {
seen.add(tag[1]);
emojis.push({ shortcode: tag[1], url: tag[2] });
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
raw.push({ shortcode: tag[1], url: tag[2], packId });
}
}
}
@@ -86,6 +87,42 @@ export function useCustomEmojis() {
}
}
// Step 4: Detect collisions and prefix with pack identifier.
// First pass: find shortcodes that appear with different URLs.
const byShortcode = new Map<string, RawEmoji[]>();
for (const entry of raw) {
const group = byShortcode.get(entry.shortcode);
if (group) {
group.push(entry);
} else {
byShortcode.set(entry.shortcode, [entry]);
}
}
const collisions = new Set<string>();
for (const [shortcode, group] of byShortcode) {
const uniqueUrls = new Set(group.map((e) => e.url));
if (uniqueUrls.size > 1) {
collisions.add(shortcode);
}
}
// Second pass: build final list. For collisions, prefix with packId.
// Deduplicate by final shortcode (first-seen wins after prefixing).
const emojis: CustomEmoji[] = [];
const seen = new Set<string>();
for (const entry of raw) {
const finalShortcode = collisions.has(entry.shortcode) && entry.packId
? `${entry.packId}-${entry.shortcode}`
: entry.shortcode;
if (!seen.has(finalShortcode)) {
seen.add(finalShortcode);
emojis.push({ shortcode: finalShortcode, url: entry.url });
}
}
return emojis;
},
enabled: !!user,
+152
View File
@@ -0,0 +1,152 @@
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { type ArticleFields } from '@/lib/articleHelpers';
/** Kind 31234 — NIP-37 Draft Wrap. */
const DRAFT_WRAP_KIND = 31234;
/** The inner draft kind we're wrapping. */
const ARTICLE_KIND = 30023;
export interface Draft extends ArticleFields {
id: string;
updatedAt: number;
eventId?: string;
}
type DraftData = ArticleFields;
/** Build an unsigned kind-30023 event object from draft data. */
function buildInnerDraftEvent(draft: DraftData): Record<string, unknown> {
const tags: string[][] = [
['d', draft.slug],
['title', draft.title],
];
if (draft.summary) tags.push(['summary', draft.summary]);
if (draft.image) tags.push(['image', draft.image]);
draft.tags.forEach(tag => tags.push(['t', tag]));
return {
kind: ARTICLE_KIND,
content: draft.content,
tags,
};
}
/** Parse a decrypted inner draft event back into a Draft. */
function parseDraftPayload(inner: Record<string, unknown>, wrapEvent: NostrEvent): Draft | null {
const tags = (inner.tags ?? []) as string[][];
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
return {
id: wrapEvent.id,
eventId: wrapEvent.id,
title: getTag('title'),
summary: getTag('summary'),
content: (inner.content as string) || '',
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
updatedAt: wrapEvent.created_at * 1000,
};
}
export function useDrafts() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
// Query and decrypt drafts from relay
const query = useQuery<Draft[]>({
queryKey: ['drafts', user?.pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!user?.pubkey || !user.signer.nip44) return [];
const events = await nostr.query(
[{ kinds: [DRAFT_WRAP_KIND], authors: [user.pubkey], '#k': [String(ARTICLE_KIND)], limit: 100 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
const drafts: Draft[] = [];
for (const event of events) {
// Blank content means deleted
if (!event.content.trim()) continue;
try {
const decrypted = await user.signer.nip44.decrypt(user.pubkey, event.content);
const inner = JSON.parse(decrypted) as Record<string, unknown>;
const draft = parseDraftPayload(inner, event);
if (draft && draft.content.trim()) drafts.push(draft);
} catch {
// Skip events that fail to decrypt or parse
continue;
}
}
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
},
enabled: !!user?.pubkey && !!user?.signer.nip44,
staleTime: 30 * 1000,
});
// Save draft: encrypt inner event and publish as kind 31234
const saveMutation = useMutation({
mutationFn: async (draft: DraftData) => {
if (!user?.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
const inner = buildInnerDraftEvent(draft);
const plaintext = JSON.stringify(inner);
const encrypted = await user.signer.nip44.encrypt(user.pubkey, plaintext);
return publishEvent({
kind: DRAFT_WRAP_KIND,
content: encrypted,
tags: [
['d', draft.slug],
['k', String(ARTICLE_KIND)],
],
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['drafts', user?.pubkey] });
},
});
// Delete draft (publish kind 5 deletion event)
const deleteMutation = useMutation({
mutationFn: async (slug: string) => {
if (!user) throw new Error('User is not logged in');
const event = await publishEvent({
kind: 5,
content: '',
tags: [['a', `${DRAFT_WRAP_KIND}:${user.pubkey}:${slug}`]],
});
return { event, slug };
},
onSuccess: (data) => {
queryClient.setQueryData(['drafts', user?.pubkey], (oldData: Draft[] | undefined) => {
if (!oldData) return [];
return oldData.filter(d => d.slug !== data?.slug);
});
},
});
return {
drafts: query.data || [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
saveDraft: saveMutation.mutateAsync,
isSaving: saveMutation.isPending,
deleteDraft: deleteMutation.mutateAsync,
isDeleting: deleteMutation.isPending,
};
}
+56
View File
@@ -0,0 +1,56 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { parseArticleEvent, type ArticleFields } from '@/lib/articleHelpers';
export interface PublishedArticle extends ArticleFields {
id: string;
eventId: string;
publishedAt: number;
updatedAt: number;
}
function eventToArticle(event: NostrEvent): PublishedArticle {
const parsed = parseArticleEvent(event);
return {
...parsed,
id: event.id,
eventId: event.id,
updatedAt: event.created_at * 1000,
};
}
export function usePublishedArticles() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const query = useQuery<PublishedArticle[]>({
queryKey: ['published-articles', user?.pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!user?.pubkey) {
return [];
}
const events = await nostr.query(
[{ kinds: [30023], authors: [user.pubkey], limit: 100 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
return events
.filter(e => e.content.trim().length > 0)
.map(eventToArticle)
.sort((a, b) => b.publishedAt - a.publishedAt);
},
enabled: !!user?.pubkey,
staleTime: 30 * 1000,
});
return {
articles: query.data || [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
+158
View File
@@ -494,3 +494,161 @@
}
}
/* ── Milkdown Editor Styles ─────────────────────────────────────────────────── */
.milkdown-editor .milkdown {
@apply outline-none;
}
.milkdown-editor .editor {
@apply outline-none min-h-[400px] p-3;
font-size: 1.125rem;
line-height: 1.75;
}
.milkdown-editor .ProseMirror {
@apply outline-none min-h-[400px];
}
.milkdown-editor .ProseMirror:focus {
@apply outline-none;
}
/* Headings */
.milkdown-editor h1 {
@apply text-3xl font-bold mt-6 mb-4;
}
.milkdown-editor h2 {
@apply text-2xl font-semibold mt-5 mb-3;
}
.milkdown-editor h3 {
@apply text-xl font-semibold mt-4 mb-2;
}
.milkdown-editor h4 {
@apply text-lg font-medium mt-3 mb-2;
}
/* Inline styles */
.milkdown-editor strong {
@apply font-bold;
}
.milkdown-editor em {
@apply italic;
}
.milkdown-editor del {
@apply line-through text-muted-foreground;
}
.milkdown-editor code {
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono;
}
/* Block elements */
.milkdown-editor p {
@apply my-1.5;
}
.milkdown-editor blockquote {
@apply border-l-4 border-primary/40 pl-4 my-4 italic text-muted-foreground;
}
.milkdown-editor pre {
@apply bg-muted rounded-lg p-4 my-4 overflow-x-auto;
}
.milkdown-editor pre code {
@apply bg-transparent p-0 font-mono;
font-size: 0.875rem;
}
/* Lists */
.milkdown-editor ul {
@apply list-disc list-inside my-3 space-y-1;
}
.milkdown-editor ol {
@apply list-decimal list-inside my-3 space-y-1;
}
.milkdown-editor li {
@apply pl-1;
}
.milkdown-editor li p {
@apply inline my-0;
}
/* Task lists (GFM) */
.milkdown-editor ul.task-list {
@apply list-none pl-0;
}
.milkdown-editor li.task-list-item {
@apply flex items-start gap-2 pl-0;
}
.milkdown-editor li.task-list-item input[type="checkbox"] {
@apply mt-1.5 h-4 w-4 rounded border-border;
}
/* Links */
.milkdown-editor a {
@apply text-primary underline underline-offset-2 hover:text-primary/80 transition-colors;
}
/* Horizontal rule */
.milkdown-editor hr {
@apply my-6 border-border;
}
/* Images */
.milkdown-editor img {
@apply max-w-full h-auto rounded-lg my-4;
}
/* Tables (GFM) */
.milkdown-editor table {
@apply w-full border-collapse my-4;
}
.milkdown-editor th,
.milkdown-editor td {
@apply border border-border px-3 py-2 text-left;
}
.milkdown-editor th {
@apply bg-muted font-semibold;
}
.milkdown-editor tr:nth-child(even) {
@apply bg-muted/30;
}
/* Upload placeholder */
.milkdown-upload-placeholder {
@apply inline-flex items-center gap-2 px-3 py-1.5 bg-muted/50 rounded-md text-sm text-muted-foreground;
}
.milkdown-upload-placeholder::before {
content: '';
@apply w-4 h-4 border-2 border-muted-foreground/30 border-t-primary rounded-full animate-spin;
}
/* Placeholder — only when unfocused AND content is empty. */
.milkdown-editor .ProseMirror:not(:focus):not(.has-content) > p:first-child::before {
@apply text-muted-foreground pointer-events-none float-left h-0;
content: var(--ph, '');
}
/* Milkdown content area */
.milkdown-editor .milkdown-content .ProseMirror {
@apply min-h-[350px];
}
+33
View File
@@ -0,0 +1,33 @@
import type { NostrEvent } from '@nostrify/nostrify';
/** Fields shared by drafts and published articles. */
export interface ArticleFields {
title: string;
summary: string;
content: string;
image: string;
tags: string[];
slug: string;
}
/**
* Extract common article fields from a Nostr event's tags + content.
* Works for kind 30023 (published) events and the inner event of NIP-37 draft wraps.
*/
export function parseArticleEvent(event: NostrEvent): ArticleFields & { publishedAt: number } {
const getTag = (name: string) => event.tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => event.tags.filter(t => t[0] === name).map(t => t[1]);
const publishedAtTag = getTag('published_at');
const publishedAt = publishedAtTag ? parseInt(publishedAtTag) * 1000 : event.created_at * 1000;
return {
title: getTag('title'),
summary: getTag('summary'),
content: event.content,
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
publishedAt,
};
}
+1 -14
View File
@@ -34,7 +34,7 @@ export type TagCategory =
| 'state' // Lifecycle state (stage, state, timestamps)
| 'progression' // Progress tracking (experience, care_streak)
| 'task' // Task system (task, task_completed, state_started_at)
| 'social' // Social flags (visible_to_others, breeding_ready)
| 'social' // Social flags (breeding_ready)
| 'evolution' // Evolution-specific (adult_type)
| 'extension'; // Extension tags (theme, crossover_app)
@@ -509,19 +509,6 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [
// ═══════════════════════════════════════════════════════════════════════════
// SOCIAL / FLAG TAGS
// ═══════════════════════════════════════════════════════════════════════════
{
tag: 'visible_to_others',
description: 'Whether the Blobbi is publicly visible to other users',
category: 'social',
required: false,
stages: ['egg', 'baby', 'adult'],
persistent: true,
source: 'user',
regenerable: false,
format: 'true | false',
defaultValue: 'true',
notes: 'User preference. Persists across stages.',
},
{
tag: 'breeding_ready',
description: 'Whether the Blobbi is eligible for breeding',
+2 -6
View File
@@ -253,8 +253,6 @@ export interface BlobbiCompanion {
lastDecayAt: number | undefined;
/** Stats (0-100) */
stats: Partial<BlobbiStats>;
/** Whether the Blobbi is publicly visible */
visibleToOthers: boolean;
/** Generation number */
generation: number | undefined;
/** Breeding eligibility */
@@ -939,7 +937,6 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
hygiene: parseNumericTag(tags, 'hygiene'),
energy: parseNumericTag(tags, 'energy'),
},
visibleToOthers: parseBooleanTag(tags, 'visible_to_others', true),
generation: parseNumericTag(tags, 'generation'),
breedingReady: parseBooleanTag(tags, 'breeding_ready', false),
experience: parseNumericTag(tags, 'experience'),
@@ -1036,7 +1033,6 @@ export function buildEggTags(
['stage', 'egg'],
['state', 'active'],
['seed', seed],
['visible_to_others', 'true'],
['generation', '1'],
['breeding_ready', 'false'],
['experience', '0'],
@@ -1084,7 +1080,7 @@ export const MANAGED_BLOBBI_STATE_TAG_NAMES = new Set([
// Progression tags
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
// Social/flag tags
'visible_to_others', 'breeding_ready',
'breeding_ready',
// Task system tags (removed after stage transitions)
'state_started_at', 'task', 'task_completed',
// Evolution tags (adult only)
@@ -1463,7 +1459,7 @@ export function buildMigrationTags(
// Progression tags
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
// Social/flag tags
'visible_to_others', 'generation', 'breeding_ready',
'generation', 'breeding_ready',
// Personality tags (preserve if they exist, do NOT generate)
'personality', 'trait', 'favorite_food', 'voice_type', 'mood',
// Evolution tags
+12 -4
View File
@@ -11,6 +11,14 @@ interface ChangelogEntry {
}[];
}
/** Apply basic typographic transformations to a changelog item string. */
function prettify(text: string): string {
return text
.replace(/ -- /g, ' \u2014 ') // space-dash-dash-space → em dash
.replace(/(\w)--(\w)/g, '$1\u2013$2') // word--word → en dash
.replace(/ (\S+)$/, '\u00A0$1'); // prevent orphaned last word
}
/**
* Parse a Keep a Changelog formatted markdown string into structured data.
* @see https://keepachangelog.com/
@@ -43,10 +51,10 @@ function parseChangelog(markdown: string): ChangelogEntry[] {
if (itemMatch && current) {
const section = current.sections[current.sections.length - 1];
if (section) {
section.items.push(itemMatch[1]);
section.items.push(prettify(itemMatch[1]));
} else {
// Item without a category heading — treat as "Changed"
current.sections.push({ category: 'Changed', items: [itemMatch[1]] });
current.sections.push({ category: 'Changed', items: [prettify(itemMatch[1])] });
}
continue;
}
@@ -58,10 +66,10 @@ function parseChangelog(markdown: string): ChangelogEntry[] {
const section = current.sections[current.sections.length - 1];
if (section) {
// Append to last item or add new item
section.items.push(trimmed);
section.items.push(prettify(trimmed));
} else {
// Freeform text under version with no category — store in a generic section
current.sections.push({ category: 'Changed', items: [trimmed] });
current.sections.push({ category: 'Changed', items: [prettify(trimmed)] });
}
}
}
+15 -3
View File
@@ -128,8 +128,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
route: 'articles',
addressable: true,
section: 'feed',
blurb: 'Blog posts, essays, and guides. Write and publish from a dedicated editor.',
sites: [{ url: 'https://inkwell.shakespeare.wtf' }],
blurb: 'Blog posts, essays, and guides. Write and publish long-form articles.',
},
// Media
{
@@ -461,13 +460,25 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
},
],
},
// Blobbi (feed-only — dedicated page at /blobbi)
{
kind: 31124,
id: 'blobbi',
feedKey: 'feedIncludeBlobbi',
label: 'Blobbi',
description: 'Blobbi virtual pet updates',
addressable: true,
section: 'whimsy',
feedOnly: true,
blurb: 'Virtual pet companions living on Nostr. Care for them, watch them grow, and share their journey.',
},
// Development
{
kind: 30617,
id: 'development',
showKey: 'showDevelopment',
feedKey: 'feedIncludeDevelopment',
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267],
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 31990],
label: 'Development',
description: 'Git repos, patches, PRs, nsites, apps, and custom NIPs',
route: 'development',
@@ -536,6 +547,7 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
30008: 'profile badges',
30817: 'repository issue',
32267: 'app',
31990: 'app',
30063: 'release',
};
+2
View File
@@ -93,6 +93,8 @@ export function shouldHideFeedEvent(event: NostrEvent): boolean {
if (event.kind === 37381 && event.tags.some(([n, v]) => n === 't' && v === 'unlisted')) return true;
// Hidden treasures (kind 37516)
if (event.kind === 37516 && event.tags.some(([n, v]) => n === 't' && v === 'hidden')) return true;
// Emoji packs (kind 30030) without at least one valid emoji tag
if (event.kind === 30030 && !event.tags.some(([n, sc, url]) => n === 'emoji' && sc && url)) return true;
return false;
}
+76
View File
@@ -0,0 +1,76 @@
import type { Draft } from '@/hooks/useDrafts';
const DRAFTS_KEY = 'article-drafts';
/** Save a draft to localStorage. Returns the draft ID or null on failure. */
export function saveDraft(draft: Omit<Draft, 'id' | 'updatedAt'> & { id?: string }): string | null {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
const drafts: Draft[] = stored ? JSON.parse(stored) : [];
const existingIndex = draft.id
? drafts.findIndex(d => d.id === draft.id)
: drafts.findIndex(d => d.slug === draft.slug);
const newDraft: Draft = {
...draft,
id: draft.id || (existingIndex >= 0 ? drafts[existingIndex].id : crypto.randomUUID()),
updatedAt: Date.now(),
};
if (existingIndex >= 0) {
drafts[existingIndex] = newDraft;
} else {
drafts.unshift(newDraft);
}
// Keep only the 20 most recent drafts
const trimmedDrafts = drafts.slice(0, 20);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(trimmedDrafts));
return newDraft.id;
} catch (error) {
console.error('Failed to save draft:', error);
return null;
}
}
/** Delete a draft by slug from localStorage. */
export function deleteDraftBySlug(slug: string): void {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
if (!stored) return;
const drafts: Draft[] = JSON.parse(stored);
const filtered = drafts.filter(d => d.slug !== slug);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
} catch (error) {
console.error('Failed to delete draft:', error);
}
}
/** Delete a draft by id from localStorage. Returns the remaining drafts. */
export function deleteLocalDraftById(id: string): Draft[] {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
if (!stored) return [];
const drafts: Draft[] = JSON.parse(stored);
const filtered = drafts.filter(d => d.id !== id);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
return filtered;
} catch (error) {
console.error('Failed to delete draft:', error);
return [];
}
}
/** Get all local drafts. */
export function getLocalDrafts(): Draft[] {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
+5 -11
View File
@@ -8,11 +8,6 @@ import type { CoreThemeColors, ThemeConfig, ThemesConfig } from '@/themes';
/** Zod schema for Theme validation */
export const ThemeSchema = z.enum(['dark', 'light', 'system', 'custom']) satisfies z.ZodType<Theme>;
/**
* Accepts current theme values as well as legacy values ("black", "pink")
* from older configs. Consumers should migrate legacy values to "custom".
*/
export const ThemeSchemaCompat = z.enum(['dark', 'light', 'system', 'custom', 'black', 'pink']);
/** HSL value string like "258 70% 55%" */
const HslValue = z.string().regex(/^\d/);
@@ -182,6 +177,7 @@ export const FeedSettingsSchema = z.looseObject({
feedIncludePodcastTrailers: z.boolean().optional(),
showDevelopment: z.boolean().optional(),
feedIncludeDevelopment: z.boolean().optional(),
feedIncludeBlobbi: z.boolean().optional(),
});
/** Schema for a NIP-01 filter object (lenient — allows variable placeholder strings). */
@@ -207,10 +203,8 @@ export const SavedFeedSchema = z.object({
/**
* Zod schema for the full AppConfig stored in localStorage.
*
* Uses compat sub-schemas (ThemeSchemaCompat, ThemeConfigCompatSchema) so
* legacy values parse successfully. Migration from legacy theme values
* ("black", "pink") to "custom" + customTheme is handled downstream by
* the AppProvider deserializer.
* Uses ThemeConfigCompatSchema for the customTheme field so legacy
* 19-token color objects still parse successfully.
*/
export const AppConfigSchema = z.object({
appName: z.string().optional(),
@@ -219,7 +213,7 @@ export const AppConfigSchema = z.object({
clientName: z.string().optional(),
client: z.string().optional(),
magicMouse: z.boolean().optional(),
theme: ThemeSchemaCompat,
theme: ThemeSchema,
customTheme: ThemeConfigCompatSchema.optional(),
autoShareTheme: z.boolean(),
themes: ThemesConfigSchema.optional(),
@@ -297,7 +291,7 @@ export const ContentFilterSchema = z.object({
* Uses looseObject to preserve unknown keys from newer app versions.
*/
export const EncryptedSettingsSchema = z.looseObject({
theme: ThemeSchemaCompat.optional(),
theme: ThemeSchema.optional(),
customTheme: ThemeConfigCompatSchema.optional(),
autoShareTheme: z.boolean().optional(),
useAppRelays: z.boolean().optional(),
+2
View File
@@ -26,6 +26,7 @@ import {
Egg,
Repeat2,
Scroll,
ScrollText,
Search,
Settings,
Smile,
@@ -133,6 +134,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
requiresAuth: true,
},
{ id: "settings", label: "Settings", path: "/settings", icon: Settings },
{ id: "changelog", label: "Changelog", path: "/changelog", icon: ScrollText },
{
id: "letters",
label: "Letters",
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useState } from 'react';
import { useSearchParams, useParams } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { nip19 } from 'nostr-tools';
import type { AddressPointer } from 'nostr-tools/nip19';
import { Loader2 } from 'lucide-react';
import { ArticleEditor, type ArticleData } from '@/components/articles/ArticleEditor';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { getLocalDrafts } from '@/lib/localDrafts';
import { parseArticleEvent } from '@/lib/articleHelpers';
/** Thin page wrapper for /articles/new and /articles/edit/:naddr */
export function ArticleEditorPage() {
useLayoutOptions({ showFAB: false, hasSubHeader: true });
const [searchParams] = useSearchParams();
const { naddr: naddrParam } = useParams<{ naddr: string }>();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const draftSlug = searchParams.get('draft');
const [initialData, setInitialData] = useState<(Partial<ArticleData> & { publishedAt?: number }) | undefined>(undefined);
const [editMode, setEditMode] = useState(false);
const [loading, setLoading] = useState(!!naddrParam || !!draftSlug);
// Load draft from relay (NIP-37 kind 31234, encrypted) or localStorage if ?draft=<slug>
useEffect(() => {
if (!draftSlug) return;
const loadDraft = async () => {
if (user?.signer.nip44) {
try {
const events = await nostr.query([
{ kinds: [31234], authors: [user.pubkey], '#d': [draftSlug], limit: 1 },
]);
if (events.length > 0 && events[0].content.trim()) {
const decrypted = await user.signer.nip44.decrypt(user.pubkey, events[0].content);
const inner = JSON.parse(decrypted) as Record<string, unknown>;
const tags = (inner.tags ?? []) as string[][];
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
setInitialData({
title: getTag('title'),
summary: getTag('summary'),
content: (inner.content as string) || '',
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
});
setLoading(false);
return;
}
} catch {
// Fall through to localStorage
}
}
// Fallback to localStorage
const drafts = getLocalDrafts();
const draft = drafts.find((d) => d.slug === draftSlug);
if (draft) {
setInitialData({
title: draft.title,
summary: draft.summary,
content: draft.content,
image: draft.image,
tags: draft.tags,
slug: draft.slug,
});
}
setLoading(false);
};
loadDraft();
}, [draftSlug, user, nostr]);
// Load existing article for editing if /articles/edit/:naddr
useEffect(() => {
if (!naddrParam) return;
let decoded: { type: string; data: AddressPointer };
try {
decoded = nip19.decode(naddrParam) as { type: 'naddr'; data: AddressPointer };
if (decoded.type !== 'naddr') {
setLoading(false);
return;
}
} catch {
setLoading(false);
return;
}
const addr = decoded.data;
// Only allow editing your own articles
if (user && addr.pubkey !== user.pubkey) {
setLoading(false);
return;
}
nostr
.query([
{
kinds: [addr.kind],
authors: [addr.pubkey],
'#d': [addr.identifier],
limit: 1,
},
])
.then((events) => {
if (events.length > 0) {
setInitialData(parseArticleEvent(events[0]));
setEditMode(true);
}
})
.catch((err) => {
console.error('Failed to load article for editing:', err);
})
.finally(() => {
setLoading(false);
});
}, [naddrParam, nostr, user]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return <ArticleEditor initialData={initialData} editMode={editMode} />;
}
+143 -382
View File
@@ -1,8 +1,8 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { Egg, Moon, Sun, Eye, EyeOff, Loader2, RefreshCw, Check, Info, Target, ShoppingBag, Package, Sparkles, HeartHandshake, Plus, Camera, ArrowLeft, AlertTriangle, X, Footprints, Wrench, Theater } from 'lucide-react';
// TODO: Re-import when features are implemented: Footprints, PictureInPicture2
// Note: Eye/EyeOff kept for BlobbiSelectorCard visibility badge display
import { nip19 } from 'nostr-tools';
import { Egg, Moon, Sun, Loader2, RefreshCw, Check, Target, Package, Sparkles, HeartHandshake, Plus, Camera, ArrowLeft, AlertTriangle, X, Footprints, Wrench, Theater, MoreHorizontal, ExternalLink } from 'lucide-react';
// Note: Sparkles kept for BlobbiBottomBar center action button
// Note: Plus kept for AdoptAnotherBlobbiCard
// Note: AlertTriangle kept for stat warning indicators
@@ -25,7 +25,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { BlobbiPhotoModal } from '@/blobbi/ui/BlobbiPhotoModal';
import { useBlobbiCompanionData } from '@/blobbi/companion/hooks/useBlobbiCompanionData';
@@ -43,7 +43,7 @@ import {
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { BlobbiShopModal } from '@/blobbi/shop/components/BlobbiShopModal';
import { BlobbiInventoryModal } from '@/blobbi/shop/components/BlobbiInventoryModal';
import {
BlobbiActionsModal,
BlobbiActionInventoryModal,
@@ -842,6 +842,13 @@ function BlobbiDashboard({
const isSleeping = companion.state === 'sleeping';
const isEgg = companion.stage === 'egg';
// Build naddr for linking to the Blobbi's detail page
const blobbiNaddr = useMemo(() => nip19.naddrEncode({
kind: KIND_BLOBBI_STATE,
pubkey: companion.event.pubkey,
identifier: companion.d,
}), [companion.event.pubkey, companion.d]);
// Derive available stages from all companions (for daily mission filtering)
const availableStages = useMemo(() => {
const stages = new Set<'egg' | 'baby' | 'adult'>();
@@ -863,8 +870,6 @@ function BlobbiDashboard({
const [showActionsModal, setShowActionsModal] = useState(false);
const [showMissionsModal, setShowMissionsModal] = useState(false);
const [showShopModal, setShowShopModal] = useState(false);
const [showInventoryModal, setShowInventoryModal] = useState(false);
const [showInfoModal, setShowInfoModal] = useState(false);
const [showPhotoModal, setShowPhotoModal] = useState(false);
// DEV ONLY: Emotion panel state
@@ -1319,8 +1324,8 @@ function BlobbiDashboard({
setActionOverrideEmotion(getActionEmotion(action as ActionType));
try {
await onUseItem(itemId, action, quantity);
// Close the inventory modal on success
setShowInventoryModal(false);
// Close the shop modal on success (inventory is a tab within it)
setShowShopModal(false);
} finally {
setUsingItemId(null);
// Clear action emotion after a brief delay for visual feedback
@@ -1344,40 +1349,7 @@ function BlobbiDashboard({
{/* Hero Section */}
<div className="flex-1 flex flex-col items-center justify-center px-4 py-4 sm:px-6">
{/* Floating Dashboard Controls */}
<BlobbiDashboardFloatingControls
stage={companion.stage}
onSetAsCompanion={handleSetAsCompanion}
isCurrentCompanion={isCurrentCompanion}
isUpdatingCompanion={isUpdatingCompanion}
onTakePhoto={() => setShowPhotoModal(true)}
onOpenPiP={() => console.log('TODO: open PiP')}
onEvolve={
// For eggs not yet incubating: show incubation dialog
// For eggs incubating with all tasks complete: hatch action handled in HatchTasksPanel
// For baby not yet evolving: show evolution dialog
// For baby evolving with all tasks complete: evolve action handled in MissionsModal
canStartIncubation
? () => setShowIncubationDialog(true)
: canStartEvolution
? () => setShowEvolutionDialog(true)
: isEgg
? onHatch
: onEvolve
}
isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution}
onInfo={() => setShowInfoModal(true)}
// Hide button when actively incubating or evolving (actions are in MissionsModal instead)
hideEvolveButton={isIncubating || isEvolvingState}
// When canStartIncubation or canStartEvolution is true, the button triggers the respective dialog
isIncubationAction={canStartIncubation}
isEvolutionAction={canStartEvolution}
// DEV ONLY: Instant stage transition (bypasses tasks)
onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined}
// DEV ONLY: Open state editor modal
onDevOpenEditor={() => setShowDevEditor(true)}
// DEV ONLY: Open emotion tester panel
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
/>
<BlobbiDashboardFloatingControls />
{/* Blobbi Name */}
<div className="flex items-center gap-2 mb-6">
@@ -1485,11 +1457,32 @@ function BlobbiDashboard({
onMissionsClick={() => setShowMissionsModal(true)}
onActionsClick={() => setShowActionsModal(true)}
onShopClick={() => setShowShopModal(true)}
onInventoryClick={() => setShowInventoryModal(true)}
needyBlobbiesCount={companions.filter(companionNeedsCare).length}
isInTaskProcess={isInTaskProcess}
remainingTasksCount={remainingTasksCount}
allTasksComplete={allTasksComplete}
stage={companion.stage}
blobbiNaddr={blobbiNaddr}
onSetAsCompanion={handleSetAsCompanion}
isCurrentCompanion={isCurrentCompanion}
isUpdatingCompanion={isUpdatingCompanion}
onTakePhoto={() => setShowPhotoModal(true)}
onEvolve={
canStartIncubation
? () => setShowIncubationDialog(true)
: canStartEvolution
? () => setShowEvolutionDialog(true)
: isEgg
? onHatch
: onEvolve
}
isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution}
hideEvolveButton={isIncubating || isEvolvingState}
isIncubationAction={canStartIncubation}
isEvolutionAction={canStartEvolution}
onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined}
onDevOpenEditor={() => setShowDevEditor(true)}
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
/>
{/* Blobbi Selector Modal */}
@@ -1605,30 +1598,16 @@ function BlobbiDashboard({
availableStages={availableStages}
/>
{/* Shop Modal */}
{/* Shop & Inventory Modal (unified) */}
<BlobbiShopModal
open={showShopModal}
onOpenChange={setShowShopModal}
profile={profile}
/>
{/* Inventory Modal */}
<BlobbiInventoryModal
open={showInventoryModal}
onOpenChange={setShowInventoryModal}
profile={profile}
companion={companion}
onUseItem={handleUseItemFromInventory}
isUsingItem={isUsingItem}
/>
{/* Blobbi Info Modal */}
<BlobbiInfoModal
open={showInfoModal}
onOpenChange={setShowInfoModal}
companion={companion}
/>
{/* Blobbi Post Modal - for hatch or evolve task */}
<BlobbiPostModal
open={showPostModal}
@@ -1719,58 +1698,6 @@ function QuickActionButton({ children, tooltip, onClick, disabled, loading }: Qu
// ─── Dashboard Floating Controls ──────────────────────────────────────────────
/** Button definition for floating action buttons */
interface FloatingActionDef {
id: string;
icon: React.ReactNode;
tooltip: string;
onClick: () => void;
variant?: 'default' | 'accent';
disabled?: boolean;
}
interface BlobbiDashboardFloatingControlsProps {
stage: 'egg' | 'baby' | 'adult';
onBack?: () => void;
onSetAsCompanion: () => void;
/** Whether this Blobbi is currently set as the user's companion */
isCurrentCompanion: boolean;
/** Whether the companion update is in progress */
isUpdatingCompanion?: boolean;
onTakePhoto: () => void;
onOpenPiP: () => void;
onEvolve: () => void;
/** Whether a stage transition is in progress (hatch or evolve) */
isTransitioning?: boolean;
onInfo: () => void;
/** Whether to hide the evolve/hatch button (e.g., when incubating or evolving) */
hideEvolveButton?: boolean;
/** Whether the button should show incubation action (for eggs not yet incubating) */
isIncubationAction?: boolean;
/** Whether the button should show evolution action (for babies not yet evolving) */
isEvolutionAction?: boolean;
/** DEV ONLY: Instant stage transition callback (bypasses tasks) */
onDevInstantTransition?: () => void;
/** DEV ONLY: Open state editor callback */
onDevOpenEditor?: () => void;
/** DEV ONLY: Open emotion tester callback */
onDevOpenEmotionPanel?: () => void;
}
/**
* Get the appropriate icon for the evolve/hatch button based on stage and incubation state.
* - egg stage (not incubating): Egg icon (start incubation action)
* - egg stage (incubating): Egg icon (hatching action)
* - baby/adult stages: Sparkles icon (evolution/transformation)
*/
function getEvolveIcon(stage: 'egg' | 'baby' | 'adult', _isIncubationAction?: boolean): React.ReactNode {
if (stage === 'egg') {
return <Egg className="size-4" />;
}
// Sparkles communicates magical transformation, fitting the Blobbi theme
return <Sparkles className="size-4" />;
}
/**
* Get the appropriate tooltip for the evolve/hatch button based on stage and action state.
*/
@@ -1788,183 +1715,15 @@ function getEvolveTooltip(
return 'Evolve';
}
/**
* Floating action controls for the Blobbi dashboard.
* Renders top-left and top-right button clusters.
*/
function BlobbiDashboardFloatingControls({
stage,
onBack,
onSetAsCompanion,
isCurrentCompanion,
isUpdatingCompanion = false,
onTakePhoto,
onOpenPiP: _onOpenPiP, // TODO: Re-enable when PiP feature is implemented
onEvolve,
isTransitioning = false,
onInfo,
hideEvolveButton = false,
isIncubationAction = false,
isEvolutionAction = false,
onDevInstantTransition,
onDevOpenEditor,
onDevOpenEmotionPanel,
}: BlobbiDashboardFloatingControlsProps) {
// Left-side buttons
const leftButtons: FloatingActionDef[] = [
...(onBack ? [{
id: 'back',
icon: <ArrowLeft className="size-4" />,
tooltip: 'Go Back',
onClick: onBack,
}] : []),
];
// Check if this Blobbi can be set as companion (eggs cannot)
const canBeCompanion = stage !== 'egg';
// Right-side buttons (top cluster)
const rightButtons: FloatingActionDef[] = [
// Only show "Set as companion" for baby/adult (eggs cannot be companions)
...(canBeCompanion ? [{
id: 'set-companion',
icon: <Footprints className={cn('size-4', isCurrentCompanion && 'text-green-500')} />,
tooltip: isCurrentCompanion
? 'Current Companion'
: 'Set as Companion',
onClick: onSetAsCompanion,
disabled: isUpdatingCompanion,
}] : []),
{
id: 'photo',
icon: <Camera className="size-4" />,
tooltip: 'Take a Photo',
onClick: onTakePhoto,
},
// TODO: Re-enable when PiP feature is implemented
// {
// id: 'pip',
// icon: <PictureInPicture2 className="size-4" />,
// tooltip: 'Open PiP',
// onClick: onOpenPiP,
// },
{
id: 'info',
icon: <Info className="size-4" />,
tooltip: 'Blobbi Info',
onClick: onInfo,
},
];
// Evolve/Hatch/Incubation button (emphasized, at the bottom of right cluster)
// Icon and tooltip are stage-aware and action-state-aware
const evolveButton: FloatingActionDef = {
id: 'evolve',
icon: getEvolveIcon(stage, isIncubationAction),
tooltip: getEvolveTooltip(stage, isIncubationAction, isEvolutionAction),
onClick: onEvolve,
variant: 'accent',
};
/** Floating back button for the Blobbi dashboard. */
function BlobbiDashboardFloatingControls({ onBack }: { onBack?: () => void }) {
if (!onBack) return null;
return (
<>
{/* Left-side floating buttons */}
{leftButtons.length > 0 && (
<div className="absolute top-28 sm:top-32 left-4 sm:left-6 flex flex-col gap-2 z-20">
{leftButtons.map((btn) => (
<QuickActionButton
key={btn.id}
tooltip={btn.tooltip}
onClick={btn.onClick}
>
{btn.icon}
</QuickActionButton>
))}
</div>
)}
{/* Right-side floating buttons */}
<div className="absolute top-28 sm:top-32 right-4 sm:right-6 flex flex-col gap-2 z-20">
{rightButtons.map((btn) => (
<QuickActionButton
key={btn.id}
tooltip={btn.tooltip}
onClick={btn.onClick}
disabled={btn.disabled}
>
{btn.icon}
</QuickActionButton>
))}
{/* Evolve/Hatch button with accent styling */}
{/* Adults can't evolve further, so hide the button */}
{/* Also hide when explicitly requested (e.g., during incubation) */}
{stage !== 'adult' && !hideEvolveButton && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={evolveButton.onClick}
disabled={isTransitioning}
className="size-10 rounded-full bg-primary/10 backdrop-blur-sm border-primary/30 hover:bg-primary/20 hover:border-primary/50 transition-all shadow-sm text-primary disabled:opacity-50"
>
{isTransitioning ? (
<Loader2 className="size-4 animate-spin" />
) : (
evolveButton.icon
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>{isTransitioning ? 'Transitioning...' : evolveButton.tooltip}</p>
</TooltipContent>
</Tooltip>
)}
{/* DEV ONLY: Developer tools dropdown */}
{isLocalhostDev() && (onDevInstantTransition || onDevOpenEditor || onDevOpenEmotionPanel) && (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-10 rounded-full bg-amber-500/10 backdrop-blur-sm border-dashed border-amber-500/50 hover:bg-amber-500/20 hover:border-amber-500/70 transition-all shadow-sm text-amber-600 dark:text-amber-400"
>
<Wrench className="size-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="left">
<p className="text-amber-600 dark:text-amber-400">Dev Tools</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="left" align="start">
{stage !== 'adult' && onDevInstantTransition && (
<DropdownMenuItem onClick={onDevInstantTransition} disabled={isTransitioning}>
{stage === 'egg' ? <Egg className="size-4 mr-2" /> : <Sparkles className="size-4 mr-2" />}
{stage === 'egg' ? 'Dev Hatch' : 'Dev Evolve'}
</DropdownMenuItem>
)}
{onDevOpenEditor && (
<DropdownMenuItem onClick={onDevOpenEditor}>
<Wrench className="size-4 mr-2" />
State Editor
</DropdownMenuItem>
)}
{onDevOpenEmotionPanel && (
<DropdownMenuItem onClick={onDevOpenEmotionPanel}>
<Theater className="size-4 mr-2" />
Emotion Tester
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</>
<div className="absolute top-28 sm:top-32 left-4 sm:left-6 flex flex-col gap-2 z-20">
<QuickActionButton tooltip="Go Back" onClick={onBack}>
<ArrowLeft className="size-4" />
</QuickActionButton>
</div>
);
}
@@ -2191,19 +1950,6 @@ function BlobbiSelectorCard({ companion, onSelect, isSelected, isCurrentCompanio
</>
)}
</Badge>
<Badge variant="outline" className="text-xs">
{companion.visibleToOthers ? (
<>
<Eye className="size-3 mr-1" />
Visible
</>
) : (
<>
<EyeOff className="size-3 mr-1" />
Hidden
</>
)}
</Badge>
</div>
</div>
</div>
@@ -2299,7 +2045,6 @@ interface BlobbiBottomBarProps {
onMissionsClick: () => void;
onActionsClick: () => void;
onShopClick: () => void;
onInventoryClick: () => void;
/** Number of Blobbies that need care (any stat below threshold) */
needyBlobbiesCount?: number;
/** Whether the current Blobbi is in an active task process (incubating or evolving) */
@@ -2308,6 +2053,22 @@ interface BlobbiBottomBarProps {
remainingTasksCount?: number;
/** Whether all tasks are complete (show "!" badge) */
allTasksComplete?: boolean;
// ── 3-dots menu actions ──
stage: 'egg' | 'baby' | 'adult';
blobbiNaddr: string;
onSetAsCompanion: () => void;
isCurrentCompanion: boolean;
isUpdatingCompanion?: boolean;
onTakePhoto: () => void;
onEvolve: () => void;
isTransitioning?: boolean;
hideEvolveButton?: boolean;
isIncubationAction?: boolean;
isEvolutionAction?: boolean;
// ── Dev-only actions ──
onDevInstantTransition?: () => void;
onDevOpenEditor?: () => void;
onDevOpenEmotionPanel?: () => void;
}
function BlobbiBottomBar({
@@ -2315,11 +2076,26 @@ function BlobbiBottomBar({
onMissionsClick,
onActionsClick,
onShopClick,
onInventoryClick,
needyBlobbiesCount,
isInTaskProcess,
remainingTasksCount,
allTasksComplete,
// 3-dots menu props
stage,
blobbiNaddr,
onSetAsCompanion,
isCurrentCompanion,
isUpdatingCompanion = false,
onTakePhoto,
onEvolve,
isTransitioning = false,
hideEvolveButton = false,
isIncubationAction = false,
isEvolutionAction = false,
// Dev-only props
onDevInstantTransition,
onDevOpenEditor,
onDevOpenEmotionPanel,
}: BlobbiBottomBarProps) {
// Determine what to show on missions badge:
// - If all tasks complete during active process: show "!"
@@ -2327,6 +2103,9 @@ function BlobbiBottomBar({
// - Otherwise: no badge
// Works for BOTH incubating (hatch) and evolving processes
const missionsBadge = allTasksComplete ? '!' : (isInTaskProcess && remainingTasksCount && remainingTasksCount > 0 ? remainingTasksCount : undefined);
const canBeCompanion = stage !== 'egg';
const showEvolveButton = stage !== 'adult' && !hideEvolveButton;
return (
<div className="mt-6 pt-2">
@@ -2361,8 +2140,67 @@ function BlobbiBottomBar({
{/* Right Group - aligned to start (closer to center) */}
<div className="flex items-center justify-start gap-0 sm:gap-1 overflow-hidden">
<BottomBarButton onClick={onShopClick} icon={<ShoppingBag className="size-4" />} label="Shop" />
<BottomBarButton onClick={onInventoryClick} icon={<Package className="size-4" />} label="Inventory" />
<BottomBarButton onClick={onShopClick} icon={<Package className="size-4" />} label="Items" />
{/* 3-dots menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex flex-col items-center gap-0.5 px-2 sm:px-3 py-1.5 rounded-xl hover:bg-accent/50 active:bg-accent transition-colors min-w-0 sm:min-w-[56px]"
>
<MoreHorizontal className="size-4" />
<span className="text-[10px] text-muted-foreground truncate max-w-[48px] sm:max-w-none">More</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end">
<DropdownMenuItem onClick={onTakePhoto}>
<Camera className="size-4 mr-2" />
Take a Photo
</DropdownMenuItem>
{canBeCompanion && (
<DropdownMenuItem onClick={onSetAsCompanion} disabled={isUpdatingCompanion}>
<Footprints className={cn('size-4 mr-2', isCurrentCompanion && 'text-green-500')} />
{isCurrentCompanion ? 'Current Companion' : 'Set as Companion'}
</DropdownMenuItem>
)}
{showEvolveButton && (
<DropdownMenuItem onClick={onEvolve} disabled={isTransitioning}>
{stage === 'egg' ? <Egg className="size-4 mr-2" /> : <Sparkles className="size-4 mr-2" />}
{getEvolveTooltip(stage, isIncubationAction, isEvolutionAction)}
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to={`/${blobbiNaddr}`}>
<ExternalLink className="size-4 mr-2" />
View Blobbi
</Link>
</DropdownMenuItem>
{/* DEV ONLY: Developer tools */}
{isLocalhostDev() && (onDevInstantTransition || onDevOpenEditor || onDevOpenEmotionPanel) && (
<>
<DropdownMenuSeparator />
{stage !== 'adult' && onDevInstantTransition && (
<DropdownMenuItem onClick={onDevInstantTransition} disabled={isTransitioning} className="text-amber-600 dark:text-amber-400">
{stage === 'egg' ? <Egg className="size-4 mr-2" /> : <Sparkles className="size-4 mr-2" />}
{stage === 'egg' ? 'Dev Hatch' : 'Dev Evolve'}
</DropdownMenuItem>
)}
{onDevOpenEditor && (
<DropdownMenuItem onClick={onDevOpenEditor} className="text-amber-600 dark:text-amber-400">
<Wrench className="size-4 mr-2" />
State Editor
</DropdownMenuItem>
)}
{onDevOpenEmotionPanel && (
<DropdownMenuItem onClick={onDevOpenEmotionPanel} className="text-amber-600 dark:text-amber-400">
<Theater className="size-4 mr-2" />
Emotion Tester
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
@@ -2414,83 +2252,6 @@ function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default'
);
}
// ─── Blobbi Info Modal ────────────────────────────────────────────────────────
interface BlobbiInfoModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
}
function BlobbiInfoModal({ open, onOpenChange, companion }: BlobbiInfoModalProps) {
const isSleeping = companion.state === 'sleeping';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<DialogTitle style={{ color: companion.visualTraits.baseColor }}>
{companion.name}
</DialogTitle>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4 space-y-4">
{/* Blobbi Visual */}
<div className="flex justify-center">
<BlobbiStageVisual
companion={companion}
size="md"
animated={!isSleeping}
/>
</div>
{/* Info Grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-muted-foreground text-xs">Stage</p>
<p className="font-medium capitalize">{companion.stage}</p>
</div>
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-muted-foreground text-xs">State</p>
<p className="font-medium capitalize">{companion.state}</p>
</div>
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-muted-foreground text-xs">Generation</p>
<p className="font-medium">{companion.generation ?? 1}</p>
</div>
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-muted-foreground text-xs">Experience</p>
<p className="font-medium">{companion.experience ?? 0}</p>
</div>
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-muted-foreground text-xs">Care Streak</p>
<p className="font-medium">{companion.careStreak ?? 0} days</p>
</div>
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-muted-foreground text-xs">Visibility</p>
<p className="font-medium">{companion.visibleToOthers ? 'Public' : 'Private'}</p>
</div>
</div>
{/* Legacy Notice */}
{companion.isLegacy && (
<div className="px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/30">
<p className="text-xs text-amber-600 dark:text-amber-400">
This pet uses a legacy format and will be upgraded on next interaction.
</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
+187 -78
View File
@@ -1,43 +1,25 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Bug, CalendarDays, FlaskConical, Minus, Package, Plus, RefreshCw, ScrollText, ShieldAlert, Tag } from 'lucide-react';
import { Bug, FlaskConical, Minus, Package, Plus, RefreshCw, ScrollText, ShieldAlert } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { PageHeader } from '@/components/PageHeader';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { parseChangelog } from '@/lib/changelog';
import type { ChangelogCategory } from '@/lib/changelog';
import type { ChangelogCategory, ChangelogEntry } 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',
},
/** Per-category icon + color used as inline list bullets. */
const CATEGORY_STYLES: Record<ChangelogCategory, { icon: typeof Plus; colorClass: string }> = {
Added: { icon: Plus, colorClass: 'text-emerald-600 dark:text-emerald-400' },
Changed: { icon: RefreshCw, colorClass: 'text-blue-600 dark:text-blue-400' },
Deprecated: { icon: Package, colorClass: 'text-orange-600 dark:text-orange-400' },
Removed: { icon: Minus, colorClass: 'text-red-600 dark:text-red-400' },
Fixed: { icon: Bug, colorClass: 'text-amber-600 dark:text-amber-400' },
Security: { icon: ShieldAlert, colorClass: 'text-purple-600 dark:text-purple-400' },
};
/** Format "2026-03-26" as a readable date string. */
@@ -92,55 +74,21 @@ export function ChangelogPage() {
<>
{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" />
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-sm hover:underline"
>
v{entry.version}
</a>
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
>
<CalendarDays className="size-3.5" />
<span>{formatDate(entry.date)}</span>
</a>
<LatestRelease entry={entries[0]} />
{entries.length > 1 && (
<>
<div className="flex items-center gap-3 pt-4 pb-1">
<div className="h-px flex-1 bg-border" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Past releases</span>
<div className="h-px flex-1 bg-border" />
</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>
))}
{entries.slice(1).map((entry) => (
<ChangelogEntryCard key={entry.version} entry={entry} />
))}
</>
)}
</>
)}
</div>
@@ -148,6 +96,167 @@ export function ChangelogPage() {
);
}
/** Hero treatment for the most recent release — no card, centered version + date. */
function LatestRelease({ entry }: { entry: ChangelogEntry }) {
const contentRef = useRef<HTMLUListElement>(null);
const [overflows, setOverflows] = useState(false);
const [expanded, setExpanded] = useState(false);
const measure = useCallback(() => {
const el = contentRef.current;
if (el) setOverflows(el.scrollHeight > ENTRY_MAX_HEIGHT);
}, []);
useEffect(() => {
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [measure]);
return (
<div className="pt-2 pb-1 px-4">
{/* Big centered version + date */}
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="block text-center text-2xl font-bold tracking-tight hover:underline"
>
v{entry.version}
</a>
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
{formatDate(entry.date)}
</a>
{/* Items */}
<div className="relative mt-4">
<ul
ref={contentRef}
style={!expanded && overflows ? { maxHeight: ENTRY_MAX_HEIGHT, overflow: 'hidden' } : undefined}
className="space-y-2.5"
>
{entry.sections.flatMap((section) => {
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
const Icon = style.icon;
return section.items.map((item, i) => (
<li key={`${section.category}-${i}`} className="flex gap-2 text-base text-foreground/90">
<Tooltip>
<TooltipTrigger asChild>
<Icon className={`size-4 shrink-0 mt-1 cursor-default ${style.colorClass}`} />
</TooltipTrigger>
<TooltipContent side="left">{section.category}</TooltipContent>
</Tooltip>
<span>{item}</span>
</li>
));
})}
</ul>
{!expanded && overflows && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
)}
</div>
{overflows && (
<button
className="w-full text-sm text-primary hover:underline mt-1"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'Show less' : 'Read more'}
</button>
)}
</div>
);
}
const ENTRY_MAX_HEIGHT = 240; // px — entries taller than this get a "Read more" button
/** A single changelog release card with truncation for long entries. */
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
const contentRef = useRef<HTMLUListElement>(null);
const [overflows, setOverflows] = useState(false);
const [expanded, setExpanded] = useState(false);
const measure = useCallback(() => {
const el = contentRef.current;
if (el) setOverflows(el.scrollHeight > ENTRY_MAX_HEIGHT);
}, []);
useEffect(() => {
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [measure]);
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Version header */}
<div className="flex items-center gap-3 px-4 py-3">
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-sm hover:underline"
>
v{entry.version}
</a>
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
>
<span>{formatDate(entry.date)}</span>
</a>
</div>
{/* Items */}
<div className="relative">
<ul
ref={contentRef}
style={!expanded && overflows ? { maxHeight: ENTRY_MAX_HEIGHT, overflow: 'hidden' } : undefined}
className="px-4 py-3 space-y-2.5"
>
{entry.sections.flatMap((section) => {
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
const Icon = style.icon;
return section.items.map((item, i) => (
<li key={`${section.category}-${i}`} className="flex gap-2 text-sm text-foreground/90">
<Tooltip>
<TooltipTrigger asChild>
<Icon className={`size-3.5 shrink-0 mt-[3px] cursor-default ${style.colorClass}`} />
</TooltipTrigger>
<TooltipContent side="left">{section.category}</TooltipContent>
</Tooltip>
<span>{item}</span>
</li>
));
})}
</ul>
{!expanded && overflows && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
)}
</div>
{overflows && (
<button
className="w-full text-sm text-primary hover:underline py-2"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'Show less' : 'Read more'}
</button>
)}
</div>
);
}
/** Banner shown at the top of the changelog for untagged (pre-release) builds. */
function PreReleaseBanner({ latestVersion }: { latestVersion: string }) {
return (
@@ -186,7 +295,7 @@ function ChangelogSkeleton() {
<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">
<div className="flex items-center gap-3 px-4 py-3">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-4 w-16" />
<div className="ml-auto flex items-center gap-1.5">
+1 -1
View File
@@ -50,7 +50,7 @@ export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo
description: `${title} on Nostr`,
});
const fabClick = onFabClick ?? (resolvedDef ? () => setInfoOpen(true) : undefined);
const fabClick = onFabClick ?? (!fabHref && resolvedDef ? () => setInfoOpen(true) : undefined);
useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick, hasSubHeader: !!user });
const kinds = Array.isArray(kind) ? kind : [kind];
+2 -2
View File
@@ -469,7 +469,7 @@ function LikeNotification({ item, isNew }: { item: NotificationItem; isNew: bool
actorPubkey={item.event.pubkey}
icon={
<span className="text-base leading-none size-4 flex items-center justify-center">
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4" />
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4 object-contain" />
</span>
}
action={`reacted to your ${noun}`}
@@ -569,7 +569,7 @@ function LikeNotificationGroup({ group }: { group: GroupedNotificationItem }) {
actors={group.actors}
icon={
<span className="text-base leading-none size-4 flex items-center justify-center">
<ReactionEmoji content={firstEvent.content.trim()} tags={firstEvent.tags} className="inline-block h-4 w-4" />
<ReactionEmoji content={firstEvent.content.trim()} tags={firstEvent.tags} className="inline-block h-4 w-4 object-contain" />
</span>
}
action={`reacted to your ${noun}`}
+29 -5
View File
@@ -12,6 +12,8 @@ import {
MessageCircle,
MoreHorizontal,
Radio,
Package,
Rocket,
Share2,
Star,
Zap,
@@ -34,6 +36,7 @@ import {
ReactionEmoji,
RenderResolvedEmoji,
} from "@/components/CustomEmoji";
const BlobbiStateCard = lazy(() => import("@/components/BlobbiStateCard").then(m => ({ default: m.BlobbiStateCard })));
const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m => ({ default: m.CustomNipCard })));
import { FileMetadataContent } from "@/components/FileMetadataContent";
import { FollowPackContent } from "@/components/FollowPackContent";
@@ -50,7 +53,7 @@ import { RepostIcon } from "@/components/icons/RepostIcon";
import { LiveStreamPage } from "@/components/LiveStreamPage";
import { MagicDeckContent } from "@/components/MagicDeckContent";
import { MusicDetailContent } from "@/components/MusicDetailContent";
import { NoteCard } from "@/components/NoteCard";
import { EventActionHeader, NoteCard } from "@/components/NoteCard";
import { NoteContent } from "@/components/NoteContent";
import { NsiteCard } from "@/components/NsiteCard";
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
@@ -82,6 +85,7 @@ import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer";
import { WebxdcEmbed } from "@/components/WebxdcEmbed";
import { ProfileCard } from "@/components/ProfileCard";
import { ZapstoreAppContent } from "@/components/ZapstoreAppContent";
import { AppHandlerContent } from "@/components/AppHandlerContent";
import { useAppContext } from "@/hooks/useAppContext";
import { type AddrCoords, useAddrEvent, useEvent } from "@/hooks/useEvent";
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
@@ -132,6 +136,7 @@ function shellTitleForKind(kind?: number): string {
if (kind === BADGE_PROFILE_KIND_NEW || kind === BADGE_PROFILE_KIND_LEGACY) return "Badge Collection";
if (kind === BOOK_REVIEW_KIND) return "Book Review";
if (kind === 32267) return "App Details";
if (kind === 31990) return "App";
if (kind === 15128 || kind === 35128) return "Nsite";
if (kind === VANISH_KIND) return "Request to Vanish";
if (kind === 20) return "Photo";
@@ -141,6 +146,7 @@ function shellTitleForKind(kind?: number): string {
if (kind === 7) return "Reaction";
if (kind === 9735) return "Zap";
if (kind === 0) return "Profile";
if (kind === 31124) return "Blobbi";
return "Post Details";
}
@@ -996,11 +1002,13 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
const isCustomNip = event.kind === 30817;
const isNsite = event.kind === 15128 || event.kind === 35128;
const isZapstoreApp = event.kind === 32267;
const isAppHandler = event.kind === 31990;
const isEncryptedDM = event.kind === 4;
const isLetter = event.kind === 8211;
const isVanish = event.kind === VANISH_KIND;
const isZap = event.kind === 9735;
const isProfile = event.kind === 0;
const isBlobbiState = event.kind === 31124;
const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite;
const isTextNote =
!isVine &&
@@ -1022,11 +1030,13 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
!isCommunity &&
!isDevKind &&
!isZapstoreApp &&
!isAppHandler &&
!isEncryptedDM &&
!isLetter &&
!isVanish &&
!isZap &&
!isProfile;
!isProfile &&
!isBlobbiState;
const videos = useMemo(
() => (isTextNote ? extractVideoUrls(event.content) : []),
@@ -1434,11 +1444,11 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
<div className="flex items-center gap-3">
{/* Reaction emoji bubble — size-10 matches the threaded ancestor avatar column */}
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0">
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0 text-xl leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="text-xl leading-none"
className="h-6 w-6 object-contain"
/>
</div>
@@ -1929,6 +1939,14 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
{/* Main post — expanded Ditto-style view */}
{!isReaction && !isRepost && !isVanish && !isZap && !isProfile && (
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
{/* Kind action header for app handlers */}
{isAppHandler && (
<EventActionHeader pubkey={event.pubkey} icon={Package} action="published an app" />
)}
{isNsite && (
<EventActionHeader pubkey={event.pubkey} icon={Rocket} action="deployed an" noun="nsite" nounRoute="/development" />
)}
{/* Author row */}
<div className="flex items-center gap-3">
{author.isLoading ? (
@@ -2040,10 +2058,16 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</div>
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} />
) : isAppHandler ? (
<AppHandlerContent event={event} />
) : isEncryptedDM ? (
<EncryptedMessageContent event={event} />
) : isLetter ? (
<EncryptedLetterContent event={event} />
) : isBlobbiState ? (
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
<BlobbiStateCard event={event} />
</Suspense>
) : isVine ||
isPoll ||
isGeocache ||
@@ -2140,7 +2164,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<RenderResolvedEmoji
key={i}
emoji={emoji}
className="h-4 w-4 leading-none"
className="h-4 w-4 object-contain leading-none"
/>
))}
</span>
+1
View File
@@ -89,6 +89,7 @@ export function TestApp({ children }: TestAppProps) {
feedIncludeBadgeDefinitions: false,
feedIncludeProfileBadges: false,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
followsFeedShowReplies: true,
},
sidebarOrder: [],
-5
View File
@@ -149,10 +149,6 @@ export interface Blobbi extends BlobbiVisualTraits {
generation?: number;
careStreak?: number;
/**
* Visibility / social.
*/
visibleToOthers?: boolean;
crossoverApp?: string | null;
themeVariant?: string;
@@ -215,7 +211,6 @@ export function createDefaultBlobbi(overrides: Partial<Blobbi> = {}): Blobbi {
generation: overrides.generation ?? 1,
careStreak: overrides.careStreak ?? 0,
visibleToOthers: overrides.visibleToOthers ?? true,
crossoverApp: overrides.crossoverApp ?? null,
themeVariant: overrides.themeVariant,
tags: overrides.tags ?? [],
+2 -6
View File
@@ -15,10 +15,6 @@ interface ImportMetaEnv {
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;
/** Build-time configuration injected from ditto.json as a JSON string. `"null"` when no config file was provided. */
readonly DITTO_CONFIG: string;
}
/**
* Build-time configuration injected by Vite from ditto.json.
* `null` when no config file was provided at build time.
*/
declare const __DITTO_CONFIG__: Partial<import('@/contexts/AppContext').AppConfig> | null;
+1 -1
View File
@@ -136,7 +136,7 @@ export default defineConfig(() => {
...(publicDir ? [mergePublicDir(publicDir)] : []),
],
define: {
__DITTO_CONFIG__: JSON.stringify(dittoConfig ?? null),
'import.meta.env.DITTO_CONFIG': JSON.stringify(JSON.stringify(dittoConfig ?? null)),
'import.meta.env.VERSION': JSON.stringify(pkg.version),
'import.meta.env.BUILD_DATE': JSON.stringify(new Date().toISOString()),
'import.meta.env.COMMIT_SHA': JSON.stringify(getCommitSha()),