Compare commits

...

40 Commits

Author SHA1 Message Date
Alex Gleason 431c388129 release: v2.5.2 2026-04-04 13:54:13 -05:00
Alex Gleason 72b63dac21 Set default AppConfig.client to Ditto's kind 31990 handler naddr 2026-04-04 13:30:09 -05:00
Chad Curtis be82cb9626 Propagate relay and author hints to all event fetch call sites
Wire relay URL hints (from e/E tag position [2]) and author pubkey hints
(from e/E tag position [4] or p/P tag fallback) through every component
that fetches a referenced event:

- NoteCard: use getParentEventHints, pass hints through ReplyContext
- ReplyContext: accept and forward relay/author hints to EmbeddedNote
- CommentContext: extract hints from E/A tags in parseCommentRoot,
  pass to useEvent, useAddrEvent, and EmbeddedNote
- NotificationsPage: extract hints from e tag in ReferencedNoteCard
- usePollVoteLabel: extract hints from e tag for parent poll fetch
- ComposeBox: pass quotedEvent.pubkey as authorHint to EmbeddedNote
2026-04-04 06:03:33 -05:00
Chad Curtis c2c6f711b5 Fix parent author hint extraction and useEvent query cache keying
getParentEventHints only looked at position [4] of the e tag for the parent
author pubkey, but many clients (e.g. Wisp) omit it. When the relay hint
doesn't have the event, Tier 3 (NIP-65 outbox resolution) never fired
because authorHint was undefined. Now falls back to the first p tag, which
per NIP-10 convention holds the parent author's pubkey.

