Compare commits

...

12 Commits

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

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

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

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

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

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

Move createPortal(…, document.body) into Lightbox so every consumer
escapes the stacking context automatically.
2026-04-03 23:27:53 -05:00
24 changed files with 940 additions and 545 deletions
+14
View File
@@ -1,5 +1,19 @@
# 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
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.5.0"
versionName "2.5.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.5.0;
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.5.0;
MARKETING_VERSION = 2.5.2;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+27 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.4.1",
"version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.4.1",
"version": "2.5.1",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
@@ -5699,6 +5699,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5712,6 +5713,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5725,6 +5727,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5738,6 +5741,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5751,6 +5755,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5764,6 +5769,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5777,6 +5783,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5790,6 +5797,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5803,6 +5811,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5816,6 +5825,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5829,6 +5839,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5842,6 +5853,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5855,6 +5867,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5868,6 +5881,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5881,6 +5895,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5894,6 +5909,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5907,6 +5923,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5920,6 +5937,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5933,6 +5951,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5946,6 +5965,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5959,6 +5979,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5972,6 +5993,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5985,6 +6007,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5998,6 +6021,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6011,6 +6035,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.5.0",
"version": "2.5.2",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
+14
View File
@@ -1,5 +1,19 @@
# 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
+5 -4
View File
@@ -51,6 +51,7 @@ const hardcodedConfig: AppConfig = {
appName: "Ditto",
appId: "ditto",
homePage: "feed",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
magicMouse: false,
theme: "system",
autoShareTheme: true,
@@ -123,11 +124,11 @@ const hardcodedConfig: AppConfig = {
"feed",
"notifications",
"search",
"themes",
"letters",
"badges",
"blobbi",
"theme",
"badges",
"emojis",
"letters",
"themes",
"settings",
"help",
],
@@ -17,7 +17,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
@@ -53,14 +52,18 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
pubkey: string,
dTag: string,
): Promise<BlobbiCompanion | null> => {
const event = await fetchFreshEvent(
nostr,
{ kinds: [KIND_BLOBBI_STATE], authors: [pubkey], '#d': [dTag] },
{ eoseTimeout: 1000 },
);
const events = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [pubkey],
'#d': [dTag],
}]);
if (!event || !isValidBlobbiEvent(event)) return null;
return parseBlobbiEvent(event) ?? null;
const validEvents = events
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at);
if (validEvents.length === 0) return null;
return parseBlobbiEvent(validEvents[0]) ?? null;
}, [nostr]);
/** Optimistically update the TanStack cache so the companion reacts immediately. */
+29 -13
View File
@@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { fetchFreshEvents } from '@/lib/fetchFreshEvent';
import {
KIND_BLOBBI_STATE,
isValidBlobbiEvent,
@@ -52,34 +51,46 @@ export function useBlobbisCollection(dList: string[] | undefined) {
// Main query to fetch all companions from relays
const query = useQuery({
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
queryFn: async () => {
queryFn: async ({ signal }) => {
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
return { companionsByD: {}, companions: [] };
}
// Log the dList we're about to query
console.log('[Blobbi] dList:', sortedDList);
// Chunk the d-list for relay compatibility
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
// Fetch all chunks, using a relaxed eoseTimeout (1000ms) so slower
// relays have time to respond and we get the freshest events.
// Query all chunks in parallel
const allEvents: NostrEvent[] = [];
for (const chunk of chunks) {
const events = await fetchFreshEvents(
nostr,
[{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': chunk,
}],
{ eoseTimeout: 1000 },
);
const filter = {
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': chunk,
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
};
// Log the filter immediately before query
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
const events = await nostr.query([filter], { signal });
allEvents.push(...events);
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
}
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
// Filter to valid events
const validEvents = allEvents.filter(isValidBlobbiEvent);
console.log('[useBlobbisCollection] Valid events:', validEvents.length);
// Group events by d-tag and keep only the newest per d
const eventsByD = new Map<string, NostrEvent>();
@@ -105,6 +116,11 @@ export function useBlobbisCollection(dList: string[] | undefined) {
}
}
console.log('[useBlobbisCollection] Parsed companions:', {
count: companions.length,
dTags: Object.keys(companionsByD),
});
return { companionsByD, companions };
},
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
+7 -9
View File
@@ -892,15 +892,13 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
const isLegacy = isLegacyBlobbiEvent(event);
// Concise, structured debug log
if (import.meta.env.DEV) {
console.log('[Blobbi]', {
d: d.length > 30 ? `${d.slice(0, 20)}...` : d,
name,
isLegacy,
hasSeed: !!seed,
traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`,
});
}
console.log('[Blobbi]', {
d: d.length > 30 ? `${d.slice(0, 20)}...` : d,
name,
isLegacy,
hasSeed: !!seed,
traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`,
});
// Parse task progress tags: ["task", "name:value"]
const tasks: BlobbiTaskProgress[] = [];
+77 -10
View File
@@ -6,7 +6,7 @@ import {
Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film,
GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music,
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles,
Users, Zap,
Users, Vote, Zap,
} from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -22,6 +22,7 @@ import { ExternalFavicon } from '@/components/ExternalFavicon';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAddrEvent, useEvent } from '@/hooks/useEvent';
import { usePollVoteLabel } from '@/hooks/usePollVoteLabel';
import { useAuthor } from '@/hooks/useAuthor';
import { useBookInfo } from '@/hooks/useBookInfo';
import { useLinkPreview } from '@/hooks/useLinkPreview';
@@ -44,26 +45,38 @@ interface CommentRoot {
identifier?: string;
/** Root kind number (from K tag). */
rootKind?: string;
/** Relay URL hint from the E or A tag (position [2]). */
relayHint?: string;
/** Author pubkey hint extracted from the E tag (position [3]) or P tag. */
authorHint?: string;
}
/** Parse the root reference from a kind 1111 comment's tags. */
function parseCommentRoot(event: NostrEvent): CommentRoot | undefined {
const aTag = event.tags.find(([name]) => name === 'A')?.[1];
const aTagFull = event.tags.find(([name]) => name === 'A');
// Use find (not findLast) to get the root E tag, not a parent e tag
const eTag = event.tags.find(([name]) => name === 'E')?.[1];
const eTagFull = event.tags.find(([name]) => name === 'E');
const iTag = event.tags.find(([name]) => name === 'I')?.[1];
const kTag = event.tags.find(([name]) => name === 'K')?.[1];
// P tag holds the root event author's pubkey — used as author hint fallback
const pTag = event.tags.find(([name]) => name === 'P')?.[1];
if (aTag) {
if (aTagFull) {
const aTag = aTagFull[1];
const relayHint = aTagFull[2] || undefined;
const parts = aTag.split(':');
const kind = parseInt(parts[0], 10);
const pubkey = parts[1] ?? '';
const identifier = parts.slice(2).join(':');
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag };
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag, relayHint };
}
if (eTag) {
return { type: 'event', eventId: eTag, rootKind: kTag };
if (eTagFull) {
const eTag = eTagFull[1];
const relayHint = eTagFull[2] || undefined;
// NIP-22 E tags may have the author pubkey at position [3]; fall back to P tag
const authorHint = eTagFull[3] || pTag || undefined;
return { type: 'event', eventId: eTag, rootKind: kTag, relayHint, authorHint };
}
if (iTag) {
@@ -91,6 +104,7 @@ const KIND_LABELS: Record<number, string> = {
22: 'a short video',
62: 'a request to vanish',
1063: 'a file',
1018: 'a vote',
1068: 'a poll',
1111: 'a comment',
1222: 'a voice message',
@@ -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,
@@ -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 ?? '';
+1 -1
View File
@@ -1266,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>
)}
+6 -6
View File
@@ -126,8 +126,8 @@ export function ImageGallery({
))}
</div>
{/* Lightbox portaled to document.body to escape stacking contexts (e.g. the center column z-0) */}
{lightboxIndex !== null && lightboxIndex !== undefined && createPortal(
{/* Lightbox (portals to document.body internally to escape stacking contexts) */}
{lightboxIndex !== null && lightboxIndex !== undefined && (
<Lightbox
images={images}
currentIndex={lightboxIndex}
@@ -136,8 +136,7 @@ export function ImageGallery({
onPrev={goPrev}
topBarLeft={lightboxTopBarLeft}
bottomBar={lightboxBottomBar}
/>,
document.body,
/>
)}
</>
);
@@ -486,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"
@@ -584,7 +583,8 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
{bottomBar}
</div>
)}
</div>
</div>,
document.body,
);
}
+204 -378
View File
@@ -21,7 +21,7 @@ import {
Zap,
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type ReactNode, lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
/** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the main feed bundle. */
const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent })));
@@ -98,7 +98,8 @@ import { genUserName } from "@/lib/genUserName";
import { getDisplayName } from "@/lib/getDisplayName";
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls";
import { getParentEventId, isReplyEvent } from "@/lib/nostrEvents";
import { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
import { getParentEventHints, isReplyEvent } from "@/lib/nostrEvents";
import { isSingleImagePost } from "@/lib/noteContent";
import { shareOrCopy } from "@/lib/share";
import { timeAgo } from "@/lib/timeAgo";
@@ -118,6 +119,113 @@ function ProfileCardContent({ event }: { event: NostrEvent }) {
);
}
/* ──── Shared activity card shell for reaction / repost / zap / poll vote ──── */
interface ActivityCardProps {
/** The round element in the left column (icon bubble or avatar). */
icon: ReactNode;
/** The actor row content (avatar + name + label + timestamp). */
actorRow: ReactNode;
/** Optional extra content below the actor row (zap message, vote label, etc.). */
children?: ReactNode;
/** Threaded mode: connector line below icon, no bottom border. */
threaded?: boolean;
/** Last item in thread — no connector line, has bottom border. */
threadedLast?: boolean;
/** Custom connector line class. */
threadedLineClassName?: string;
className?: string;
onClick?: React.MouseEventHandler;
onAuxClick?: React.MouseEventHandler;
}
export function ActivityCard({
icon,
actorRow,
children,
threaded,
threadedLast,
threadedLineClassName,
className,
onClick,
onAuxClick,
}: ActivityCardProps) {
const isThreaded = threaded || threadedLast;
return (
<article
className={cn(
"px-4 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
isThreaded
? cn("pt-3", threaded ? "pb-0" : "pb-3 border-b border-border")
: "py-3 border-b border-border",
className,
)}
onClick={onClick}
onAuxClick={onAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{icon}
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div className={cn("flex-1 min-w-0", isThreaded ? "min-h-10 flex flex-col justify-center" : "", threaded && "pb-3")}>
{actorRow}
{children}
</div>
</div>
</article>
);
}
/** Reusable actor row: small avatar + display name + action label + timestamp. */
export interface ActorRowProps {
pubkey: string;
profileUrl: string;
avatarShape: Parameters<typeof Avatar>[0]['shape'];
picture?: string;
displayName: string;
authorEvent?: NostrEvent;
isLoading?: boolean;
label: string;
/** Extra inline elements after the label (e.g. zap amount). */
extra?: ReactNode;
/** Formatted timestamp string (e.g. timeAgo or full date). */
timestampLabel: string;
}
export function ActorRow({ pubkey, profileUrl, avatarShape, picture, displayName, authorEvent, isLoading, label, extra, timestampLabel }: ActorRowProps) {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-20" />
</div>
);
}
return (
<div className="flex items-center gap-2">
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
{authorEvent ? <EmojifiedText tags={authorEvent.tags}>{displayName}</EmojifiedText> : displayName}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">{label}</span>
{extra}
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timestampLabel}</span>
</div>
);
}
interface NoteCardProps {
event: NostrEvent;
className?: string;
@@ -219,6 +327,8 @@ export const NoteCard = memo(function NoteCard({
const zapSenderName = getDisplayName(zapSenderMeta, zapSenderPubkey);
const zapSenderUrl = useProfileUrl(zapSenderPubkey, zapSenderMeta);
const pollVoteLabel = usePollVoteLabel(event);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = getDisplayName(metadata, event.pubkey);
@@ -295,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;
@@ -339,6 +450,7 @@ export const NoteCard = memo(function NoteCard({
!isEmojiPack &&
!isBadge &&
!isReaction &&
!isPollVote &&
!isRepost &&
!isPhoto &&
!isVideo &&
@@ -424,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(
@@ -470,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 */}
@@ -802,399 +920,107 @@ export const NoteCard = memo(function NoteCard({
);
}
// ── Reaction layout (kind 7) — compact activity-style card ──
// ── Reaction layout (kind 7) ──
if (isReaction) {
// Threaded reaction (used in AncestorThread with connector line)
if (threaded || threadedLast) {
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{/* Reaction emoji bubble instead of avatar */}
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0 text-lg leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="h-5 w-5 object-contain"
/>
</div>
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div
className={cn(
"flex-1 min-w-0 flex items-center min-h-10",
threaded && "pb-3",
)}
>
<div className="flex items-center gap-2">
{author.isLoading ? (
<Skeleton className="size-6 rounded-full shrink-0" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage
src={metadata?.picture}
alt={displayName}
/>
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
)}
{author.isLoading ? (
<Skeleton className="h-3.5 w-20" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
)}
<span className="text-sm text-muted-foreground">reacted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</div>
</div>
</div>
</article>
);
}
// Normal reaction card (standalone or in feed)
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex items-center gap-3">
{/* Large reaction emoji */}
<div className="flex items-center justify-center size-11 rounded-full bg-pink-500/10 shrink-0 text-xl leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="h-6 w-6 object-contain"
/>
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-pink-500/10 shrink-0 text-lg leading-none", iconSize)}>
<ReactionEmoji content={event.content} tags={event.tags} className="h-5 w-5 object-contain" />
</div>
{/* Author + "reacted" label */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-4 w-24" />
</>
) : (
<>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground">reacted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</>
)}
</div>
</div>
</article>
}
actorRow={
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reacted" timestampLabel={timeAgo(event.created_at)} />
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
/>
);
}
// ── Repost layout (kind 6 / 16) — compact activity-style card ──
// ── Repost layout (kind 6 / 16) ──
if (isRepost) {
// Threaded repost (used in AncestorThread with connector line)
if (threaded || threadedLast) {
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{/* Repost icon bubble instead of avatar */}
<div className="flex items-center justify-center size-10 rounded-full bg-accent/10 shrink-0">
<RepostIcon className="size-5 text-accent" />
</div>
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div
className={cn(
"flex-1 min-w-0 flex items-center min-h-10",
threaded && "pb-3",
)}
>
<div className="flex items-center gap-2">
{author.isLoading ? (
<Skeleton className="size-6 rounded-full shrink-0" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage
src={metadata?.picture}
alt={displayName}
/>
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
)}
{author.isLoading ? (
<Skeleton className="h-3.5 w-20" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
)}
<span className="text-sm text-muted-foreground">reposted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</div>
</div>
</div>
</article>
);
}
// Normal repost card (standalone or in feed)
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex items-center gap-3">
{/* Repost icon */}
<div className="flex items-center justify-center size-11 rounded-full bg-accent/10 shrink-0">
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-accent/10 shrink-0", iconSize)}>
<RepostIcon className="size-5 text-accent" />
</div>
{/* Author + "reposted" label */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-4 w-24" />
</>
) : (
<>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground">reposted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</>
)}
</div>
</div>
</article>
}
actorRow={
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reposted" timestampLabel={timeAgo(event.created_at)} />
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
/>
);
}
// ── Zap receipt layout (kind 9735) — mirrors reaction layout exactly ──
// ── Zap receipt layout (kind 9735) ──
if (isZap) {
const zapAmountSats = Math.floor(extractZapAmount(event) / 1000);
const zapMessage = extractZapMessage(event);
const zapActorRow = (
<div className="flex items-center gap-2">
{zapSender.isLoading ? (
<>
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-20" />
</>
) : (
<>
{zapSenderPubkey && (
<ProfileHoverCard pubkey={zapSenderPubkey} asChild>
<Link to={zapSenderUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={zapSenderShape} className="size-6">
<AvatarImage src={zapSenderMeta?.picture} alt={zapSenderName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{zapSenderName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
)}
{zapSenderPubkey && (
<ProfileHoverCard pubkey={zapSenderPubkey} asChild>
<Link to={zapSenderUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
{zapSender.data?.event ? <EmojifiedText tags={zapSender.data.event.tags}>{zapSenderName}</EmojifiedText> : zapSenderName}
</Link>
</ProfileHoverCard>
)}
<span className="text-sm text-muted-foreground shrink-0">zapped</span>
{zapAmountSats > 0 && (
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-amber-500/10 shrink-0", iconSize)}>
<Zap className="size-5 text-amber-500 fill-amber-500" />
</div>
}
actorRow={
<ActorRow pubkey={zapSenderPubkey} profileUrl={zapSenderUrl} avatarShape={zapSenderShape} picture={zapSenderMeta?.picture}
displayName={zapSenderName} authorEvent={zapSender.data?.event} isLoading={zapSender.isLoading} label="zapped" timestampLabel={timeAgo(event.created_at)}
extra={zapAmountSats > 0 ? (
<span className="text-sm font-semibold text-amber-500 shrink-0">
{formatNumber(zapAmountSats)} {zapAmountSats === 1 ? 'sat' : 'sats'}
</span>
)}
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
</>
)}
</div>
);
if (threaded || threadedLast) {
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className="flex items-center justify-center size-10 rounded-full bg-amber-500/10 shrink-0">
<Zap className="size-5 text-amber-500 fill-amber-500" />
</div>
{threaded && <div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />}
</div>
<div className={cn("flex-1 min-w-0 flex flex-col justify-center min-h-10", threaded && "pb-3")}>
{zapActorRow}
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">&ldquo;{zapMessage}&rdquo;</p>}
</div>
</div>
</article>
);
}
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
) : undefined}
/>
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-11 rounded-full bg-amber-500/10 shrink-0">
<Zap className="size-5 text-amber-500 fill-amber-500" />
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">&ldquo;{zapMessage}&rdquo;</p>}
</ActivityCard>
);
}
// ── Poll vote layout (kind 1018) ──
if (isPollVote) {
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
icon={
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className={iconSize}>
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
}
actorRow={
<div className="flex items-center gap-1.5">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">voted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
</div>
<div className="flex-1 min-w-0 flex flex-col">
{zapActorRow}
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">&ldquo;{zapMessage}&rdquo;</p>}
</div>
</div>
</article>
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
>
{pollVoteLabel && <p className="text-sm font-semibold mt-0.5 truncate">{pollVoteLabel}</p>}
</ActivityCard>
);
}
+362 -22
View File
@@ -1,10 +1,22 @@
import { useState, useMemo } from 'react';
import { BarChart3, CheckCircle2, Clock } from 'lucide-react';
import { Link } from 'react-router-dom';
import { BarChart3, CheckCircle2, Clock, X, ChevronRight } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { NoteContent } from '@/components/NoteContent';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { EmojifiedText } from '@/components/CustomEmoji';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { getAvatarShape } from '@/lib/avatarShape';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -61,6 +73,61 @@ function tallyVotes(
return counts;
}
/** Get voter events for a specific option ID. */
function getVotersForOption(
votes: NostrEvent[],
optionId: string,
pollType: string,
): NostrEvent[] {
return votes.filter((vote) => {
const responseTags = vote.tags.filter(([n]) => n === 'response');
if (pollType === 'singlechoice') {
return responseTags[0]?.[1] === optionId;
} else {
return responseTags.some(([, id]) => id === optionId);
}
});
}
/** Clickable avatar stack + "N votes" label. */
function VoterAvatarsButton({
votes,
totalVotes,
authorsMap,
onClick,
className,
}: {
votes: NostrEvent[];
totalVotes: number;
authorsMap?: Map<string, { pubkey: string; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
onClick: () => void;
className?: string;
}) {
return (
<button onClick={onClick} className={cn('flex items-center gap-1.5 group', className)}>
<div className="flex -space-x-1.5">
{votes.slice(0, 6).map((vote) => {
const authorData = authorsMap?.get(vote.pubkey);
const metadata = authorData?.metadata;
const avatarShape = getAvatarShape(metadata);
const name = metadata?.name || genUserName(vote.pubkey);
return (
<Avatar key={vote.pubkey} shape={avatarShape} className="size-5 ring-1 ring-background">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
);
})}
</div>
<span className="text-xs text-muted-foreground group-hover:text-foreground transition-colors">
{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}
</span>
</button>
);
}
export function PollContent({ event }: { event: NostrEvent }) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
@@ -72,6 +139,10 @@ export function PollContent({ event }: { event: NostrEvent }) {
const endsAt = getTag(event.tags, 'endsAt');
const isExpired = endsAt ? Number(endsAt) < Math.floor(Date.now() / 1000) : false;
// Modal state
const [votersModalOpen, setVotersModalOpen] = useState(false);
const [votersModalOptionId, setVotersModalOptionId] = useState<string | null>(null);
// Fetch vote events
const { data: votes } = useQuery<NostrEvent[]>({
queryKey: ['poll-votes', event.id],
@@ -126,6 +197,19 @@ export function PollContent({ event }: { event: NostrEvent }) {
});
};
// Collect all voter pubkeys for batch profile fetching
const allVoterPubkeys = useMemo(() => {
if (!votes) return [];
return votes.map((v) => v.pubkey);
}, [votes]);
const { data: authorsMap } = useAuthors(allVoterPubkeys);
const openVotersModal = (optionId: string | null) => {
setVotersModalOptionId(optionId);
setVotersModalOpen(true);
};
return (
<div className="mt-2" onClick={(e) => e.stopPropagation()}>
{/* Question */}
@@ -133,7 +217,7 @@ export function PollContent({ event }: { event: NostrEvent }) {
<NoteContent event={event} />
</div>
{/* Poll type + expiry badges */}
{/* Poll type + expiry badges + voter avatars + vote count */}
<div className="flex items-center gap-2 mt-2">
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground bg-secondary/60 px-2 py-0.5 rounded-full">
<BarChart3 className="size-3" />
@@ -145,6 +229,17 @@ export function PollContent({ event }: { event: NostrEvent }) {
Ended
</span>
)}
{/* Voter avatars + count pushed to the right */}
{showResults && totalVotes > 0 && (
<VoterAvatarsButton
votes={votes ?? []}
totalVotes={totalVotes}
authorsMap={authorsMap}
onClick={() => openVotersModal(null)}
className="ml-auto"
/>
)}
</div>
{/* Options */}
@@ -192,26 +287,271 @@ export function PollContent({ event }: { event: NostrEvent }) {
})}
</div>
{/* Vote button or total */}
<div className="flex items-center justify-between mt-3">
<span className="text-xs text-muted-foreground">
{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}
</span>
{!showResults && user && (
<button
onClick={handleVote}
disabled={!selectedOption}
className={cn(
'text-sm font-semibold px-4 py-1.5 rounded-full transition-colors',
selectedOption
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-secondary text-muted-foreground cursor-not-allowed',
)}
>
Vote
</button>
)}
</div>
{/* Vote button + voter avatars (voting mode only) */}
{!showResults && (
<div className="flex items-center justify-between mt-3">
{totalVotes > 0 ? (
<VoterAvatarsButton
votes={votes ?? []}
totalVotes={totalVotes}
authorsMap={authorsMap}
onClick={() => openVotersModal(null)}
/>
) : (
<span className="text-xs text-muted-foreground">0 votes</span>
)}
{user && (
<button
onClick={handleVote}
disabled={!selectedOption}
className={cn(
'text-sm font-semibold px-4 py-1.5 rounded-full transition-colors',
selectedOption
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-secondary text-muted-foreground cursor-not-allowed',
)}
>
Vote
</button>
)}
</div>
)}
{/* Voters Modal */}
<PollVotersModal
open={votersModalOpen}
onOpenChange={setVotersModalOpen}
allVotes={votes ?? []}
options={options}
pollType={pollType}
initialOptionId={votersModalOptionId}
authorsMap={authorsMap}
/>
</div>
);
}
/* ──── Poll Voters Modal ──── */
interface PollVotersModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
allVotes: NostrEvent[];
options: PollOption[];
pollType: string;
initialOptionId?: string | null;
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
}
function PollVotersModal({ open, onOpenChange, allVotes, options, pollType, initialOptionId, authorsMap }: PollVotersModalProps) {
const [activeFilter, setActiveFilter] = useState<string | null>(initialOptionId ?? null);
// Sync filter when modal opens with a specific option
useMemo(() => {
if (open) setActiveFilter(initialOptionId ?? null);
}, [open, initialOptionId]);
// Build a map from option ID to label for display
const optionLabelMap = useMemo(() => {
const map = new Map<string, string>();
for (const opt of options) {
map.set(opt.id, opt.label);
}
return map;
}, [options]);
// Filter voters based on active filter
const filteredVoters = useMemo(() => {
if (activeFilter === null) return allVotes;
return getVotersForOption(allVotes, activeFilter, pollType);
}, [allVotes, activeFilter, pollType]);
// Tally per option for the count badges
const tally = useMemo(() => tallyVotes(allVotes, pollType), [allVotes, pollType]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[460px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12">
<DialogTitle className="text-base font-semibold">Voters</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Option filter bars — scrollable when more than 3 */}
<ScrollArea className={cn('px-4', options.length > 2 && 'max-h-[120px]')}>
<div className="space-y-1.5">
{/* "All" bar */}
<button
onClick={() => setActiveFilter(null)}
className={cn(
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
activeFilter === null ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
)}
>
<div
className={cn(
'absolute inset-0 transition-all duration-500',
activeFilter === null ? 'bg-primary/15' : 'bg-secondary/40',
)}
style={{ width: '100%' }}
/>
<div className="relative flex items-center justify-between px-3 py-2">
<span className={cn('text-sm', activeFilter === null && 'font-semibold')}>All</span>
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
{allVotes.length}
</span>
</div>
</button>
{/* Per-option bars */}
{options.map((opt) => {
const count = tally.get(opt.id) ?? 0;
const pct = allVotes.length > 0 ? Math.round((count / allVotes.length) * 100) : 0;
const isActive = activeFilter === opt.id;
return (
<button
key={opt.id}
onClick={() => setActiveFilter(opt.id)}
className={cn(
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
isActive ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
)}
>
<div
className={cn(
'absolute inset-0 transition-all duration-500',
isActive ? 'bg-primary/15' : 'bg-secondary/40',
)}
style={{ width: `${pct}%` }}
/>
<div className="relative flex items-center justify-between px-3 py-2">
<span className={cn('text-sm break-words min-w-0', isActive && 'font-semibold')}>{opt.label}</span>
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
{count}
</span>
</div>
</button>
);
})}
</div>
</ScrollArea>
{/* Primary accent divider — only when scrollbox is active */}
{options.length > 2 && <div className="mx-4 h-1 bg-primary rounded-full" />}
{/* Voter list */}
<ScrollArea className="max-h-[60vh]">
{filteredVoters.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm">
No votes yet
</div>
) : (
<div className="divide-y divide-border">
{filteredVoters.map((vote) => (
<VoterRow
key={vote.id}
vote={vote}
optionLabelMap={optionLabelMap}
pollType={pollType}
authorsMap={authorsMap}
/>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}
/* ──── Voter Row ──── */
interface VoterRowProps {
vote: NostrEvent;
optionLabelMap: Map<string, string>;
pollType: string;
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
}
function VoterRow({ vote, optionLabelMap, pollType, authorsMap }: VoterRowProps) {
// Use batch-fetched author data if available, fall back to individual fetch
const individualAuthor = useAuthor(authorsMap?.has(vote.pubkey) ? undefined : vote.pubkey);
const authorData = authorsMap?.get(vote.pubkey) ?? individualAuthor.data;
const metadata = authorData?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(vote.pubkey);
const nevent = useMemo(
() => nip19.neventEncode({ id: vote.id, author: vote.pubkey }),
[vote.id, vote.pubkey],
);
// Resolve which option(s) this person voted for
const votedOptions = useMemo(() => {
const responseTags = vote.tags.filter(([n]) => n === 'response');
if (pollType === 'singlechoice') {
const id = responseTags[0]?.[1];
const label = id ? optionLabelMap.get(id) : undefined;
return label ? [label] : [];
}
const labels: string[] = [];
const seen = new Set<string>();
for (const [, id] of responseTags) {
if (id && !seen.has(id)) {
seen.add(id);
const label = optionLabelMap.get(id);
if (label) labels.push(label);
}
}
return labels;
}, [vote.tags, pollType, optionLabelMap]);
return (
<Link
to={`/${nevent}`}
onClick={() => {
// Close any open dialogs by dispatching escape
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
}}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
>
<Avatar shape={avatarShape} className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm truncate">
{authorData?.event ? (
<EmojifiedText tags={authorData.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={vote.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</div>
<div className="flex items-center gap-2">
{votedOptions.length > 0 && (
<span className="text-xs text-muted-foreground truncate">
{votedOptions.join(', ')}
</span>
)}
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(vote.created_at)}</span>
</div>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
+12 -2
View File
@@ -12,6 +12,10 @@ interface ReplyContextProps {
pubkeys: string[];
/** Hex event ID of the parent post being replied to. */
parentEventId?: string;
/** Relay URL hint for fetching the parent event. */
parentRelayHint?: string;
/** Author pubkey hint for NIP-65 outbox resolution of the parent event. */
parentAuthorHint?: string;
className?: string;
}
@@ -20,7 +24,7 @@ interface ReplyContextProps {
* When parentEventId is provided, hovering over the line shows an embedded preview of the parent post.
* Used consistently across NoteCard and notification views.
*/
export function ReplyContext({ pubkeys, parentEventId, className }: ReplyContextProps) {
export function ReplyContext({ pubkeys, parentEventId, parentRelayHint, parentAuthorHint, className }: ReplyContextProps) {
// Filter out any undefined/empty pubkeys defensively
const validPubkeys = pubkeys.filter(Boolean);
// Show max 2 authors for cleaner UI
@@ -38,7 +42,13 @@ export function ReplyContext({ pubkeys, parentEventId, className }: ReplyContext
className="w-80 p-0 rounded-2xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<EmbeddedNote eventId={parentEventId} className="border-0 rounded-none" disableHoverCards />
<EmbeddedNote
eventId={parentEventId}
relays={parentRelayHint ? [parentRelayHint] : undefined}
authorHint={parentAuthorHint}
className="border-0 rounded-none"
disableHoverCards
/>
</HoverCardContent>
</HoverCard>
) : (
+9 -14
View File
@@ -5,7 +5,6 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useLocalStorage } from './useLocalStorage';
import { fetchFreshEvents } from '@/lib/fetchFreshEvent';
import {
KIND_BLOBBONAUT_PROFILE,
BLOBBONAUT_PROFILE_KINDS,
@@ -77,7 +76,7 @@ export function useBlobbonautProfile() {
// Main query to fetch the profile from relays
const query = useQuery({
queryKey: ['blobbonaut-profile', user?.pubkey],
queryFn: async () => {
queryFn: async ({ signal }) => {
if (!user?.pubkey) {
return null;
}
@@ -85,18 +84,14 @@ export function useBlobbonautProfile() {
// Query with all possible d-tag values (canonical + legacy)
const dValues = getBlobbonautQueryDValues(user.pubkey);
// Query BOTH current (11125) and legacy (31125) kinds for migration support.
// Use a relaxed eoseTimeout (1000ms) so slower relays have time to respond
// and we get the freshest profile across all relays.
const events = await fetchFreshEvents(
nostr,
[{
kinds: [...BLOBBONAUT_PROFILE_KINDS],
authors: [user.pubkey],
'#d': dValues,
}],
{ eoseTimeout: 1000 },
);
// Query BOTH current (11125) and legacy (31125) kinds for migration support
const filter = {
kinds: [...BLOBBONAUT_PROFILE_KINDS],
authors: [user.pubkey],
'#d': dValues,
};
const events = await nostr.query([filter], { signal });
// Filter to valid events
const validEvents = events.filter(isValidBlobbonautEvent);
+1 -1
View File
@@ -64,7 +64,7 @@ export function useEvent(eventId: string | undefined, relays?: string[], authorH
const { nostr } = useNostr();
return useQuery<NostrEvent | null>({
queryKey: ['event', eventId ?? ''],
queryKey: ['event', eventId ?? '', relays ?? [], authorHint ?? ''],
queryFn: async () => {
if (!eventId) return null;
const filter: NostrFilter[] = [{ ids: [eventId], limit: 1 }];
+34
View File
@@ -0,0 +1,34 @@
import { useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useEvent } from '@/hooks/useEvent';
/**
* Given a kind 1018 poll vote event, resolves the human-readable option label(s)
* by fetching the parent poll and mapping response IDs to option names.
*
* Returns an empty string for non-1018 events or if the parent poll hasn't loaded yet.
*/
export function usePollVoteLabel(event: NostrEvent): string {
const parentTag = useMemo(
() => event.kind === 1018 ? event.tags.find(([n]) => n === 'e') : undefined,
[event],
);
const parentId = parentTag?.[1];
const relayHint = parentTag?.[2] || undefined;
const authorHint = parentTag?.[4] || (event.kind === 1018 ? event.tags.find(([n]) => n === 'p')?.[1] : undefined) || undefined;
const { data: parentPoll } = useEvent(parentId, relayHint ? [relayHint] : undefined, authorHint);
return useMemo(() => {
const responseIds = event.kind === 1018
? event.tags.filter(([n]) => n === 'response').map(([, id]) => id)
: [];
if (responseIds.length === 0) return '';
if (!parentPoll) return responseIds.join(', ');
const optMap = new Map<string, string>();
for (const tag of parentPoll.tags) {
if (tag[0] === 'option') optMap.set(tag[1], tag[2]);
}
return responseIds.map((id) => optMap.get(id) ?? id).join(', ');
}, [event, parentPoll]);
}
+1 -1
View File
@@ -566,7 +566,7 @@ export class NostrBatcher {
req(
filters: NostrFilter[],
opts?: { signal?: AbortSignal; relays?: string[]; eoseTimeout?: number },
opts?: { signal?: AbortSignal },
): AsyncIterable<import('@nostrify/types').NostrRelayEVENT | import('@nostrify/types').NostrRelayEOSE | import('@nostrify/types').NostrRelayCLOSED> {
return this.pool.req(filters, opts);
}
+6 -54
View File
@@ -1,18 +1,5 @@
import type { NostrEvent, NostrFilter, NPool } from '@nostrify/nostrify';
interface FetchFreshEventOpts {
/**
* Override the pool-level eoseTimeout for this query. When set, uses
* `nostr.req()` directly with this value instead of `nostr.query()`,
* giving slower relays more time to respond.
*
* The default pool eoseTimeout is 300ms (resolves quickly after the
* fastest relay). Set to eg. 1000 for accuracy-sensitive queries where
* you need the absolute freshest event across all relays.
*/
eoseTimeout?: number;
}
/**
* Fetches the freshest version of a replaceable/addressable event directly from
* relays, bypassing any local cache.
@@ -37,9 +24,13 @@ interface FetchFreshEventOpts {
export async function fetchFreshEvent(
nostr: NPool,
filter: NostrFilter,
opts?: FetchFreshEventOpts,
): Promise<NostrEvent | null> {
const events = await fetchFreshEvents(nostr, [{ ...filter, limit: 1 }], opts);
const signal = AbortSignal.timeout(10_000);
const events = await nostr.query(
[{ ...filter, limit: 1 }],
{ signal },
);
if (events.length === 0) return null;
@@ -48,42 +39,3 @@ export async function fetchFreshEvent(
current.created_at > latest.created_at ? current : latest,
);
}
/**
* Fetches events from relays, bypassing any local cache. Like
* {@link fetchFreshEvent} but accepts multiple filters and returns all
* matching events (not just one).
*
* When `opts.eoseTimeout` is set, uses `nostr.req()` directly with that
* timeout, overriding the pool-level eoseTimeout. Otherwise falls back to
* the standard `nostr.query()` path.
*/
export async function fetchFreshEvents(
nostr: NPool,
filters: NostrFilter[],
opts?: FetchFreshEventOpts,
): Promise<NostrEvent[]> {
const signal = AbortSignal.timeout(10_000);
if (opts?.eoseTimeout !== undefined) {
// Use req() directly so we can pass a custom eoseTimeout,
// overriding the pool-level value (typically 300ms).
const events: NostrEvent[] = [];
const seen = new Set<string>();
for await (const msg of nostr.req(filters, { signal, eoseTimeout: opts.eoseTimeout })) {
if (msg[0] === 'EOSE' || msg[0] === 'CLOSED') break;
if (msg[0] === 'EVENT') {
const event = msg[2];
if (!seen.has(event.id)) {
seen.add(event.id);
events.push(event);
}
}
}
return events;
}
return nostr.query(filters, { signal });
}
+42 -4
View File
@@ -14,15 +14,53 @@ export function isReplyEvent(event: NostrEvent): boolean {
return nonMentionTags.length > 0;
}
/** Hints extracted from an `e` tag for relay resolution. */
export interface ParentEventHints {
id: string;
relayHint?: string;
authorHint?: string;
}
/**
* Extracts the parent (replied-to) event ID from an event's tags following NIP-10 conventions.
* Supports both the preferred marked-tag scheme and the deprecated positional scheme.
* For kind 7 reactions, uses NIP-25 semantics: the last `e` tag is the reacted-to event.
*/
export function getParentEventId(event: NostrEvent): string | undefined {
return getParentEventTag(event)?.[1];
}
/**
* Extracts the parent event ID along with relay and author hints from the `e` tag.
* Returns the full NIP-10 hints (relay URL at position [2], author pubkey at position [4]).
*
* When the `e` tag doesn't include a pubkey at position [4] (many clients omit it),
* falls back to the first `p` tag in the event, which per NIP-10 convention contains
* the pubkey of the author being replied to.
*/
export function getParentEventHints(event: NostrEvent): ParentEventHints | undefined {
const tag = getParentEventTag(event);
if (!tag) return undefined;
// Prefer the pubkey embedded in the e tag (NIP-10 position [4]).
// Fall back to the first p tag, which conventionally holds the parent author's pubkey.
const authorHint = tag[4] || event.tags.find(([name]) => name === 'p')?.[1] || undefined;
return {
id: tag[1],
relayHint: tag[2] || undefined,
authorHint,
};
}
/**
* Returns the raw parent `e` tag from an event following NIP-10 conventions.
* For kind 7 reactions, uses NIP-25 semantics: the last `e` tag is the reacted-to event.
*/
function getParentEventTag(event: NostrEvent): string[] | undefined {
// NIP-25: for kind 7 reactions, the target event is always the last e-tag
if (event.kind === 7) {
return event.tags.findLast(([name]) => name === 'e')?.[1];
return event.tags.findLast(([name]) => name === 'e');
}
// Exclude "mention" e-tags — they are inline quotes, not reply/root references
@@ -31,12 +69,12 @@ export function getParentEventId(event: NostrEvent): string | undefined {
// Preferred: look for marked "reply" tag first
const replyTag = eTags.find(([, , , marker]) => marker === 'reply');
if (replyTag) return replyTag[1];
if (replyTag) return replyTag;
// If there's a "root" marker but no "reply" marker, the event replies directly to root
const rootTag = eTags.find(([, , , marker]) => marker === 'root');
if (rootTag) return rootTag[1];
if (rootTag) return rootTag;
// Deprecated positional scheme: last non-mention e-tag is the reply target
return eTags[eTags.length - 1][1];
return eTags[eTags.length - 1];
}
+7 -1
View File
@@ -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;
+67 -11
View File
@@ -53,7 +53,7 @@ import { RepostIcon } from "@/components/icons/RepostIcon";
import { LiveStreamPage } from "@/components/LiveStreamPage";
import { MagicDeckContent } from "@/components/MagicDeckContent";
import { MusicDetailContent } from "@/components/MusicDetailContent";
import { EventActionHeader, NoteCard } from "@/components/NoteCard";
import { ActivityCard, EventActionHeader, NoteCard } from "@/components/NoteCard";
import { NoteContent } from "@/components/NoteContent";
import { NsiteCard } from "@/components/NsiteCard";
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
@@ -89,6 +89,7 @@ import { ZapstoreReleaseContent, ZapstoreReleaseSkeleton, ZapstoreAssetContent,
import { AppHandlerContent } from "@/components/AppHandlerContent";
import { useAppContext } from "@/hooks/useAppContext";
import { type AddrCoords, useAddrEvent, useEvent } from "@/hooks/useEvent";
import { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
import { formatNumber } from "@/lib/formatNumber";
import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls";
@@ -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(() => {
@@ -1459,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>
)}
@@ -1961,8 +1970,48 @@ 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 && (
@@ -2365,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;
@@ -2440,6 +2494,8 @@ function AncestorThread({
) : (
<AncestorThread
eventId={parentId}
relays={parentHints?.relayHint ? [parentHints.relayHint] : undefined}
authorHint={parentHints?.authorHint}
depth={depth + 1}
collapseAfter={collapseAfter}
/>