Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 431c388129 | |||
| 72b63dac21 | |||
| be82cb9626 | |||
| c2c6f711b5 | |||
| 3fba81a7d2 | |||
| 6f2b51197f | |||
| 00c801e9dc | |||
| 47e7d05cb9 | |||
| 4ef6d1b149 | |||
| badd19d27c | |||
| e67f90582b | |||
| 7fa6e574f8 | |||
| 9b36bf3325 | |||
| bc1c4cb7cf | |||
| 119f684fb3 | |||
| 45134ef9cc | |||
| db502b462c | |||
| ed083bfdad | |||
| 47811f9190 | |||
| ba99cdc51c | |||
| 7092f7306f | |||
| 357dd56de0 | |||
| fadec0574a | |||
| 469806886a | |||
| f7ab980ecd | |||
| c6b5ab2284 | |||
| 2231673ee6 | |||
| f8907475f9 | |||
| 4252841125 | |||
| ee8220c1f0 | |||
| 11e29646a7 | |||
| a9bab7f8e8 | |||
| 0b69ab51f4 | |||
| 2a32e79b13 | |||
| 39fc7549ac | |||
| 414f42e339 | |||
| 8e3f778f5b | |||
| bc83d08961 | |||
| 7d83273410 | |||
| fabcb4170d |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Generated
+36
-36
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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">“{zapMessage}”</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">“{zapMessage}”</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">“{zapMessage}”</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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
) : (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user