Also include relays and authorHint in the useEvent queryKey so calls with
different hints aren't served stale null results from a hint-less query.
2026-04-04 05:50:21 -05:00
Chad Curtis 3fba81a7d2 Fix ancestor thread fetching to use relay hints and author outbox relays
AncestorThread was calling useEvent(eventId) without relay hints or author
hints, so ancestor events only resolved via Tier 1 (user's configured relays).
Tiers 2 (relay hints from e tags) and 3 (author's NIP-65 outbox relays) were
never activated, causing parent events on personal relays to silently fail.

Added getParentEventHints() to extract relay URL and author pubkey from NIP-10
e tags, and wired both through AncestorThread's recursive chain.
2026-04-04 05:22:28 -05:00
Chad Curtis 6f2b51197f Add option filter bars to poll voters modal with scrollable overflow and accent divider 2026-04-04 03:23:39 -05:00
Chad Curtis 00c801e9dc Add poll voter interactions, kind 1018 vote rendering, and DRY activity card refactor
Poll voters:
- Clickable voter avatar stack + vote count on polls (before and after voting)
- Voters modal showing each voter with avatar, name, option, and nevent link
- Extract VoterAvatarsButton to DRY the avatar stack pattern

Kind 1018 vote rendering:
- Register in PostDetailPage as compact activity card with parent poll ancestor
- Register in NoteCard with threaded + normal variants (user avatar, not icon)
- Register in CommentContext with Vote icon, 'a vote' label, and rich hover showing voter + option
- Extract usePollVoteLabel hook to DRY vote label resolution across 3 call sites

ActivityCard refactor:
- Extract shared ActivityCard and ActorRow from NoteCard
- Refactor reaction (kind 7), repost (kind 6/16), zap (kind 9735), and poll vote (kind 1018)
- Reuse ActivityCard in PostDetailPage for vote detail view
- Net ~250 line reduction in NoteCard
2026-04-04 03:09:20 -05:00
Chad Curtis 47e7d05cb9 Add poll voter avatars, voters modal, and kind 1018 vote detail view
- Show clickable voter avatar stack + vote count on polls (both before and after voting)
- Clicking opens a voters modal listing each voter with avatar, name, voted option, and link to their vote nevent
- Extract VoterAvatarsButton to DRY the avatar stack pattern
- Register kind 1018 in PostDetailPage so vote nevents render as compact activity cards (avatar + 'voted' + label)
- Parent poll appears as threaded ancestor above the vote card
- Use PostActionBar for vote detail action buttons
2026-04-04 02:42:19 -05:00
Chad Curtis 4ef6d1b149 Revert "Use relaxed eoseTimeout (1000ms) for Blobbi queries to ensure freshest data"
This reverts commit ed083bfdad.
2026-04-04 01:56:40 -05:00
Alex Gleason badd19d27c Reorder default sidebar: Blobbi, Badges, Emojis, Letters, Themes 2026-04-04 00:25:16 -05:00
Alex Gleason e67f90582b release: v2.5.1 2026-04-03 23:31:09 -05:00
Alex Gleason 7fa6e574f8 Fix lightbox z-index by portaling inside Lightbox itself, not just ImageGallery
The previous fix (db502b46) only portaled the Lightbox when rendered
from ImageGallery. But Lightbox is also rendered directly by
NoteContent, MediaCollage, and MagicDeckContent — all still trapped
inside the center column's z-0 stacking context (added in 8e3f778f).

Move createPortal(…, document.body) into Lightbox so every consumer
escapes the stacking context automatically.
2026-04-03 23:27:53 -05:00
Alex Gleason 9b36bf3325 release: v2.5.0 2026-04-03 23:09:20 -05:00
Alex Gleason bc1c4cb7cf Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-03 22:50:34 -05:00
Chad Curtis 119f684fb3 Fix sharp corners on compose box by adding rounded-2xl 2026-04-03 22:41:16 -05:00
Chad Curtis 45134ef9cc Allow file uploads in poll composer
Remove the separate pollQuestion state and poll builder branch. Poll
mode now reuses the normal textarea/preview ternary (with edit/preview
toggle, file uploads, paste handling, imeta tags) and renders poll
options and settings below it.
2026-04-03 22:29:32 -05:00
Chad Curtis db502b462c Fix lightbox appearing behind right sidebar by portaling to document.body 2026-04-03 21:52:05 -05:00
Chad Curtis ed083bfdad Use relaxed eoseTimeout (1000ms) for Blobbi queries to ensure freshest data
The default pool eoseTimeout (300ms) races and resolves shortly after the
fastest relay. Blobbi pet state and profile data are accuracy-sensitive —
stale data from a single fast relay can cause data loss when mutations
overwrite newer versions on other relays.

- Add eoseTimeout option to fetchFreshEvent and new fetchFreshEvents variant
- Update useBlobbisCollection, useBlobbonautProfile, and useBlobbiSleepToggle
  to use fetchFreshEvents/fetchFreshEvent with eoseTimeout: 1000
- Widen NostrBatcher.req() type to pass through eoseTimeout to NPool
- Gate unconditional console.log in parseBlobbiEvent behind import.meta.env.DEV
- Remove unconditional console.logs from useBlobbisCollection
2026-04-03 21:39:00 -05:00
Alex Gleason 47811f9190 Use NIP-5A canonical subdomains for nsite preview iframe origins
Instead of generating a random session ID for the iframe subdomain,
derive it from the nsite event using the NIP-5A canonical format:
- Root sites (kind 15128): npub subdomain
- Named sites (kind 35128): base36(pubkey) + d-tag subdomain

Extract hexToBase36 and getNsiteSubdomain into a shared utility
used by both NsiteCard and NsitePreviewDialog.
2026-04-03 18:37:28 -05:00
Alex Gleason ba99cdc51c Fix MIME type for nsite assets by always using extension-based detection
Blossom servers commonly return incorrect Content-Type headers (e.g. text/plain
for .js files), causing browsers to reject module scripts under strict MIME
checking. Since we always know the file path from the manifest, use guessMimeType
based on the file extension instead of trusting the Blossom response header.
2026-04-03 18:13:40 -05:00
Alex Gleason 7092f7306f Serve nsite previews directly from Blossom instead of proxying through nsite.lol gateway
NsitePreviewDialog now builds a path→sha256 manifest from the event's 'path'
tags and resolves files directly from Blossom servers (from the event's 'server'
tags, falling back to the user's configured app Blossom servers). Each fetch
request from the iframe is intercepted, the sha256 is looked up in the manifest,
and the blob is fetched from the first Blossom server that responds successfully.
Unknown paths fall back to /index.html to support SPA client-side routing.

- NsitePreviewDialog: remove nsiteUrl proxy, accept NostrEvent instead
- NsiteCard: pass event directly to dialog
- AppHandlerContent: use useAddrEvent to fetch the kind 35128 event by
  pubkey+d-tag from the 'a' tag, then pass the event to the dialog; disable
  Run button until the nsite event is loaded; remove unused hexToBase36
2026-04-03 18:10:22 -05:00
Alex Gleason 357dd56de0 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-03 17:54:42 -05:00
Alex Gleason fadec0574a Add Run button to NsiteCard for in-app nsite preview 2026-04-03 17:49:06 -05:00
Alex Gleason 469806886a Fix card navigation firing on button/link clicks in NoteCard 2026-04-03 17:45:36 -05:00
Alex Gleason f7ab980ecd Fix nsite preview panel height using measured column rect
Replace absolute/sticky positioning with fixed + inline styles derived
from a ResizeObserver on the center column element. The panel now sits
at exactly the column's left/top/width and fills to the bottom of the
viewport, unaffected by the column's pb-overscroll padding.
2026-04-03 17:41:09 -05:00
Alex Gleason c6b5ab2284 Replace address bar and external link with app icon and name in preview nav bar 2026-04-03 17:36:17 -05:00
Alex Gleason 2231673ee6 Fix nsite preview panel to fill exactly the center column
Add CenterColumnContext to LayoutContext and expose the center column DOM
element from MainLayout via a useState ref callback. NsitePreviewDialog now
portals into that element using absolute inset-0 instead of fixed positioning
with hardcoded sidebar insets, so it always covers exactly the center column
regardless of viewport width.
2026-04-03 17:30:00 -05:00
Alex Gleason f8907475f9 Link client tag name to /:naddr on post detail page 2026-04-03 17:28:29 -05:00
Alex Gleason 4252841125 Replace dialog with fixed center-column overlay panel for nsite preview
Remove the Radix Dialog and browser chrome (back/forward/refresh/fullscreen).
The preview now renders as a portal-based fixed panel that overlays exactly
the center column using responsive left/right insets matching the sidebar
widths (sidebar:left-[300px], xl:right-[300px]). A slim nav bar at the top
shows the nsite:// URL, an external-link button, and a close button.
2026-04-03 17:25:55 -05:00
Alex Gleason ee8220c1f0 Use nsite://<name><path> in preview address bar
Separate the proxy target (nsite.lol gateway URL) from the display URL.
Pass nsiteName through to the dialog so the address bar shows a clean
nsite:// scheme with no gateway hostname.
2026-04-03 17:11:45 -05:00
Alex Gleason 11e29646a7 Show nsite:// URL in preview address bar instead of gateway URL 2026-04-03 17:09:03 -05:00
Alex Gleason a9bab7f8e8 Remove default dialog close button from nsite preview 2026-04-03 17:03:09 -05:00
Alex Gleason 0b69ab51f4 Fix Content-Type header matching in nsite preview proxy
The iframe-fetch-client does an exact equality check for "text/html",
but real servers return "text/html; charset=UTF-8". Also, the browser
fetch() API lowercases all header names while main.js checks Title-Case
keys. Fix both: re-key headers to Title-Case and strip charset params
from Content-Type values before sending them to the iframe.
2026-04-03 17:01:59 -05:00
Alex Gleason 2a32e79b13 feat: change AppConfig.client to naddr1 format, decode relay hint per NIP-89
AppConfig.client now expects a NIP-19 naddr1 string pointing to the app's
kind 31990 handler event instead of a raw 'a' tag value. useNostrPublish
decodes the naddr at publish time to extract the 31990:<pubkey>:<d-tag>
addr and any embedded relay hint, producing a fully NIP-89-compliant
client tag: ["client", <name>, <addr>, <relay-hint>].
2026-04-03 16:57:57 -05:00
Alex Gleason 39fc7549ac Add Run button and nsite preview dialog to app handler cards
When a kind 31990 app event includes an 'a' tag pointing to a kind 35128
nsite, display a 'Run' button that opens an in-app preview dialog. The
dialog embeds the nsite in a sandboxed iframe via the Shakespeare
iframe-fetch-client protocol (local-shakespeare.dev), proxying fetch
requests from the iframe to the live nsite URL so the SPA renders
without needing CORS headers on the origin server.
2026-04-03 16:53:25 -05:00
Alex Gleason 414f42e339 Add Blobbi (kind 31124) to the Ditto homepage feed 2026-04-03 16:50:17 -05:00
Alex Gleason 8e3f778f5b Improve Zapstore and app handler card display
- Rename Zapstore kind labels to include 'Zapstore' prefix across all
  label registries (NoteCard, PostDetailPage, CommentContext,
  ExternalContentHeader, NotificationsPage, extraKinds)
- Wrap Zapstore (32267, 30063, 3063) compact and detail content in
  rounded bordered cards with hover effects; remove redundant mt-2/mt-3
  margins from component roots
- Replace useLinkPreview thumbnail with metadata banner/picture in kind
  31990 app handler cards (compact and full views)
- Add pt-4 to Zapstore detail card wrappers in PostDetailPage
- Fix sticky tab bar (SubHeaderBar z-10) being painted over by card
  content: remove z-10 from AppHandlerContent inner div and add z-0 to
  the main content column in MainLayout
2026-04-03 16:20:40 -05:00
Alex Gleason bc83d08961 Upgrade Nostrify 2026-04-03 13:56:48 -05:00
Alex Gleason 7d83273410 Simplify sidebar media query to a single useQuery with inline fallback logic 2026-04-03 00:53:45 -05:00
Alex Gleason fabcb4170d Fill profile media sidebar with kind 1 fallback when kind 20 results are sparse
When fewer than 9 media-native events (kind 20, 21, 22, etc.) are found for a
profile, perform a secondary query for kind 1 events with search:media:true and
append them to fill the remaining slots. Kind 20 events are always displayed first.
2026-04-03 00:36:44 -05:00
33 changed files with 1862 additions and 775 deletions
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.4.1"
versionName "2.5.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.4.1;
MARKETING_VERSION = 2.5.2;
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.4.1;
MARKETING_VERSION = 2.5.2;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+36 -36
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.3.1",
"version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.3.1",
"version": "2.5.1",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
@@ -58,8 +58,8 @@
"@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/nostrify": "^0.51.1",
"@nostrify/react": "^0.4.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -2486,9 +2486,9 @@
}
},
"node_modules/@nostrify/nostrify": {
"version": "0.51.0",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.0.tgz",
"integrity": "sha512-GLka8FHu7o04kpz/NB69JppQy3rbwkadr8Au2fLmYbbB478kkGuthF+U5JS2qKaAI137n1p5BN1eFsCk2JyuXQ==",
"version": "0.51.1",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.1.tgz",
"integrity": "sha512-oPJhUiO1TlV5sGYizqAP4GvLijib34Uwh48wxlFimR/2MoCuSmab4AppcztGPNwxQoTKkJbLJwsSpl42V+WIXA==",
"dependencies": {
"@nostrify/types": "0.36.9",
"@scure/base": "^2.0.0",
@@ -2512,9 +2512,9 @@
}
},
"node_modules/@nostrify/nostrify/node_modules/@types/node": {
"version": "24.11.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -2527,11 +2527,11 @@
"license": "MIT"
},
"node_modules/@nostrify/react": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.0.tgz",
"integrity": "sha512-noroI4R2BS3GzEk55NGoWZkrBKDFHtM43HW99dnYdP+ecxtjBY6nYplypouUUkalHfTfGK2lQetLg5DvM2k2+w==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.1.tgz",
"integrity": "sha512-2JXxEl4e6FIFhbi96Dwv2knu5qAACYulo1a0oVell/aS8KCWsBTPd1+v0EUra0yqiUA3Q1nVLrk8mx7kQYH/yQ==",
"dependencies": {
"@nostrify/nostrify": "0.51.0",
"@nostrify/nostrify": "0.51.1",
"@nostrify/types": "0.36.9"
},
"peerDependencies": {
@@ -6185,9 +6185,9 @@
}
},
"node_modules/@smithy/is-array-buffer": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.1.tgz",
"integrity": "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz",
"integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -6197,13 +6197,13 @@
}
},
"node_modules/@smithy/util-base64": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.1.tgz",
"integrity": "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz",
"integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.1",
"@smithy/util-utf8": "^4.2.1",
"@smithy/util-buffer-from": "^4.2.2",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -6211,12 +6211,12 @@
}
},
"node_modules/@smithy/util-buffer-from": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.1.tgz",
"integrity": "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz",
"integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^4.2.1",
"@smithy/is-array-buffer": "^4.2.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -6224,9 +6224,9 @@
}
},
"node_modules/@smithy/util-hex-encoding": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.1.tgz",
"integrity": "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz",
"integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -6236,12 +6236,12 @@
}
},
"node_modules/@smithy/util-utf8": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.1.tgz",
"integrity": "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz",
"integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.1",
"@smithy/util-buffer-from": "^4.2.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -15485,9 +15485,9 @@
"license": "MIT"
},
"node_modules/websocket-ts": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/websocket-ts/-/websocket-ts-2.2.1.tgz",
"integrity": "sha512-YKPDfxlK5qOheLZ2bTIiktZO1bpfGdNCPJmTEaPW7G9UXI1GKjDdeacOrsULUS000OPNxDVOyAuKLuIWPqWM0Q==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/websocket-ts/-/websocket-ts-2.3.0.tgz",
"integrity": "sha512-DocKMdXx7i8TCBMU+XUKZeUaKwQ7O2NPlxUcgb0poG4RwDrIqBo19mRdW00a1Sm7MSijhIEsgv9UJ0kB/qNy+Q==",
"license": "MIT"
},
"node_modules/whatwg-encoding": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.4.1",
"version": "2.5.2",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -64,8 +64,8 @@
"@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/nostrify": "^0.51.1",
"@nostrify/react": "^0.4.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
+5 -4
View File
@@ -51,6 +51,7 @@ const hardcodedConfig: AppConfig = {
appName: "Ditto",
appId: "ditto",
homePage: "feed",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
magicMouse: false,
theme: "system",
autoShareTheme: true,
@@ -123,11 +124,11 @@ const hardcodedConfig: AppConfig = {
"feed",
"notifications",
"search",
"themes",
"letters",
"badges",
"blobbi",
"theme",
"badges",
"emojis",
"letters",
"themes",
"settings",
"help",
],
+103 -42
View File
@@ -1,12 +1,13 @@
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { ExternalLink, GitFork, Package } from 'lucide-react';
import { ExternalLink, GitFork, Package, Play } 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 { NsitePreviewDialog } from '@/components/NsitePreviewDialog';
import { Skeleton } from '@/components/ui/skeleton';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { useAddrEvent } from '@/hooks/useEvent';
import { NostrURI } from '@/lib/NostrURI';
import { cn } from '@/lib/utils';
@@ -66,6 +67,31 @@ function getShakespeareUrl(tags: string[][]): string | undefined {
return undefined;
}
interface NsiteRef {
/** The author pubkey (hex) of the kind 35128 event. */
pubkey: string;
/** The d-tag identifier of the kind 35128 event. */
identifier: string;
}
/**
* Extract nsite info from a kind 35128 `a` tag, if present.
* The `a` tag value format is `"35128:<pubkey>:<d-tag>"`.
*/
function getNsiteRef(tags: string[][]): NsiteRef | undefined {
for (const tag of tags) {
if (tag[0] !== 'a') continue;
const parts = tag[1]?.split(':');
if (!parts || parts[0] !== '35128' || parts.length < 3) continue;
const pubkey = parts[1];
const identifier = parts.slice(2).join(':');
if (!pubkey || !identifier) continue;
return { pubkey, identifier };
}
return undefined;
}
interface AppHandlerContentProps {
event: NostrEvent;
/** If true, show compact preview (used in NoteCard feed). */
@@ -79,42 +105,40 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App';
const about = metadata.about;
const picture = metadata.picture;
const banner = metadata.banner;
const websiteUrl = getWebsiteUrl(event.tags, metadata);
const hashtags = getAllTags(event.tags, 't');
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
const nsiteRef = useMemo(() => getNsiteRef(event.tags), [event.tags]);
const [previewOpen, setPreviewOpen] = useState(false);
const { data: preview, isLoading: previewLoading } = useLinkPreview(websiteUrl ?? null);
const thumbnailUrl = preview?.thumbnail_url;
const [imgError, setImgError] = useState(false);
const showThumbnail = thumbnailUrl && !imgError;
// Fetch the actual nsite event so we can serve files directly from Blossom.
const { data: nsiteEvent } = useAddrEvent(
nsiteRef ? { kind: 35128, pubkey: nsiteRef.pubkey, identifier: nsiteRef.identifier } : undefined,
);
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) && (
{/* Banner hero */}
{banner && (
<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)}
/>
)}
<img
src={banner}
alt=""
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</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'}>
<div className="relative px-3.5 pb-3.5 space-y-2">
{/* App icon — overlaps the banner hero like a profile avatar */}
<div className={banner ? '-mt-7' : 'pt-3.5'}>
{picture ? (
<img
src={picture}
@@ -166,8 +190,19 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
{/* Actions */}
<div className="flex items-center gap-2">
{nsiteRef && (
<Button
size="sm"
className="h-7 text-xs"
disabled={!nsiteEvent}
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
>
<Play className="size-3 mr-1" />
Run
</Button>
)}
{websiteUrl && (
<Button asChild size="sm" className="h-7 text-xs">
<Button asChild size="sm" variant={nsiteRef ? 'secondary' : 'default'} 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" />
@@ -186,36 +221,42 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
</div>
</div>
</div>
);
{nsiteRef && nsiteEvent && (
<NsitePreviewDialog
event={nsiteEvent}
appName={name}
appPicture={picture}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
)}
</>
);
}
// 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) && (
{/* Banner hero */}
{banner && (
<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)}
/>
)}
<img
src={banner}
alt=""
className="size-full object-cover"
loading="lazy"
/>
</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="relative px-4 pb-4 space-y-3">
{/* App icon — overlaps the banner hero like a profile avatar */}
<div className={cn(
'flex items-end justify-between',
showThumbnail || previewLoading ? '-mt-10' : 'pt-4',
banner ? '-mt-10' : 'pt-4',
)}>
{picture ? (
<img
@@ -268,8 +309,18 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
{nsiteRef && (
<Button
size="sm"
disabled={!nsiteEvent}
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
>
<Play className="size-3.5 mr-1.5" />
Run
</Button>
)}
{websiteUrl && (
<Button asChild size="sm">
<Button asChild size="sm" variant={nsiteRef ? 'secondary' : 'default'}>
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Open App
<ExternalLink className="size-3 ml-1.5" />
@@ -287,6 +338,16 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
</div>
</div>
</div>
{nsiteRef && nsiteEvent && (
<NsitePreviewDialog
event={nsiteEvent}
appName={name}
appPicture={picture}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
)}
</div>
);
}
+83 -16
View File
@@ -6,7 +6,7 @@ import {
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,
Users, Vote, Zap,
} from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -22,6 +22,7 @@ import { ExternalFavicon } from '@/components/ExternalFavicon';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAddrEvent, useEvent } from '@/hooks/useEvent';
import { usePollVoteLabel } from '@/hooks/usePollVoteLabel';
import { useAuthor } from '@/hooks/useAuthor';
import { useBookInfo } from '@/hooks/useBookInfo';
import { useLinkPreview } from '@/hooks/useLinkPreview';
@@ -44,26 +45,38 @@ interface CommentRoot {
identifier?: string;
/** Root kind number (from K tag). */
rootKind?: string;
/** Relay URL hint from the E or A tag (position [2]). */
relayHint?: string;
/** Author pubkey hint extracted from the E tag (position [3]) or P tag. */
authorHint?: string;
}
/** Parse the root reference from a kind 1111 comment's tags. */
function parseCommentRoot(event: NostrEvent): CommentRoot | undefined {
const aTag = event.tags.find(([name]) => name === 'A')?.[1];
const aTagFull = event.tags.find(([name]) => name === 'A');
// Use find (not findLast) to get the root E tag, not a parent e tag
const eTag = event.tags.find(([name]) => name === 'E')?.[1];
const eTagFull = event.tags.find(([name]) => name === 'E');
const iTag = event.tags.find(([name]) => name === 'I')?.[1];
const kTag = event.tags.find(([name]) => name === 'K')?.[1];
// P tag holds the root event author's pubkey — used as author hint fallback
const pTag = event.tags.find(([name]) => name === 'P')?.[1];
if (aTag) {
if (aTagFull) {
const aTag = aTagFull[1];
const relayHint = aTagFull[2] || undefined;
const parts = aTag.split(':');
const kind = parseInt(parts[0], 10);
const pubkey = parts[1] ?? '';
const identifier = parts.slice(2).join(':');
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag };
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag, relayHint };
}
if (eTag) {
return { type: 'event', eventId: eTag, rootKind: kTag };
if (eTagFull) {
const eTag = eTagFull[1];
const relayHint = eTagFull[2] || undefined;
// NIP-22 E tags may have the author pubkey at position [3]; fall back to P tag
const authorHint = eTagFull[3] || pTag || undefined;
return { type: 'event', eventId: eTag, rootKind: kTag, relayHint, authorHint };
}
if (iTag) {
@@ -91,6 +104,7 @@ const KIND_LABELS: Record<number, string> = {
22: 'a short video',
62: 'a request to vanish',
1063: 'a file',
1018: 'a vote',
1068: 'a poll',
1111: 'a comment',
1222: 'a voice message',
@@ -108,8 +122,8 @@ const KIND_LABELS: Record<number, string> = {
30030: 'an emoji pack',
30054: 'a podcast episode',
30055: 'a podcast trailer',
3063: 'an asset',
30063: 'a release',
3063: 'a Zapstore asset',
30063: 'a Zapstore release',
30311: 'a stream',
30315: 'a status',
30617: 'a repository',
@@ -117,7 +131,7 @@ const KIND_LABELS: Record<number, string> = {
31922: 'a calendar event',
31923: 'a calendar event',
31990: 'an app',
32267: 'an app',
32267: 'a Zapstore app',
34139: 'a playlist',
34236: 'a divine',
34550: 'a community',
@@ -142,6 +156,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
21: Film,
22: Film,
1063: FileText,
1018: Vote,
1068: BarChart3,
1222: Mic,
1617: FileText,
@@ -214,11 +229,11 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
34139: 'playlist',
};
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto app"). */
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto Zapstore app"). */
const KIND_POSTFIXES: Partial<Record<number, string>> = {
32267: 'on Zapstore',
30063: 'release',
3063: 'asset',
30063: 'Zapstore release',
3063: 'Zapstore asset',
};
/** Get a display name for an event based on its kind and tags. */
@@ -488,7 +503,7 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
/** Comment context for non-profile addressable event roots (A tag). */
function GenericAddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const { data: event, isLoading } = useAddrEvent(root.addr);
const { data: event, isLoading } = useAddrEvent(root.addr, root.relayHint ? [root.relayHint] : undefined);
const isCommunity = root.rootKind === '34550' || root.addr?.kind === 34550;
const prefix = isCommunity ? 'Posted in' : 'Commenting on';
@@ -526,18 +541,33 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
/** Comment context for regular event roots (E tag). */
function EventCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const { data: event, isLoading } = useEvent(root.eventId);
const { data: event, isLoading } = useEvent(
root.eventId,
root.relayHint ? [root.relayHint] : undefined,
root.authorHint,
);
// Kind 7 reactions get special treatment
if (event?.kind === 7) {
return <ReactionCommentContext event={event} className={className} />;
}
// Kind 1018 poll votes get special treatment
if (event?.kind === 1018) {
return <PollVoteCommentContext event={event} className={className} />;
}
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
const link = event ? getRootLink(event) : undefined;
const hoverContent = root.eventId ? (
<EmbeddedNote eventId={root.eventId} className="border-0 rounded-none" disableHoverCards />
<EmbeddedNote
eventId={root.eventId}
relays={root.relayHint ? [root.relayHint] : undefined}
authorHint={root.authorHint}
className="border-0 rounded-none"
disableHoverCards
/>
) : undefined;
return (
@@ -586,6 +616,43 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
);
}
/** Comment context for kind 1018 poll vote roots — shows "Commenting on @{name}'s vote for {option}". */
function PollVoteCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const voteLink = getRootLink(event);
const profileLink = `/${nip19.npubEncode(event.pubkey)}`;
const voteLabel = usePollVoteLabel(event);
return (
<CommentContextRow prefix="Commenting on" className={className}>
{author.isLoading ? (
<Skeleton className="h-3.5 w-16 inline-block" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileLink}
className="text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
@{displayName}
</Link>
</ProfileHoverCard>
)}
<Link
to={voteLink}
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<Vote className="size-3.5 shrink-0" />
{voteLabel ? `vote for ${voteLabel}` : 'vote'}
</Link>
</CommentContextRow>
);
}
/** Comment context for external content roots (I tag). */
function ExternalCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const identifier = root.identifier ?? '';
+90 -82
View File
@@ -201,7 +201,6 @@ export function ComposeBox({
// Poll mode state
const [mode, setMode] = useState<'post' | 'poll'>(initialMode);
const [pollQuestion, setPollQuestion] = useState('');
const [pollOptions, setPollOptions] = useState([
{ id: pollOptionId(), label: '' },
{ id: pollOptionId(), label: '' },
@@ -233,7 +232,6 @@ export function ComposeBox({
setTrayOpen(false);
setInternalPreviewMode(false);
setMode(initialMode);
setPollQuestion('');
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
setPollType('singlechoice');
setPollDuration(7);
@@ -982,7 +980,8 @@ export function ComposeBox({
const handlePollSubmit = async () => {
const filledOptions = pollOptions.filter((o) => o.label.trim());
if (!pollQuestion.trim() || filledOptions.length < 2 || !user || isPollPending) return;
const finalContent = content.trim();
if (!finalContent || filledOptions.length < 2 || !user || isPollPending) return;
const tags: string[][] = [];
for (const opt of filledOptions) {
@@ -992,10 +991,27 @@ export function ComposeBox({
if (pollDuration > 0) {
tags.push(['endsAt', String(Math.floor(Date.now() / 1000) + pollDuration * 86_400)]);
}
tags.push(['alt', `Poll: ${pollQuestion.trim()}`]);
// NIP-92: Add imeta tags for media URLs in content
const mediaUrlMatches = finalContent.matchAll(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi'));
const processedUrls = new Set<string>();
for (const match of mediaUrlMatches) {
const url = match[0];
if (processedUrls.has(url)) continue;
processedUrls.add(url);
const fileTags = uploadedFileGroups.get(url);
if (fileTags) {
tags.push(['imeta', ...fileTags.map(tag => `${tag[0]} ${tag[1]}`)]);
} else {
const ext = match[1].toLowerCase();
tags.push(['imeta', `url ${url}`, `m ${mimeFromExt(ext)}`]);
}
}
tags.push(['alt', `Poll: ${finalContent}`]);
try {
await createEvent({ kind: 1068, content: pollQuestion.trim(), tags });
await createEvent({ kind: 1068, content: finalContent, tags });
resetComposeState();
queryClient.invalidateQueries({ queryKey: ['feed'] });
toast({ title: 'Poll published!' });
@@ -1006,7 +1022,7 @@ export function ComposeBox({
};
const pollFilledCount = pollOptions.filter((o) => o.label.trim()).length;
const isPollValid = pollQuestion.trim().length > 0 && pollFilledCount >= 2;
const isPollValid = content.trim().length > 0 && pollFilledCount >= 2;
const isExpanded = forceExpanded || expanded || content.length > 0 || !compact;
@@ -1014,7 +1030,7 @@ export function ComposeBox({
if (!user && compact) return null;
return (
<div className={cn("px-4 py-3 bg-background/85")}>
<div className={cn("px-4 py-3 bg-background/85 rounded-2xl")}>
{/* Preview toggle at top when not controlled and has previewable content */}
{hasPreviewableContent && controlledPreviewMode === undefined && (
<div className="flex items-center justify-end mb-3">
@@ -1062,31 +1078,83 @@ export function ComposeBox({
)}
<div className="flex-1 min-w-0">
{mode === 'poll' ? (
/* ── Inline poll builder ─────────────────────────────── */
<div className="pt-2.5 pb-1 space-y-3">
{!previewMode ? (
/* ── Edit mode — Textarea ────────────────────────────── */
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={expand}
onPaste={handlePaste}
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
<MentionAutocomplete
textareaRef={textareaRef}
content={content}
onInsertMention={handleInsertMention}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={content}
onInsertEmoji={handleInsertShortcodeEmoji}
/>
</div>
) : (
/* Preview mode - Show how post will look */
mockEvent && (() => {
const imetaMap = parseImetaMap(mockEvent.tags);
const videos = extractVideoUrls(mockEvent.content);
const imetaAudios = Array.from(imetaMap.values())
.filter((e) => e.mime?.startsWith('audio/'))
.map((e) => e.url);
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
const webxdcApps = Array.from(imetaMap.values()).filter(
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
);
return (
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
<NoteMedia
videos={videos}
audios={audios}
imetaMap={imetaMap}
webxdcApps={webxdcApps}
event={mockEvent}
/>
</div>
);
})()
)}
{/* Poll options + settings — shown below the normal textarea/preview */}
{mode === 'poll' && (
<div className="space-y-3 pt-1">
{/* Back to post link — hidden when poll mode is the only mode */}
{initialMode !== 'poll' && (
<button
type="button"
onClick={() => setMode('post')}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors -mt-0.5"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-3.5" />
Back to post
</button>
)}
{/* Question */}
<textarea
value={pollQuestion}
onChange={(e) => setPollQuestion(e.target.value)}
placeholder="Ask a question…"
rows={2}
maxLength={280}
className="w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pb-1 opacity-85 break-words"
/>
{/* Options */}
<div className="space-y-1.5">
{pollOptions.map((opt, idx) => (
@@ -1167,66 +1235,6 @@ export function ComposeBox({
))}
</div>
</div>
) : !previewMode ? (
/* ── Edit mode — Textarea ────────────────────────────── */
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={expand}
onPaste={handlePaste}
placeholder={placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
<MentionAutocomplete
textareaRef={textareaRef}
content={content}
onInsertMention={handleInsertMention}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={content}
onInsertEmoji={handleInsertShortcodeEmoji}
/>
</div>
) : (
/* Preview mode - Show how post will look */
mockEvent && (() => {
const imetaMap = parseImetaMap(mockEvent.tags);
const videos = extractVideoUrls(mockEvent.content);
const imetaAudios = Array.from(imetaMap.values())
.filter((e) => e.mime?.startsWith('audio/'))
.map((e) => e.url);
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
const webxdcApps = Array.from(imetaMap.values()).filter(
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
);
return (
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
<NoteMedia
videos={videos}
audios={audios}
imetaMap={imetaMap}
webxdcApps={webxdcApps}
event={mockEvent}
/>
</div>
);
})()
)}
{/* Content warning input */}
@@ -1258,7 +1266,7 @@ export function ComposeBox({
identifier: quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? '',
}} />
) : (
<EmbeddedNote eventId={quotedEvent.id} />
<EmbeddedNote eventId={quotedEvent.id} authorHint={quotedEvent.pubkey} />
)}
</div>
)}
+3 -3
View File
@@ -1083,9 +1083,9 @@ 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',
3063: 'Asset',
32267: 'Zapstore App',
30063: 'Zapstore Release',
3063: 'Zapstore Asset',
15128: 'Nsite',
35128: 'Nsite',
31124: 'Blobbi',
+1
View File
@@ -47,6 +47,7 @@ const LANDING_KINDS = [
30009, // Badge Definitions
10008, // Profile Badges
30008, // Profile Badges (legacy)
31124, // Blobbi
];
/** Webxdc needs a MIME-type tag filter, so it gets its own filter object. */
+5 -3
View File
@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import { cn } from '@/lib/utils';
@@ -125,7 +126,7 @@ export function ImageGallery({
))}
</div>
{/* Lightbox */}
{/* Lightbox (portals to document.body internally to escape stacking contexts) */}
{lightboxIndex !== null && lightboxIndex !== undefined && (
<Lightbox
images={images}
@@ -484,7 +485,7 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
(i) => i >= 0 && i < images.length,
);
return (
return createPortal(
<div
ref={containerRef}
className="fixed inset-0 z-[100] animate-in fade-in duration-200"
@@ -582,7 +583,8 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
{bottomBar}
</div>
)}
</div>
</div>,
document.body,
);
}
+10 -3
View File
@@ -1,4 +1,4 @@
import { Suspense, useState, useMemo, useCallback } from 'react';
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
import { Outlet } from 'react-router-dom';
import { LeftSidebar } from '@/components/LeftSidebar';
import { RightSidebar } from '@/components/RightSidebar';
@@ -8,7 +8,7 @@ import { MobileBottomNav } from '@/components/MobileBottomNav';
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { CursorFireEffect } from '@/components/CursorFireEffect';
import { Skeleton } from '@/components/ui/skeleton';
import { DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { cn } from '@/lib/utils';
@@ -106,9 +106,12 @@ function MainLayoutInner() {
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
const [drawerOpen, setDrawerOpen] = useState(false);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const centerColumnRef = useRef<HTMLDivElement>(null);
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
const { config } = useAppContext();
const { hidden: navHidden } = useScrollDirection(scrollContainer);
return (
<CenterColumnContext.Provider value={centerColumnEl}>
<DrawerContext.Provider value={openDrawer}>
<NavHiddenContext.Provider value={navHidden}>
{/* Magic Mouse fire particle overlay */}
@@ -135,7 +138,10 @@ function MainLayoutInner() {
being hidden. This depends on MobileTopBar having a transparent /
semi-transparent background — a solid top bar would obscure the
content underneath. Only active below the sidebar breakpoint. */}
<div className={cn("relative flex-1 min-w-0 sidebar:border-l sidebar:border-r border-border bg-background/85", !hideTopBar && "-mt-mobile-bar", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}>
<div
ref={(el) => { centerColumnRef.current = el; setCenterColumnEl(el); }}
className={cn("relative z-0 flex-1 min-w-0 sidebar:border-l sidebar:border-r border-border bg-background/85", !hideTopBar && "-mt-mobile-bar", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}
>
<Outlet />
{/* Desktop FAB — sticky within the feed column so it stays
@@ -174,6 +180,7 @@ function MainLayoutInner() {
)}
</NavHiddenContext.Provider>
</DrawerContext.Provider>
</CenterColumnContext.Provider>
);
}
+228 -386
View File
@@ -21,7 +21,7 @@ import {
Zap,
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type ReactNode, lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
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 })));
@@ -98,7 +98,8 @@ import { genUserName } from "@/lib/genUserName";
import { getDisplayName } from "@/lib/getDisplayName";
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls";
import { getParentEventId, isReplyEvent } from "@/lib/nostrEvents";
import { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
import { getParentEventHints, isReplyEvent } from "@/lib/nostrEvents";
import { isSingleImagePost } from "@/lib/noteContent";
import { shareOrCopy } from "@/lib/share";
import { timeAgo } from "@/lib/timeAgo";
@@ -118,6 +119,113 @@ function ProfileCardContent({ event }: { event: NostrEvent }) {
);
}
/* ──── Shared activity card shell for reaction / repost / zap / poll vote ──── */
interface ActivityCardProps {
/** The round element in the left column (icon bubble or avatar). */
icon: ReactNode;
/** The actor row content (avatar + name + label + timestamp). */
actorRow: ReactNode;
/** Optional extra content below the actor row (zap message, vote label, etc.). */
children?: ReactNode;
/** Threaded mode: connector line below icon, no bottom border. */
threaded?: boolean;
/** Last item in thread — no connector line, has bottom border. */
threadedLast?: boolean;
/** Custom connector line class. */
threadedLineClassName?: string;
className?: string;
onClick?: React.MouseEventHandler;
onAuxClick?: React.MouseEventHandler;
}
export function ActivityCard({
icon,
actorRow,
children,
threaded,
threadedLast,
threadedLineClassName,
className,
onClick,
onAuxClick,
}: ActivityCardProps) {
const isThreaded = threaded || threadedLast;
return (
<article
className={cn(
"px-4 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
isThreaded
? cn("pt-3", threaded ? "pb-0" : "pb-3 border-b border-border")
: "py-3 border-b border-border",
className,
)}
onClick={onClick}
onAuxClick={onAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{icon}
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div className={cn("flex-1 min-w-0", isThreaded ? "min-h-10 flex flex-col justify-center" : "", threaded && "pb-3")}>
{actorRow}
{children}
</div>
</div>
</article>
);
}
/** Reusable actor row: small avatar + display name + action label + timestamp. */
export interface ActorRowProps {
pubkey: string;
profileUrl: string;
avatarShape: Parameters<typeof Avatar>[0]['shape'];
picture?: string;
displayName: string;
authorEvent?: NostrEvent;
isLoading?: boolean;
label: string;
/** Extra inline elements after the label (e.g. zap amount). */
extra?: ReactNode;
/** Formatted timestamp string (e.g. timeAgo or full date). */
timestampLabel: string;
}
export function ActorRow({ pubkey, profileUrl, avatarShape, picture, displayName, authorEvent, isLoading, label, extra, timestampLabel }: ActorRowProps) {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-20" />
</div>
);
}
return (
<div className="flex items-center gap-2">
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
{authorEvent ? <EmojifiedText tags={authorEvent.tags}>{displayName}</EmojifiedText> : displayName}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">{label}</span>
{extra}
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timestampLabel}</span>
</div>
);
}
interface NoteCardProps {
event: NostrEvent;
className?: string;
@@ -219,6 +327,8 @@ export const NoteCard = memo(function NoteCard({
const zapSenderName = getDisplayName(zapSenderMeta, zapSenderPubkey);
const zapSenderUrl = useProfileUrl(zapSenderPubkey, zapSenderMeta);
const pollVoteLabel = usePollVoteLabel(event);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = getDisplayName(metadata, event.pubkey);
@@ -249,7 +359,9 @@ export const NoteCard = memo(function NoteCard({
target.closest("[data-radix-dialog-content]") ||
target.closest("[data-vaul-drawer]") ||
target.closest("[data-vaul-drawer-overlay]") ||
target.closest('[data-testid="zap-modal"]')
target.closest('[data-testid="zap-modal"]') ||
target.closest("button") ||
target.closest("a")
) {
return;
}
@@ -264,7 +376,9 @@ export const NoteCard = memo(function NoteCard({
target.closest("[data-radix-dialog-content]") ||
target.closest("[data-vaul-drawer]") ||
target.closest("[data-vaul-drawer-overlay]") ||
target.closest('[data-testid="zap-modal"]')
target.closest('[data-testid="zap-modal"]') ||
target.closest("button") ||
target.closest("a")
) {
return;
}
@@ -291,6 +405,7 @@ export const NoteCard = memo(function NoteCard({
const isProfileBadges = event.kind === 10008 || event.kind === 30008;
const isBadge = isBadgeDefinition || isProfileBadges;
const isReaction = event.kind === 7;
const isPollVote = event.kind === 1018;
const isRepost = event.kind === 6 || event.kind === 16;
const isPhoto = event.kind === 20;
const isNormalVideo = event.kind === 21;
@@ -335,6 +450,7 @@ export const NoteCard = memo(function NoteCard({
!isEmojiPack &&
!isBadge &&
!isReaction &&
!isPollVote &&
!isRepost &&
!isPhoto &&
!isVideo &&
@@ -420,11 +536,12 @@ export const NoteCard = memo(function NoteCard({
return [parentAuthor];
}, [event.tags, isTextNote, isReply, event.pubkey]);
// Extract the parent event ID for reply hover card preview
const parentEventId = useMemo(() => {
// Extract the parent event ID + relay/author hints for reply hover card preview
const parentHints = useMemo(() => {
if (!isReply) return undefined;
return getParentEventId(event);
return getParentEventHints(event);
}, [event, isReply]);
const parentEventId = parentHints?.id;
// Kind 34236 specific
const imeta = useMemo(
@@ -466,7 +583,12 @@ export const NoteCard = memo(function NoteCard({
{/* Reply context (kind 1) or comment context (kind 1111) — shown above content */}
{isComment && <CommentContext event={event} />}
{isReply && (
<ReplyContext pubkeys={replyToPubkeys} parentEventId={parentEventId} />
<ReplyContext
pubkeys={replyToPubkeys}
parentEventId={parentEventId}
parentRelayHint={parentHints?.relayHint}
parentAuthorHint={parentHints?.authorHint}
/>
)}
{/* Content — kind-based dispatch, guarded by NIP-36 content-warning */}
@@ -539,11 +661,23 @@ export const NoteCard = memo(function NoteCard({
) : isNsite ? (
<NsiteCard event={event} />
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} compact />
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreAppContent event={event} compact />
</div>
</div>
) : isZapstoreRelease ? (
<ZapstoreReleaseContent event={event} compact />
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreReleaseContent event={event} compact />
</div>
</div>
) : isZapstoreAsset ? (
<ZapstoreAssetContent event={event} compact />
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreAssetContent event={event} compact />
</div>
</div>
) : isAppHandler ? (
<AppHandlerContent event={event} compact />
) : isEncryptedDM ? (
@@ -786,399 +920,107 @@ export const NoteCard = memo(function NoteCard({
);
}
// ── Reaction layout (kind 7) — compact activity-style card ──
// ── Reaction layout (kind 7) ──
if (isReaction) {
// Threaded reaction (used in AncestorThread with connector line)
if (threaded || threadedLast) {
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{/* Reaction emoji bubble instead of avatar */}
<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="h-5 w-5 object-contain"
/>
</div>
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div
className={cn(
"flex-1 min-w-0 flex items-center min-h-10",
threaded && "pb-3",
)}
>
<div className="flex items-center gap-2">
{author.isLoading ? (
<Skeleton className="size-6 rounded-full shrink-0" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage
src={metadata?.picture}
alt={displayName}
/>
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
)}
{author.isLoading ? (
<Skeleton className="h-3.5 w-20" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
)}
<span className="text-sm text-muted-foreground">reacted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</div>
</div>
</div>
</article>
);
}
// Normal reaction card (standalone or in feed)
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex items-center gap-3">
{/* Large reaction emoji */}
<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="h-6 w-6 object-contain"
/>
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-pink-500/10 shrink-0 text-lg leading-none", iconSize)}>
<ReactionEmoji content={event.content} tags={event.tags} className="h-5 w-5 object-contain" />
</div>
{/* Author + "reacted" label */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-4 w-24" />
</>
) : (
<>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground">reacted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</>
)}
</div>
</div>
</article>
}
actorRow={
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reacted" timestampLabel={timeAgo(event.created_at)} />
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
/>
);
}
// ── Repost layout (kind 6 / 16) — compact activity-style card ──
// ── Repost layout (kind 6 / 16) ──
if (isRepost) {
// Threaded repost (used in AncestorThread with connector line)
if (threaded || threadedLast) {
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{/* Repost icon bubble instead of avatar */}
<div className="flex items-center justify-center size-10 rounded-full bg-accent/10 shrink-0">
<RepostIcon className="size-5 text-accent" />
</div>
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div
className={cn(
"flex-1 min-w-0 flex items-center min-h-10",
threaded && "pb-3",
)}
>
<div className="flex items-center gap-2">
{author.isLoading ? (
<Skeleton className="size-6 rounded-full shrink-0" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage
src={metadata?.picture}
alt={displayName}
/>
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
)}
{author.isLoading ? (
<Skeleton className="h-3.5 w-20" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
)}
<span className="text-sm text-muted-foreground">reposted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</div>
</div>
</div>
</article>
);
}
// Normal repost card (standalone or in feed)
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex items-center gap-3">
{/* Repost icon */}
<div className="flex items-center justify-center size-11 rounded-full bg-accent/10 shrink-0">
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-accent/10 shrink-0", iconSize)}>
<RepostIcon className="size-5 text-accent" />
</div>
{/* Author + "reposted" label */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-4 w-24" />
</>
) : (
<>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground">reposted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</>
)}
</div>
</div>
</article>
}
actorRow={
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reposted" timestampLabel={timeAgo(event.created_at)} />
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
/>
);
}
// ── Zap receipt layout (kind 9735) — mirrors reaction layout exactly ──
// ── Zap receipt layout (kind 9735) ──
if (isZap) {
const zapAmountSats = Math.floor(extractZapAmount(event) / 1000);
const zapMessage = extractZapMessage(event);
const zapActorRow = (
<div className="flex items-center gap-2">
{zapSender.isLoading ? (
<>
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-20" />
</>
) : (
<>
{zapSenderPubkey && (
<ProfileHoverCard pubkey={zapSenderPubkey} asChild>
<Link to={zapSenderUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={zapSenderShape} className="size-6">
<AvatarImage src={zapSenderMeta?.picture} alt={zapSenderName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{zapSenderName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
)}
{zapSenderPubkey && (
<ProfileHoverCard pubkey={zapSenderPubkey} asChild>
<Link to={zapSenderUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
{zapSender.data?.event ? <EmojifiedText tags={zapSender.data.event.tags}>{zapSenderName}</EmojifiedText> : zapSenderName}
</Link>
</ProfileHoverCard>
)}
<span className="text-sm text-muted-foreground shrink-0">zapped</span>
{zapAmountSats > 0 && (
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-amber-500/10 shrink-0", iconSize)}>
<Zap className="size-5 text-amber-500 fill-amber-500" />
</div>
}
actorRow={
<ActorRow pubkey={zapSenderPubkey} profileUrl={zapSenderUrl} avatarShape={zapSenderShape} picture={zapSenderMeta?.picture}
displayName={zapSenderName} authorEvent={zapSender.data?.event} isLoading={zapSender.isLoading} label="zapped" timestampLabel={timeAgo(event.created_at)}
extra={zapAmountSats > 0 ? (
<span className="text-sm font-semibold text-amber-500 shrink-0">
{formatNumber(zapAmountSats)} {zapAmountSats === 1 ? 'sat' : 'sats'}
</span>
)}
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
</>
)}
</div>
);
if (threaded || threadedLast) {
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className="flex items-center justify-center size-10 rounded-full bg-amber-500/10 shrink-0">
<Zap className="size-5 text-amber-500 fill-amber-500" />
</div>
{threaded && <div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />}
</div>
<div className={cn("flex-1 min-w-0 flex flex-col justify-center min-h-10", threaded && "pb-3")}>
{zapActorRow}
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">&ldquo;{zapMessage}&rdquo;</p>}
</div>
</div>
</article>
);
}
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
) : undefined}
/>
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-11 rounded-full bg-amber-500/10 shrink-0">
<Zap className="size-5 text-amber-500 fill-amber-500" />
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">&ldquo;{zapMessage}&rdquo;</p>}
</ActivityCard>
);
}
// ── Poll vote layout (kind 1018) ──
if (isPollVote) {
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
icon={
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className={iconSize}>
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
}
actorRow={
<div className="flex items-center gap-1.5">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">voted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
</div>
<div className="flex-1 min-w-0 flex flex-col">
{zapActorRow}
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">&ldquo;{zapMessage}&rdquo;</p>}
</div>
</div>
</article>
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
>
{pollVoteLabel && <p className="text-sm font-semibold mt-0.5 truncate">{pollVoteLabel}</p>}
</ActivityCard>
);
}
@@ -2014,15 +1856,15 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
32267: {
icon: Package,
action: "published an app",
action: "published a Zapstore app",
},
30063: {
icon: Package,
action: "published a release",
action: "published a Zapstore release",
},
3063: {
icon: Package,
action: "published an asset",
action: "published a Zapstore asset",
},
31990: {
icon: Package,
+103 -83
View File
@@ -1,37 +1,22 @@
import type { NostrEvent } from "@nostrify/nostrify";
import { ExternalLink, FileText, Globe, Server } from "lucide-react";
import { nip19 } from "nostr-tools";
import { ExternalLink, FileText, Globe, Play, Server } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ExternalFavicon } from "@/components/ExternalFavicon";
import { NsitePreviewDialog } from "@/components/NsitePreviewDialog";
import { Skeleton } from "@/components/ui/skeleton";
import { useLinkPreview } from "@/hooks/useLinkPreview";
import { getNsiteSubdomain } from "@/lib/nsiteSubdomain";
import { cn } from "@/lib/utils";
interface NsiteCardProps {
event: NostrEvent;
}
/** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */
function hexToBase36(hex: string): string {
let n = 0n;
for (let i = 0; i < hex.length; i++) {
n = n * 16n + BigInt(parseInt(hex[i], 16));
}
const b36 = n.toString(36);
return b36.padStart(50, "0");
}
/** Build the nsite.lol gateway URL for an nsite event. */
function getNsiteUrl(event: NostrEvent): string {
const dTag = event.tags.find(([n]) => n === "d")?.[1];
if (event.kind === 35128 && dTag) {
const pubkeyB36 = hexToBase36(event.pubkey);
return `https://${pubkeyB36}${dTag}.nsite.lol`;
}
const npub = nip19.npubEncode(event.pubkey);
return `https://${npub}.nsite.lol`;
return `https://${getNsiteSubdomain(event)}.nsite.lol`;
}
/** Renders an nsite deployment card with a rich link preview. */
@@ -51,90 +36,125 @@ export function NsiteCard({ event }: NsiteCardProps) {
const image = preview?.thumbnail_url;
const previewTitle = preview?.title;
const [previewOpen, setPreviewOpen] = useState(false);
if (isLoading) {
return <NsiteCardSkeleton />;
}
return (
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
<>
<div
className={cn(
"group block mt-2 rounded-2xl border border-border overflow-hidden",
"group mt-2 rounded-2xl border border-border overflow-hidden",
"hover:bg-secondary/40 transition-colors",
)}
onClick={(e) => e.stopPropagation()}
>
{/* Link preview thumbnail */}
{image && (
<div className="w-full overflow-hidden bg-muted">
<img
src={image}
alt=""
className="w-full h-[180px] object-cover transition-transform duration-300 group-hover:scale-[1.02]"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = "none";
}}
/>
</div>
)}
<div className="px-3.5 py-2.5 space-y-1.5">
{/* Title with favicon */}
<div className="flex items-center gap-2 min-w-0">
<ExternalFavicon url={siteUrl} size={16} className="shrink-0" />
<p className="text-sm font-semibold leading-snug line-clamp-2">
{previewTitle || displayName}
</p>
</div>
{/* Description — prefer event description (it's curated), fall back to OEmbed author */}
{(description || preview?.author_name) && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{description || preview?.author_name}
</p>
{/* Link preview thumbnail — clicking navigates to the site */}
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
className="block"
onClick={(e) => e.stopPropagation()}
>
{image && (
<div className="w-full overflow-hidden bg-muted">
<img
src={image}
alt=""
className="w-full h-[180px] object-cover transition-transform duration-300 group-hover:scale-[1.02]"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = "none";
}}
/>
</div>
)}
{/* Deployment stats + source link */}
<div className="flex items-center gap-3 pt-0.5 text-[11px] text-muted-foreground">
{pathTags.length > 0 && (
<span className="inline-flex items-center gap-1">
<FileText className="size-3" />
{pathTags.length} {pathTags.length === 1 ? "file" : "files"}
</span>
<div className="px-3.5 pt-2.5 pb-1.5 space-y-1.5">
{/* Title with favicon */}
<div className="flex items-center gap-2 min-w-0">
<ExternalFavicon url={siteUrl} size={16} className="shrink-0" />
<p className="text-sm font-semibold leading-snug line-clamp-2">
{previewTitle || displayName}
</p>
</div>
{/* Description — prefer event description (it's curated), fall back to OEmbed author */}
{(description || preview?.author_name) && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{description || preview?.author_name}
</p>
)}
{serverTags.length > 0 && (
<span className="inline-flex items-center gap-1">
<Server className="size-3" />
{serverTags.length} {serverTags.length === 1 ? "server" : "servers"}
</span>
{/* Deployment stats */}
{(pathTags.length > 0 || serverTags.length > 0) && (
<div className="flex items-center gap-3 pt-0.5 text-[11px] text-muted-foreground">
{pathTags.length > 0 && (
<span className="inline-flex items-center gap-1">
<FileText className="size-3" />
{pathTags.length} {pathTags.length === 1 ? "file" : "files"}
</span>
)}
{serverTags.length > 0 && (
<span className="inline-flex items-center gap-1">
<Server className="size-3" />
{serverTags.length} {serverTags.length === 1 ? "server" : "servers"}
</span>
)}
</div>
)}
{sourceUrl && (
</div>
</a>
{/* Action row */}
<div className="px-3.5 pb-2.5 flex items-center gap-2">
<Button
size="sm"
className="h-7 text-xs"
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
>
<Play className="size-3 mr-1" />
Run
</Button>
{sourceUrl ? (
<Button asChild size="sm" variant="secondary" className="h-7 text-xs">
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
"ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded-full",
"hover:bg-primary/10 hover:text-primary transition-colors",
)}
onClick={(e) => e.stopPropagation()}
>
<Globe className="size-3" />
<span>Source</span>
<Globe className="size-3 mr-1" />
Source
</a>
)}
{!sourceUrl && (
<span className="ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded-full hover:bg-primary/10 hover:text-primary transition-colors">
<ExternalLink className="size-3" />
<span>Visit</span>
</span>
)}
</div>
</Button>
) : (
<Button asChild size="sm" variant="secondary" className="h-7 text-xs">
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-3 mr-1" />
Visit
</a>
</Button>
)}
</div>
</a>
</div>
<NsitePreviewDialog
event={event}
appName={previewTitle || displayName || "nsite"}
appPicture={undefined}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
</>
);
}
+389
View File
@@ -0,0 +1,389 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Package, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useCenterColumn } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { APP_BLOSSOM_SERVERS, getEffectiveBlossomServers } from '@/lib/appBlossom';
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
interface Rect { left: number; top: number; width: number; height: number }
/** Track the viewport-relative bounding rect of an element, updating on resize. */
function useElementRect(el: HTMLElement | null): Rect | null {
const [rect, setRect] = useState<Rect | null>(null);
useEffect(() => {
if (!el) { setRect(null); return; }
const measure = () => {
const r = el.getBoundingClientRect();
setRect({ left: r.left, top: r.top, width: r.width, height: r.height });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
window.addEventListener('resize', measure);
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
}, [el]);
return rect;
}
/** The wildcard-to-localhost preview domain used by Shakespeare's iframe-fetch-client. */
const PREVIEW_DOMAIN = 'local-shakespeare.dev';
interface JSONRPCFetchRequest {
jsonrpc: '2.0';
method: 'fetch';
params: {
request: {
url: string;
method: string;
headers: Record<string, string>;
body: string | null;
};
};
id: number;
}
interface JSONRPCResponse {
jsonrpc: '2.0';
result?: {
status: number;
statusText: string;
headers: Record<string, string>;
body: string | null;
};
error?: {
code: number;
message: string;
};
id: number;
}
/**
* Build the path→sha256 manifest from a nsite event's `path` tags.
* Each path tag has the format: ["path", "/file/path", "<sha256>"]
*/
function buildManifest(event: NostrEvent): Map<string, string> {
const manifest = new Map<string, string>();
for (const tag of event.tags) {
if (tag[0] === 'path' && tag[1] && tag[2]) {
manifest.set(tag[1], tag[2]);
}
}
return manifest;
}
/**
* Resolve the Blossom servers for a nsite event.
* Prefers the `server` tags on the event; falls back to the provided app servers.
*/
function resolveServers(event: NostrEvent, appServers: string[]): string[] {
const eventServers = event.tags
.filter(([name]) => name === 'server')
.map(([, url]) => url)
.filter((url) => {
try { new URL(url); return true; } catch { return false; }
});
return eventServers.length > 0 ? eventServers : appServers;
}
/**
* Fetch a blob from the given sha256 by trying each Blossom server in order.
* Returns a Response from the first server that responds successfully, or
* throws if all servers fail.
*/
async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Response> {
let lastError: unknown;
for (const server of servers) {
const base = server.replace(/\/+$/, '');
const url = `${base}/${sha256}`;
try {
const res = await fetch(url);
if (res.ok) return res;
} catch (err) {
lastError = err;
}
}
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
}
/**
* Guess a MIME type from a file path extension.
* Falls back to 'application/octet-stream' for unknown extensions.
*/
function guessMimeType(path: string): string {
const ext = path.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
html: 'text/html',
htm: 'text/html',
css: 'text/css',
js: 'application/javascript',
mjs: 'application/javascript',
json: 'application/json',
svg: 'image/svg+xml',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
ico: 'image/x-icon',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
otf: 'font/otf',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
wav: 'audio/wav',
wasm: 'application/wasm',
xml: 'application/xml',
txt: 'text/plain',
md: 'text/markdown',
};
return map[ext] ?? 'application/octet-stream';
}
interface NsitePreviewDialogProps {
/** The nsite event (kind 15128 or 35128) containing path and server tags. */
event: NostrEvent;
/** Display name for the app. */
appName: string;
/** Optional app icon URL. */
appPicture?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* An in-app preview panel that covers the center column and loads an nsite in
* a sandboxed iframe, using the Shakespeare iframe-fetch-client protocol over
* local-shakespeare.dev.
*
* Instead of proxying requests through an nsite gateway, this component serves
* files directly from Blossom servers using the manifest data embedded in the
* nsite event's `path` tags. Each path tag maps a file path to its sha256 hash,
* which is used to construct a Blossom content-addressed URL.
*
* The panel is portaled into the center column DOM element (via CenterColumnContext)
* and uses `position: fixed` to fill the viewport column area.
*
* The parent window intercepts JSON-RPC `fetch` requests from the iframe and
* serves them directly from Blossom, so the SPA can run without any gateway dependency.
*/
export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenChange }: NsitePreviewDialogProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const centerColumn = useCenterColumn();
const columnRect = useElementRect(open ? centerColumn : null);
const { config } = useAppContext();
// Derive the iframe origin from the NIP-5A canonical subdomain for this event
const subdomain = getNsiteSubdomain(event);
const iframeOrigin = `https://${subdomain}.${PREVIEW_DOMAIN}`;
const iframeSrc = `${iframeOrigin}/`;
// Build the manifest and server list from the event (memoised per event identity)
const manifest = useRef<Map<string, string>>(new Map());
const servers = useRef<string[]>([]);
useEffect(() => {
manifest.current = buildManifest(event);
const appServers = getEffectiveBlossomServers(
config.blossomServerMetadata,
config.useAppBlossomServers ?? true,
);
servers.current = resolveServers(event, appServers.length > 0 ? appServers : APP_BLOSSOM_SERVERS.servers);
}, [event, config.blossomServerMetadata, config.useAppBlossomServers]);
/** Send a JSON-RPC response back to the iframe. */
const sendResponse = useCallback((message: JSONRPCResponse) => {
iframeRef.current?.contentWindow?.postMessage(message, iframeOrigin);
}, [iframeOrigin]);
/** Handle a fetch request from the iframe by serving files directly from Blossom. */
const handleFetch = useCallback(async (request: JSONRPCFetchRequest) => {
const { params, id } = request;
const { request: fetchRequest } = params;
try {
const requestedUrl = new URL(fetchRequest.url);
// Only serve requests for our iframe origin
if (requestedUrl.origin !== iframeOrigin) {
sendResponse({
jsonrpc: '2.0',
error: { code: -32003, message: 'Origin mismatch' },
id,
});
return;
}
// Strip query string from path for manifest lookup
const requestedPath = requestedUrl.pathname;
// Look up the sha256 for this path in the manifest.
// If not found, fall back to /index.html (SPA client-side routing).
let sha256 = manifest.current.get(requestedPath);
let servingPath = requestedPath;
if (!sha256) {
sha256 = manifest.current.get('/index.html');
servingPath = '/index.html';
}
if (!sha256) {
sendResponse({
jsonrpc: '2.0',
result: {
status: 404,
statusText: 'Not Found',
headers: { 'Content-Type': 'text/plain' },
body: btoa('Not Found'),
},
id,
});
return;
}
// Fetch the blob from Blossom, trying each server in order
const res = await fetchFromBlossom(sha256, servers.current);
// Read as ArrayBuffer → base64 so binary assets work correctly
const buffer = await res.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const bodyBase64 = btoa(binary);
// Always determine content type from the file extension.
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
// files), which causes browsers to reject module scripts. The file path from
// the manifest is authoritative for the correct MIME type.
const contentType = guessMimeType(servingPath);
// The iframe-fetch-client (main.js) checks headers with Title-Case keys
// (e.g. "Content-Type"), and does an exact equality check against "text/html"
// for routing decisions.
const responseHeaders: Record<string, string> = {
'Content-Type': contentType,
'Content-Length': String(bytes.byteLength),
};
sendResponse({
jsonrpc: '2.0',
result: {
status: 200,
statusText: 'OK',
headers: responseHeaders,
body: bodyBase64,
},
id,
});
} catch (err) {
sendResponse({
jsonrpc: '2.0',
error: { code: -32002, message: String(err) },
id,
});
}
}, [iframeOrigin, sendResponse]);
/** Handle navigation state updates from the iframe (no-op). */
const handleNavigationState = useCallback((_params: {
currentUrl: string;
canGoBack: boolean;
canGoForward: boolean;
}) => {
// intentionally empty
}, []);
// Listen for messages from the iframe
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== iframeOrigin) return;
const message = event.data;
if (message?.jsonrpc !== '2.0') return;
if (message.method === 'fetch') {
handleFetch(message as JSONRPCFetchRequest);
} else if (message.method === 'updateNavigationState') {
handleNavigationState(message.params);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [iframeOrigin, handleFetch, handleNavigationState]);
if (!open || !centerColumn || !columnRect) return null;
// If the user has scrolled down, columnRect.top is negative (the column top
// is above the viewport). Clamp to 0 so the panel always starts at the
// viewport top edge and never grows taller than the viewport.
const panelTop = Math.max(0, columnRect.top);
const panelHeight = window.innerHeight - panelTop;
return createPortal(
<div
className="fixed z-50 flex flex-col bg-background"
style={{
left: columnRect.left,
top: panelTop,
width: columnRect.width,
height: panelHeight,
}}
>
{/* Nav bar */}
<div className="h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{appPicture ? (
<img
src={appPicture}
alt={appName}
className="size-6 rounded-md object-cover shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Package className="size-3.5 text-primary/50" />
</div>
)}
<span className="text-sm font-medium truncate">{appName}</span>
</div>
{/* Close */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => onOpenChange(false)}
title="Close"
>
<X className="size-3.5" />
</Button>
</div>
{/* iframe */}
<div className="flex-1 min-h-0 bg-background">
<iframe
key={`${subdomain}-${open}`}
ref={iframeRef}
src={iframeSrc}
className="w-full h-full border-0"
title={`${appName} preview`}
sandbox="allow-scripts allow-same-origin allow-forms"
/>
</div>
</div>,
document.body,
);
}
+362 -22
View File
@@ -1,10 +1,22 @@
import { useState, useMemo } from 'react';
import { BarChart3, CheckCircle2, Clock } from 'lucide-react';
import { Link } from 'react-router-dom';
import { BarChart3, CheckCircle2, Clock, X, ChevronRight } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { NoteContent } from '@/components/NoteContent';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { EmojifiedText } from '@/components/CustomEmoji';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { getAvatarShape } from '@/lib/avatarShape';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -61,6 +73,61 @@ function tallyVotes(
return counts;
}
/** Get voter events for a specific option ID. */
function getVotersForOption(
votes: NostrEvent[],
optionId: string,
pollType: string,
): NostrEvent[] {
return votes.filter((vote) => {
const responseTags = vote.tags.filter(([n]) => n === 'response');
if (pollType === 'singlechoice') {
return responseTags[0]?.[1] === optionId;
} else {
return responseTags.some(([, id]) => id === optionId);
}
});
}
/** Clickable avatar stack + "N votes" label. */
function VoterAvatarsButton({
votes,
totalVotes,
authorsMap,
onClick,
className,
}: {
votes: NostrEvent[];
totalVotes: number;
authorsMap?: Map<string, { pubkey: string; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
onClick: () => void;
className?: string;
}) {
return (
<button onClick={onClick} className={cn('flex items-center gap-1.5 group', className)}>
<div className="flex -space-x-1.5">
{votes.slice(0, 6).map((vote) => {
const authorData = authorsMap?.get(vote.pubkey);
const metadata = authorData?.metadata;
const avatarShape = getAvatarShape(metadata);
const name = metadata?.name || genUserName(vote.pubkey);
return (
<Avatar key={vote.pubkey} shape={avatarShape} className="size-5 ring-1 ring-background">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
);
})}
</div>
<span className="text-xs text-muted-foreground group-hover:text-foreground transition-colors">
{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}
</span>
</button>
);
}
export function PollContent({ event }: { event: NostrEvent }) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
@@ -72,6 +139,10 @@ export function PollContent({ event }: { event: NostrEvent }) {
const endsAt = getTag(event.tags, 'endsAt');
const isExpired = endsAt ? Number(endsAt) < Math.floor(Date.now() / 1000) : false;
// Modal state
const [votersModalOpen, setVotersModalOpen] = useState(false);
const [votersModalOptionId, setVotersModalOptionId] = useState<string | null>(null);
// Fetch vote events
const { data: votes } = useQuery<NostrEvent[]>({
queryKey: ['poll-votes', event.id],
@@ -126,6 +197,19 @@ export function PollContent({ event }: { event: NostrEvent }) {
});
};
// Collect all voter pubkeys for batch profile fetching
const allVoterPubkeys = useMemo(() => {
if (!votes) return [];
return votes.map((v) => v.pubkey);
}, [votes]);
const { data: authorsMap } = useAuthors(allVoterPubkeys);
const openVotersModal = (optionId: string | null) => {
setVotersModalOptionId(optionId);
setVotersModalOpen(true);
};
return (
<div className="mt-2" onClick={(e) => e.stopPropagation()}>
{/* Question */}
@@ -133,7 +217,7 @@ export function PollContent({ event }: { event: NostrEvent }) {
<NoteContent event={event} />
</div>
{/* Poll type + expiry badges */}
{/* Poll type + expiry badges + voter avatars + vote count */}
<div className="flex items-center gap-2 mt-2">
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground bg-secondary/60 px-2 py-0.5 rounded-full">
<BarChart3 className="size-3" />
@@ -145,6 +229,17 @@ export function PollContent({ event }: { event: NostrEvent }) {
Ended
</span>
)}
{/* Voter avatars + count pushed to the right */}
{showResults && totalVotes > 0 && (
<VoterAvatarsButton
votes={votes ?? []}
totalVotes={totalVotes}
authorsMap={authorsMap}
onClick={() => openVotersModal(null)}
className="ml-auto"
/>
)}
</div>
{/* Options */}
@@ -192,26 +287,271 @@ export function PollContent({ event }: { event: NostrEvent }) {
})}
</div>
{/* Vote button or total */}
<div className="flex items-center justify-between mt-3">
<span className="text-xs text-muted-foreground">
{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}
</span>
{!showResults && user && (
<button
onClick={handleVote}
disabled={!selectedOption}
className={cn(
'text-sm font-semibold px-4 py-1.5 rounded-full transition-colors',
selectedOption
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-secondary text-muted-foreground cursor-not-allowed',
)}
>
Vote
</button>
)}
</div>
{/* Vote button + voter avatars (voting mode only) */}
{!showResults && (
<div className="flex items-center justify-between mt-3">
{totalVotes > 0 ? (
<VoterAvatarsButton
votes={votes ?? []}
totalVotes={totalVotes}
authorsMap={authorsMap}
onClick={() => openVotersModal(null)}
/>
) : (
<span className="text-xs text-muted-foreground">0 votes</span>
)}
{user && (
<button
onClick={handleVote}
disabled={!selectedOption}
className={cn(
'text-sm font-semibold px-4 py-1.5 rounded-full transition-colors',
selectedOption
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-secondary text-muted-foreground cursor-not-allowed',
)}
>
Vote
</button>
)}
</div>
)}
{/* Voters Modal */}
<PollVotersModal
open={votersModalOpen}
onOpenChange={setVotersModalOpen}
allVotes={votes ?? []}
options={options}
pollType={pollType}
initialOptionId={votersModalOptionId}
authorsMap={authorsMap}
/>
</div>
);
}
/* ──── Poll Voters Modal ──── */
interface PollVotersModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
allVotes: NostrEvent[];
options: PollOption[];
pollType: string;
initialOptionId?: string | null;
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
}
function PollVotersModal({ open, onOpenChange, allVotes, options, pollType, initialOptionId, authorsMap }: PollVotersModalProps) {
const [activeFilter, setActiveFilter] = useState<string | null>(initialOptionId ?? null);
// Sync filter when modal opens with a specific option
useMemo(() => {
if (open) setActiveFilter(initialOptionId ?? null);
}, [open, initialOptionId]);
// Build a map from option ID to label for display
const optionLabelMap = useMemo(() => {
const map = new Map<string, string>();
for (const opt of options) {
map.set(opt.id, opt.label);
}
return map;
}, [options]);
// Filter voters based on active filter
const filteredVoters = useMemo(() => {
if (activeFilter === null) return allVotes;
return getVotersForOption(allVotes, activeFilter, pollType);
}, [allVotes, activeFilter, pollType]);
// Tally per option for the count badges
const tally = useMemo(() => tallyVotes(allVotes, pollType), [allVotes, pollType]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[460px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12">
<DialogTitle className="text-base font-semibold">Voters</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Option filter bars — scrollable when more than 3 */}
<ScrollArea className={cn('px-4', options.length > 2 && 'max-h-[120px]')}>
<div className="space-y-1.5">
{/* "All" bar */}
<button
onClick={() => setActiveFilter(null)}
className={cn(
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
activeFilter === null ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
)}
>
<div
className={cn(
'absolute inset-0 transition-all duration-500',
activeFilter === null ? 'bg-primary/15' : 'bg-secondary/40',
)}
style={{ width: '100%' }}
/>
<div className="relative flex items-center justify-between px-3 py-2">
<span className={cn('text-sm', activeFilter === null && 'font-semibold')}>All</span>
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
{allVotes.length}
</span>
</div>
</button>
{/* Per-option bars */}
{options.map((opt) => {
const count = tally.get(opt.id) ?? 0;
const pct = allVotes.length > 0 ? Math.round((count / allVotes.length) * 100) : 0;
const isActive = activeFilter === opt.id;
return (
<button
key={opt.id}
onClick={() => setActiveFilter(opt.id)}
className={cn(
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
isActive ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
)}
>
<div
className={cn(
'absolute inset-0 transition-all duration-500',
isActive ? 'bg-primary/15' : 'bg-secondary/40',
)}
style={{ width: `${pct}%` }}
/>
<div className="relative flex items-center justify-between px-3 py-2">
<span className={cn('text-sm break-words min-w-0', isActive && 'font-semibold')}>{opt.label}</span>
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
{count}
</span>
</div>
</button>
);
})}
</div>
</ScrollArea>
{/* Primary accent divider — only when scrollbox is active */}
{options.length > 2 && <div className="mx-4 h-1 bg-primary rounded-full" />}
{/* Voter list */}
<ScrollArea className="max-h-[60vh]">
{filteredVoters.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm">
No votes yet
</div>
) : (
<div className="divide-y divide-border">
{filteredVoters.map((vote) => (
<VoterRow
key={vote.id}
vote={vote}
optionLabelMap={optionLabelMap}
pollType={pollType}
authorsMap={authorsMap}
/>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}
/* ──── Voter Row ──── */
interface VoterRowProps {
vote: NostrEvent;
optionLabelMap: Map<string, string>;
pollType: string;
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
}
function VoterRow({ vote, optionLabelMap, pollType, authorsMap }: VoterRowProps) {
// Use batch-fetched author data if available, fall back to individual fetch
const individualAuthor = useAuthor(authorsMap?.has(vote.pubkey) ? undefined : vote.pubkey);
const authorData = authorsMap?.get(vote.pubkey) ?? individualAuthor.data;
const metadata = authorData?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(vote.pubkey);
const nevent = useMemo(
() => nip19.neventEncode({ id: vote.id, author: vote.pubkey }),
[vote.id, vote.pubkey],
);
// Resolve which option(s) this person voted for
const votedOptions = useMemo(() => {
const responseTags = vote.tags.filter(([n]) => n === 'response');
if (pollType === 'singlechoice') {
const id = responseTags[0]?.[1];
const label = id ? optionLabelMap.get(id) : undefined;
return label ? [label] : [];
}
const labels: string[] = [];
const seen = new Set<string>();
for (const [, id] of responseTags) {
if (id && !seen.has(id)) {
seen.add(id);
const label = optionLabelMap.get(id);
if (label) labels.push(label);
}
}
return labels;
}, [vote.tags, pollType, optionLabelMap]);
return (
<Link
to={`/${nevent}`}
onClick={() => {
// Close any open dialogs by dispatching escape
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
}}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
>
<Avatar shape={avatarShape} className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm truncate">
{authorData?.event ? (
<EmojifiedText tags={authorData.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={vote.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</div>
<div className="flex items-center gap-2">
{votedOptions.length > 0 && (
<span className="text-xs text-muted-foreground truncate">
{votedOptions.join(', ')}
</span>
)}
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(vote.created_at)}</span>
</div>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
+20 -5
View File
@@ -28,6 +28,9 @@ import { WeatherStationCard } from '@/components/WeatherStationCard';
/** Media-native kinds shown in the sidebar (excludes kind 1 text notes and kind 1111 comments). */
const SIDEBAR_MEDIA_KINDS = [20, 21, 22, 34236, 36787, 34139, 30054, 30055];
/** Maximum number of media tiles shown in the sidebar. */
const SIDEBAR_MEDIA_LIMIT = 9;
/** Simple email regex for display purposes. */
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -492,17 +495,29 @@ export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }:
const { config } = useAppContext();
const { nostr } = useNostr();
// Fetch media-native events directly using a kind whitelist (no search extension).
// Single query: fetch media-native events, then fill remaining slots with kind 1 media if needed.
const { data: sidebarEvents, isPending: mediaLoading } = useQuery({
queryKey: ['sidebar-media', pubkey ?? ''],
queryFn: async ({ signal }) => {
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const events = await nostr.query(
[{ kinds: SIDEBAR_MEDIA_KINDS, authors: [pubkey!], limit: 20 }],
const now = Math.floor(Date.now() / 1000);
const primaryEvents = await nostr.query(
[{ kinds: SIDEBAR_MEDIA_KINDS, authors: [pubkey!], limit: SIDEBAR_MEDIA_LIMIT }],
{ signal: querySignal },
);
const now = Math.floor(Date.now() / 1000);
return events.filter((e) => e.created_at <= now).sort((a, b) => b.created_at - a.created_at);
const primary = primaryEvents.filter((e) => e.created_at <= now).sort((a, b) => b.created_at - a.created_at);
// Only fetch kind 1 fallback if there aren't enough media-native events.
if (primary.length >= SIDEBAR_MEDIA_LIMIT) return primary;
const fallbackEvents = await nostr.query(
[{ kinds: [1], authors: [pubkey!], search: 'media:true', limit: SIDEBAR_MEDIA_LIMIT } as { kinds: number[]; authors: string[]; search: string; limit: number }],
{ signal: querySignal },
);
const fallback = fallbackEvents.filter((e) => e.created_at <= now).sort((a, b) => b.created_at - a.created_at);
return [...primary, ...fallback];
},
enabled: !!pubkey,
staleTime: 30_000,
+12 -2
View File
@@ -12,6 +12,10 @@ interface ReplyContextProps {
pubkeys: string[];
/** Hex event ID of the parent post being replied to. */
parentEventId?: string;
/** Relay URL hint for fetching the parent event. */
parentRelayHint?: string;
/** Author pubkey hint for NIP-65 outbox resolution of the parent event. */
parentAuthorHint?: string;
className?: string;
}
@@ -20,7 +24,7 @@ interface ReplyContextProps {
* When parentEventId is provided, hovering over the line shows an embedded preview of the parent post.
* Used consistently across NoteCard and notification views.
*/
export function ReplyContext({ pubkeys, parentEventId, className }: ReplyContextProps) {
export function ReplyContext({ pubkeys, parentEventId, parentRelayHint, parentAuthorHint, className }: ReplyContextProps) {
// Filter out any undefined/empty pubkeys defensively
const validPubkeys = pubkeys.filter(Boolean);
// Show max 2 authors for cleaner UI
@@ -38,7 +42,13 @@ export function ReplyContext({ pubkeys, parentEventId, className }: ReplyContext
className="w-80 p-0 rounded-2xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<EmbeddedNote eventId={parentEventId} className="border-0 rounded-none" disableHoverCards />
<EmbeddedNote
eventId={parentEventId}
relays={parentRelayHint ? [parentRelayHint] : undefined}
authorHint={parentAuthorHint}
className="border-0 rounded-none"
disableHoverCards
/>
</HoverCardContent>
</HoverCard>
) : (
+2 -2
View File
@@ -255,7 +255,7 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps)
if (compact) {
return (
<div className="mt-2 space-y-2.5">
<div className="space-y-2.5">
{/* Header: icon + name + summary */}
<div className="flex items-start gap-3">
{icon ? (
@@ -326,7 +326,7 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps)
// Full detail view
return (
<div className="mt-3 space-y-4">
<div className="space-y-4">
{/* Header: icon + name + summary */}
<div className="flex items-start gap-4">
{icon ? (
+42 -13
View File
@@ -15,6 +15,8 @@ import {
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { useMemo } from 'react';
import Markdown from 'react-markdown';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { Link } from 'react-router-dom';
import { Badge } from '@/components/ui/badge';
@@ -24,6 +26,13 @@ import { Skeleton } from '@/components/ui/skeleton';
import { ZAPSTORE_RELAY } from '@/lib/appRelays';
import { openUrl } from '@/lib/downloadFile';
/** Sanitize schema allowing only the subset needed for a CHANGELOG. */
const CHANGELOG_SANITIZE_SCHEMA = {
...defaultSchema,
tagNames: ['h1', 'h2', 'h3', 'ul', 'ol', 'li', 'p', 'strong', 'em', 'code', 'br'] as string[],
attributes: {},
};
/** Get a tag value by name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
@@ -308,7 +317,7 @@ export function ZapstoreReleaseContent({ event, compact }: ZapstoreReleaseConten
if (compact) {
return (
<div className="mt-2 space-y-2.5">
<div className="space-y-2.5">
{/* Header: icon + app name + version */}
<div className="flex items-start gap-3">
{appIcon ? (
@@ -355,11 +364,20 @@ export function ZapstoreReleaseContent({ event, compact }: ZapstoreReleaseConten
</div>
</div>
{/* Release notes (truncated) */}
{/* Release notes — rendered as Markdown, clamped to 4 lines */}
{releaseNotes && (
<p className="text-sm text-muted-foreground whitespace-pre-wrap break-words line-clamp-4">
{releaseNotes}
</p>
<div className="prose prose-sm dark:prose-invert max-w-none text-sm leading-relaxed text-muted-foreground line-clamp-4
[&_h1]:text-sm [&_h1]:font-semibold
[&_h2]:text-sm [&_h2]:font-semibold
[&_h3]:text-sm [&_h3]:font-semibold
[&_ul]:pl-4 [&_ul]:list-disc
[&_ol]:pl-4 [&_ol]:list-decimal
[&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono
[&_p]:my-0 [&_li]:my-0 [&_h1]:my-0 [&_h2]:my-0 [&_h3]:my-0">
<Markdown rehypePlugins={[[rehypeSanitize, CHANGELOG_SANITIZE_SCHEMA]]}>
{releaseNotes}
</Markdown>
</div>
)}
</div>
);
@@ -367,7 +385,7 @@ export function ZapstoreReleaseContent({ event, compact }: ZapstoreReleaseConten
// Full detail view
return (
<div className="mt-3 space-y-4">
<div className="space-y-4">
{/* Header */}
<div className="flex items-start gap-4">
{appIcon ? (
@@ -440,9 +458,20 @@ export function ZapstoreReleaseContent({ event, compact }: ZapstoreReleaseConten
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Release Notes
</p>
<p className="text-sm leading-relaxed whitespace-pre-wrap break-words">
{releaseNotes}
</p>
<div className="prose prose-sm dark:prose-invert max-w-none text-sm leading-relaxed
[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-3 [&_h1]:mb-1
[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1
[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1
[&_ul]:my-1 [&_ul]:pl-4 [&_ul]:list-disc
[&_ol]:my-1 [&_ol]:pl-4 [&_ol]:list-decimal
[&_li]:my-0.5
[&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono
[&_p]:my-1
first:[&>*]:mt-0">
<Markdown rehypePlugins={[[rehypeSanitize, CHANGELOG_SANITIZE_SCHEMA]]}>
{releaseNotes}
</Markdown>
</div>
</div>
)}
@@ -488,7 +517,7 @@ export function ZapstoreReleaseContent({ event, compact }: ZapstoreReleaseConten
/** Skeleton loading state for ZapstoreReleaseContent. */
export function ZapstoreReleaseSkeleton() {
return (
<div className="mt-3 space-y-4">
<div className="space-y-4">
<div className="flex items-start gap-4">
<Skeleton className="size-14 rounded-2xl shrink-0" />
<div className="flex-1 space-y-2">
@@ -554,7 +583,7 @@ export function ZapstoreAssetContent({ event, compact }: ZapstoreAssetContentPro
if (compact) {
return (
<div className="mt-2 space-y-2">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
<PlatformIcon mime={mime} className="size-5 text-primary" />
@@ -581,7 +610,7 @@ export function ZapstoreAssetContent({ event, compact }: ZapstoreAssetContentPro
}
return (
<div className="mt-3 space-y-4">
<div className="space-y-4">
{/* Header */}
<div className="flex items-start gap-4">
<div className="size-14 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
@@ -694,7 +723,7 @@ function MetaRow({ label, value }: { label: string; value: React.ReactNode }) {
/** Skeleton for ZapstoreAssetContent. */
export function ZapstoreAssetSkeleton() {
return (
<div className="mt-3 space-y-4">
<div className="space-y-4">
<div className="flex items-start gap-4">
<Skeleton className="size-14 rounded-2xl shrink-0" />
<div className="flex-1 space-y-2">
+1 -1
View File
@@ -188,7 +188,7 @@ export interface AppConfig {
homePage: string;
/** Display name used in the NIP-89 "client" tag. Falls back to `appName` when not set. */
clientName?: string;
/** NIP-89 addr (`31990:<pubkey>:<d-tag>`) identifying this client's handler event. Included as the third element of the "client" tag. */
/** NIP-19 `naddr1…` identifying this client's kind 31990 handler event. Decoded at publish time to produce the `31990:<pubkey>:<d-tag>` addr and relay hint for the "client" tag per NIP-89. */
client?: string;
/** Enable Magic Mouse mode: cursor/finger emanates magical fire in the primary color */
magicMouse: boolean;
+11
View File
@@ -112,6 +112,17 @@ export class LayoutStore {
export const LayoutStoreContext = createContext<LayoutStore | null>(null);
/**
* Provides the center column DOM element so components deep in the tree can
* portal overlays into it (e.g. the nsite preview panel).
*/
export const CenterColumnContext = createContext<HTMLElement | null>(null);
/** Hook to get the center column DOM element. Returns null until the layout has mounted. */
export function useCenterColumn(): HTMLElement | null {
return useContext(CenterColumnContext);
}
/** Context for exposing the scroll-direction hidden state to child components (MobileTopBar, SubHeaderBar). */
export const NavHiddenContext = createContext<boolean>(false);
+1 -1
View File
@@ -64,7 +64,7 @@ export function useEvent(eventId: string | undefined, relays?: string[], authorH
const { nostr } = useNostr();
return useQuery<NostrEvent | null>({
queryKey: ['event', eventId ?? ''],
queryKey: ['event', eventId ?? '', relays ?? [], authorHint ?? ''],
queryFn: async () => {
if (!eventId) return null;
const filter: NostrFilter[] = [{ ids: [eventId], limit: 1 }];
+32 -2
View File
@@ -1,11 +1,40 @@
import { useNostr } from "@nostrify/react";
import { useMutation, type UseMutationResult } from "@tanstack/react-query";
import { nip19 } from "nostr-tools";
import { useAppContext } from "./useAppContext";
import { useCurrentUser } from "./useCurrentUser";
import type { NostrEvent } from "@nostrify/nostrify";
/**
* Builds a NIP-89 "client" tag from the app display name and an optional
* `naddr1` identifier for the kind 31990 handler event.
*
* Tag format (per NIP-89):
* ["client", <name>, <31990:pubkey:d-tag>, <relay-hint>]
*
* The relay hint is taken from the first relay embedded in the naddr (if any).
*/
function buildClientTag(name: string, clientNaddr: string | undefined): string[] {
if (!clientNaddr) {
return ["client", name];
}
try {
const decoded = nip19.decode(clientNaddr);
if (decoded.type !== "naddr") {
return ["client", name];
}
const { kind, pubkey, identifier, relays } = decoded.data;
const addr = `${kind}:${pubkey}:${identifier}`;
const relayHint = relays?.[0];
return relayHint ? ["client", name, addr, relayHint] : ["client", name, addr];
} catch {
return ["client", name];
}
}
export function useNostrPublish(): UseMutationResult<NostrEvent> {
const { nostr } = useNostr();
const { user } = useCurrentUser();
@@ -16,9 +45,10 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
if (user) {
const tags = t.tags ?? [];
// Add the client tag if it doesn't exist
// Add the NIP-89 client tag if it doesn't exist
if (location.protocol === "https:" && !tags.some(([name]) => name === "client")) {
tags.push(["client", config.clientName ?? config.appName, ...(config.client ? [config.client] : [])]);
const clientTag = buildClientTag(config.clientName ?? config.appName, config.client);
tags.push(clientTag);
}
const event = await user.signer.signEvent({
+34
View File
@@ -0,0 +1,34 @@
import { useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useEvent } from '@/hooks/useEvent';
/**
* Given a kind 1018 poll vote event, resolves the human-readable option label(s)
* by fetching the parent poll and mapping response IDs to option names.
*
* Returns an empty string for non-1018 events or if the parent poll hasn't loaded yet.
*/
export function usePollVoteLabel(event: NostrEvent): string {
const parentTag = useMemo(
() => event.kind === 1018 ? event.tags.find(([n]) => n === 'e') : undefined,
[event],
);
const parentId = parentTag?.[1];
const relayHint = parentTag?.[2] || undefined;
const authorHint = parentTag?.[4] || (event.kind === 1018 ? event.tags.find(([n]) => n === 'p')?.[1] : undefined) || undefined;
const { data: parentPoll } = useEvent(parentId, relayHint ? [relayHint] : undefined, authorHint);
return useMemo(() => {
const responseIds = event.kind === 1018
? event.tags.filter(([n]) => n === 'response').map(([, id]) => id)
: [];
if (responseIds.length === 0) return '';
if (!parentPoll) return responseIds.join(', ');
const optMap = new Map<string, string>();
for (const tag of parentPoll.tags) {
if (tag[0] === 'option') optMap.set(tag[1], tag[2]);
}
return responseIds.map((id) => optMap.get(id) ?? id).join(', ');
}, [event, parentPoll]);
}
+3 -3
View File
@@ -546,10 +546,10 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
35128: 'nsite',
30008: 'profile badges',
30817: 'repository issue',
32267: 'app',
32267: 'Zapstore app',
31990: 'app',
30063: 'release',
3063: 'asset',
30063: 'Zapstore release',
3063: 'Zapstore asset',
};
/**
+42 -4
View File
@@ -14,15 +14,53 @@ export function isReplyEvent(event: NostrEvent): boolean {
return nonMentionTags.length > 0;
}
/** Hints extracted from an `e` tag for relay resolution. */
export interface ParentEventHints {
id: string;
relayHint?: string;
authorHint?: string;
}
/**
* Extracts the parent (replied-to) event ID from an event's tags following NIP-10 conventions.
* Supports both the preferred marked-tag scheme and the deprecated positional scheme.
* For kind 7 reactions, uses NIP-25 semantics: the last `e` tag is the reacted-to event.
*/
export function getParentEventId(event: NostrEvent): string | undefined {
return getParentEventTag(event)?.[1];
}
/**
* Extracts the parent event ID along with relay and author hints from the `e` tag.
* Returns the full NIP-10 hints (relay URL at position [2], author pubkey at position [4]).
*
* When the `e` tag doesn't include a pubkey at position [4] (many clients omit it),
* falls back to the first `p` tag in the event, which per NIP-10 convention contains
* the pubkey of the author being replied to.
*/
export function getParentEventHints(event: NostrEvent): ParentEventHints | undefined {
const tag = getParentEventTag(event);
if (!tag) return undefined;
// Prefer the pubkey embedded in the e tag (NIP-10 position [4]).
// Fall back to the first p tag, which conventionally holds the parent author's pubkey.
const authorHint = tag[4] || event.tags.find(([name]) => name === 'p')?.[1] || undefined;
return {
id: tag[1],
relayHint: tag[2] || undefined,
authorHint,
};
}
/**
* Returns the raw parent `e` tag from an event following NIP-10 conventions.
* For kind 7 reactions, uses NIP-25 semantics: the last `e` tag is the reacted-to event.
*/
function getParentEventTag(event: NostrEvent): string[] | undefined {
// NIP-25: for kind 7 reactions, the target event is always the last e-tag
if (event.kind === 7) {
return event.tags.findLast(([name]) => name === 'e')?.[1];
return event.tags.findLast(([name]) => name === 'e');
}
// Exclude "mention" e-tags — they are inline quotes, not reply/root references
@@ -31,12 +69,12 @@ export function getParentEventId(event: NostrEvent): string | undefined {
// Preferred: look for marked "reply" tag first
const replyTag = eTags.find(([, , , marker]) => marker === 'reply');
if (replyTag) return replyTag[1];
if (replyTag) return replyTag;
// If there's a "root" marker but no "reply" marker, the event replies directly to root
const rootTag = eTags.find(([, , , marker]) => marker === 'root');
if (rootTag) return rootTag[1];
if (rootTag) return rootTag;
// Deprecated positional scheme: last non-mention e-tag is the reply target
return eTags[eTags.length - 1][1];
return eTags[eTags.length - 1];
}
+29
View File
@@ -0,0 +1,29 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
/** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */
export function hexToBase36(hex: string): string {
let n = 0n;
for (let i = 0; i < hex.length; i++) {
n = n * 16n + BigInt(parseInt(hex[i], 16));
}
const b36 = n.toString(36);
return b36.padStart(50, '0');
}
/**
* Derive the NIP-5A canonical subdomain for an nsite event.
*
* - Root site (kind 15128): `<npub>`
* - Named site (kind 35128 with d-tag): `<pubkeyB36><dTag>`
*/
export function getNsiteSubdomain(event: NostrEvent): string {
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (event.kind === 35128 && dTag) {
const pubkeyB36 = hexToBase36(event.pubkey);
return `${pubkeyB36}${dTag}`;
}
return nip19.npubEncode(event.pubkey);
}
+2 -1
View File
@@ -211,7 +211,8 @@ export const AppConfigSchema = z.object({
appId: z.string().optional(),
homePage: z.string().optional(),
clientName: z.string().optional(),
client: z.string().optional(),
/** NIP-19 naddr1 string for the kind 31990 handler event. */
client: z.string().startsWith('naddr1').optional(),
magicMouse: z.boolean().optional(),
theme: ThemeSchema,
customTheme: ThemeConfigCompatSchema.optional(),
+10 -4
View File
@@ -78,15 +78,15 @@ const NOTIFICATION_KIND_NOUNS: Record<number, string> = {
30030: 'emoji pack',
30054: 'podcast episode',
30055: 'podcast trailer',
3063: 'asset',
30063: 'release',
3063: 'Zapstore asset',
30063: 'Zapstore release',
30311: 'stream',
30315: 'status',
30617: 'repository',
30817: 'custom NIP',
31922: 'calendar event',
31923: 'calendar event',
32267: 'app',
32267: 'Zapstore app',
34139: 'playlist',
34236: 'divine',
34550: 'community',
@@ -318,10 +318,16 @@ function NotificationWrapper({ isNew, children }: { isNew: boolean; children: Re
* Uses the pre-fetched event from the group, falling back to useEvent.
*/
function ReferencedNoteCard({ item }: { item: NotificationItem }) {
const referencedEventId = item.event.tags.findLast(([name]) => name === 'e')?.[1];
const referencedTag = item.event.tags.findLast(([name]) => name === 'e');
const referencedEventId = referencedTag?.[1];
const relayHint = referencedTag?.[2] || undefined;
// Fall back to the first p tag for the author hint (parent event author)
const authorHint = referencedTag?.[4] || item.event.tags.find(([name]) => name === 'p')?.[1] || undefined;
// Fall back to useEvent if the batch fetch didn't find it
const { data: fetchedEvent } = useEvent(
item.referencedEvent ? undefined : referencedEventId,
relayHint ? [relayHint] : undefined,
authorHint,
);
const event = item.referencedEvent ?? fetchedEvent;
+137 -51
View File
@@ -53,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 { EventActionHeader, NoteCard } from "@/components/NoteCard";
import { ActivityCard, EventActionHeader, NoteCard } from "@/components/NoteCard";
import { NoteContent } from "@/components/NoteContent";
import { NsiteCard } from "@/components/NsiteCard";
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
@@ -89,6 +89,7 @@ import { ZapstoreReleaseContent, ZapstoreReleaseSkeleton, ZapstoreAssetContent,
import { AppHandlerContent } from "@/components/AppHandlerContent";
import { useAppContext } from "@/hooks/useAppContext";
import { type AddrCoords, useAddrEvent, useEvent } from "@/hooks/useEvent";
import { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
import { formatNumber } from "@/lib/formatNumber";
import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls";
@@ -136,9 +137,9 @@ function shellTitleForKind(kind?: number): string {
if (kind === BADGE_DEFINITION_KIND) return "Badge Details";
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 === 30063) return "Release";
if (kind === 3063) return "Asset";
if (kind === 32267) return "Zapstore App";
if (kind === 30063) return "Zapstore Release";
if (kind === 3063) return "Zapstore Asset";
if (kind === 31990) return "App";
if (kind === 15128 || kind === 35128) return "Nsite";
if (kind === VANISH_KIND) return "Request to Vanish";
@@ -147,6 +148,7 @@ function shellTitleForKind(kind?: number): string {
if (kind === 8211) return "Letter";
if (kind === 6 || kind === 16) return "Repost";
if (kind === 7) return "Reaction";
if (kind === 1018) return "Poll Vote";
if (kind === 9735) return "Zap";
if (kind === 0) return "Profile";
if (kind === 31124) return "Blobbi";
@@ -178,7 +180,7 @@ import { extractISBNFromEvent } from "@/lib/bookstr";
import { isCustomEmoji, type ResolvedEmoji } from "@/lib/customEmoji";
import { getDisplayName } from "@/lib/getDisplayName";
import { isEventMuted } from "@/lib/muteHelpers";
import { getParentEventId, isReplyEvent } from "@/lib/nostrEvents";
import { getParentEventId, getParentEventHints, isReplyEvent } from "@/lib/nostrEvents";
import { shareOrCopy } from "@/lib/share";
import { cn } from "@/lib/utils";
@@ -954,6 +956,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
const zapSenderDisplayName = getDisplayName(zapSenderMeta, zapSenderPubkeyRaw);
const zapSenderProfileUrl = useProfileUrl(zapSenderPubkeyRaw, zapSenderMeta);
const pollVoteLabel = usePollVoteLabel(event);
// NIP-19 encoded event identifier for share URLs
const encodedEventId = useMemo(() => {
if (event.kind >= 30000 && event.kind < 40000) {
@@ -984,6 +988,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
// Kind detection — mirrors NoteCard
const isVine = event.kind === 34236;
const isPoll = event.kind === 1068;
const isPollVote = event.kind === 1018;
const isGeocache = event.kind === 37516;
const isFoundLog = event.kind === 7516;
const isColor = event.kind === 3367;
@@ -1018,6 +1023,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
const isTextNote =
!isVine &&
!isPoll &&
!isPollVote &&
!isGeocache &&
!isFoundLog &&
!isColor &&
@@ -1293,10 +1299,11 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
const [interactionsTab, setInteractionsTab] =
useState<InteractionTab>("reposts");
const parentEventId = useMemo(
() => (isTextNote || isReaction || isRepost || isZap ? getParentEventId(event) : undefined),
[event, isTextNote, isReaction, isRepost, isZap],
const parentHints = useMemo(
() => (isTextNote || isReaction || isRepost || isZap || isPollVote ? getParentEventHints(event) : undefined),
[event, isTextNote, isReaction, isRepost, isZap, isPollVote],
);
const parentEventId = parentHints?.id;
// For kind 1111 comments on external content, extract the I tag for the parent preview
const externalIdentifier = useMemo(() => {
@@ -1408,6 +1415,24 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
// Extract client from tags
const clientTag = event.tags.find(([name]) => name === "client");
// Parse NIP-89 client tag: ["client", name, "kind:pubkey:d-tag", relayHint?]
const clientNaddr = (() => {
const addr = clientTag?.[2];
if (!addr) return null;
const parts = addr.split(":");
if (parts.length < 3) return null;
const [kindStr, pubkey, ...rest] = parts;
const kind = parseInt(kindStr, 10);
if (isNaN(kind) || !pubkey) return null;
const identifier = rest.join(":");
const relays = clientTag?.[3] ? [clientTag[3]] : undefined;
try {
return nip19.naddrEncode({ kind, pubkey, identifier, relays });
} catch {
return null;
}
})();
const openInteractions = (tab: InteractionTab) => {
setInteractionsTab(tab);
setInteractionsOpen(true);
@@ -1441,7 +1466,9 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<div ref={ancestorRef}>
<AncestorThread
eventId={parentEventId}
collapseAfter={isReaction || isRepost || isZap ? 0 : undefined}
relays={parentHints?.relayHint ? [parentHints.relayHint] : undefined}
authorHint={parentHints?.authorHint}
collapseAfter={isReaction || isRepost || isZap || isPollVote ? 0 : undefined}
/>
</div>
)}
@@ -1943,13 +1970,62 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</article>
)}
{/* Kind 1018 — Poll vote: compact activity-style card */}
{isPollVote && (
<div ref={focusedPostRef as React.RefObject<HTMLDivElement>}>
<ActivityCard
className="border-b-0 pb-0"
icon={
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0">
<Avatar shape={avatarShape} className="size-10">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
}
actorRow={
<div className="flex items-center gap-1.5">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="font-bold text-sm hover:underline truncate">
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">voted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">{formatFullDate(event.created_at)}</span>
</div>
}
>
{pollVoteLabel && <p className="text-sm font-semibold mt-0.5 truncate">{pollVoteLabel}</p>}
</ActivityCard>
<PostActionBar
event={event}
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="mt-2 px-4"
/>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
</div>
)}
{/* Main post — expanded Ditto-style view */}
{!isReaction && !isRepost && !isVanish && !isZap && !isProfile && (
{!isReaction && !isRepost && !isVanish && !isZap && !isProfile && !isPollVote && (
<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" />
)}
{isZapstoreApp && (
<EventActionHeader pubkey={event.pubkey} icon={Package} action="published a Zapstore app" />
)}
{isZapstoreRelease && (
<EventActionHeader pubkey={event.pubkey} icon={Package} action="published a Zapstore release" />
)}
{isZapstoreAsset && (
<EventActionHeader pubkey={event.pubkey} icon={Package} action="published a Zapstore asset" />
)}
{isNsite && (
<EventActionHeader pubkey={event.pubkey} icon={Rocket} action="deployed an" noun="nsite" nounRoute="/development" />
)}
@@ -2064,15 +2140,21 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<NsiteCard event={event} />
</div>
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} />
<div className="mt-3 rounded-xl border border-border overflow-hidden px-4 pt-4 pb-4">
<ZapstoreAppContent event={event} />
</div>
) : isZapstoreRelease ? (
<Suspense fallback={<ZapstoreReleaseSkeleton />}>
<ZapstoreReleaseContent event={event} />
</Suspense>
<div className="mt-3 rounded-xl border border-border overflow-hidden px-4 pt-4 pb-4">
<Suspense fallback={<ZapstoreReleaseSkeleton />}>
<ZapstoreReleaseContent event={event} />
</Suspense>
</div>
) : isZapstoreAsset ? (
<Suspense fallback={<ZapstoreAssetSkeleton />}>
<ZapstoreAssetContent event={event} />
</Suspense>
<div className="mt-3 rounded-xl border border-border overflow-hidden px-4 pt-4 pb-4">
<Suspense fallback={<ZapstoreAssetSkeleton />}>
<ZapstoreAssetContent event={event} />
</Suspense>
</div>
) : isAppHandler ? (
<AppHandlerContent event={event} />
) : isEncryptedDM ? (
@@ -2200,35 +2282,22 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</button>
) : null}
<span className="ml-auto shrink-0 flex items-center gap-1.5">
{clientTag?.[1] &&
(() => {
const clientValue = clientTag[1];
let isHostname = false;
try {
const url = new URL(`https://${clientValue}`);
isHostname = url.hostname === clientValue;
} catch {
isHostname = false;
}
return (
<>
{isHostname ? (
<a
href={`https://${clientValue}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{clientValue}
</a>
) : (
<span>{clientValue}</span>
)}
<span>·</span>
</>
);
})()}
{clientTag?.[1] && (
<>
{clientNaddr ? (
<Link
to={`/${clientNaddr}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{clientTag[1]}
</Link>
) : (
<span>{clientTag[1]}</span>
)}
<span>·</span>
</>
)}
<span>{formatFullDate(event.created_at)}</span>
</span>
</div>
@@ -2239,7 +2308,17 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<div className="py-2 sidebar:py-2.5 mt-2 sidebar:mt-3 text-xs sidebar:text-sm text-muted-foreground flex items-center gap-1.5">
{clientTag?.[1] && (
<>
<span>{clientTag?.[1]}</span>
{clientNaddr ? (
<Link
to={`/${clientNaddr}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{clientTag[1]}
</Link>
) : (
<span>{clientTag[1]}</span>
)}
<span>·</span>
</>
)}
@@ -2335,21 +2414,26 @@ function AddrAncestor({ addr }: { addr: { kind: number; pubkey: string; identifi
*/
function AncestorThread({
eventId,
relays,
authorHint,
depth = 0,
collapseAfter,
}: {
eventId: string;
relays?: string[];
authorHint?: string;
depth?: number;
collapseAfter?: number;
}) {
const { data: event, isLoading } = useEvent(eventId);
const { data: event, isLoading } = useEvent(eventId, relays, authorHint);
const [expanded, setExpanded] = useState(false);
// Determine this ancestor's own parent
const parentId = useMemo(
() => (event ? getParentEventId(event) : undefined),
// Determine this ancestor's own parent, including relay and author hints
const parentHints = useMemo(
() => (event ? getParentEventHints(event) : undefined),
[event],
);
const parentId = parentHints?.id;
// Cap recursion to avoid runaway chains
const MAX_DEPTH = 20;
@@ -2410,6 +2494,8 @@ function AncestorThread({
) : (
<AncestorThread
eventId={parentId}
relays={parentHints?.relayHint ? [parentHints.relayHint] : undefined}
authorHint={parentHints?.authorHint}
depth={depth + 1}
collapseAfter={collapseAfter}
/>