Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 431c388129 | |||
| 72b63dac21 | |||
| be82cb9626 | |||
| c2c6f711b5 | |||
| 3fba81a7d2 | |||
| 6f2b51197f | |||
| 00c801e9dc | |||
| 47e7d05cb9 | |||
| 4ef6d1b149 | |||
| badd19d27c | |||
| e67f90582b | |||
| 7fa6e574f8 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Generated
+27
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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">“{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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user