diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx
index e2ba9be6..215a4f95 100644
--- a/src/components/Embedded/EmbeddedNote.tsx
+++ b/src/components/Embedded/EmbeddedNote.tsx
@@ -21,7 +21,7 @@ import {
} from '@/lib/nostr-land-relay-eligibility'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { useFavoriteRelays } from '@/providers/favorite-relays-context'
-import { useDeletedEvent } from '@/providers/DeletedEventProvider'
+import { useIsEventDeleted } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -213,7 +213,7 @@ function EmbeddedNoteFetched({
allowLiveEmbeds: boolean
}) {
const { t } = useTranslation()
- const { isEventDeleted } = useDeletedEvent()
+ const isEventDeleted = useIsEventDeleted()
const { addReplies } = useReply()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { inboxRelayUrls } = useViewerInboxRelayUrls()
diff --git a/src/components/Embedded/EmbeddedNoteProviders.tsx b/src/components/Embedded/EmbeddedNoteProviders.tsx
new file mode 100644
index 00000000..85c1ae7a
--- /dev/null
+++ b/src/components/Embedded/EmbeddedNoteProviders.tsx
@@ -0,0 +1,11 @@
+import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
+import { ReplyProvider } from '@/providers/ReplyProvider'
+
+/** Minimal providers for {@link EmbeddedNote} in isolated `createRoot` trees (e.g. Asciidoc). */
+export default function EmbeddedNoteProviders({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
index 5e64a943..83df540b 100644
--- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
+++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
@@ -28,6 +28,7 @@ import EmbeddedCitation from '@/components/EmbeddedCitation'
import { parsePaytoUri } from '@/lib/payto'
import PaytoLink from '@/components/PaytoLink'
import { URI_LINK_CLASS, URI_LINK_INLINE_HTML_CLASS } from '@/lib/link-styles'
+import EmbeddedNoteProviders from '@/components/Embedded/EmbeddedNoteProviders'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { ReplyProvider } from '@/providers/ReplyProvider'
import Wikilink from '@/components/UniversalContent/Wikilink'
@@ -1080,7 +1081,11 @@ export default function AsciidocArticle({
// Use React to render the component, with error handling
try {
const root = createRoot(container)
- root.render()
+ root.render(
+
+
+
+ )
reactRootsRef.current.set(container, root)
} catch (error) {
logger.error('Failed to render nostr note', { bech32Id, error })
diff --git a/src/components/NoteStats/NoteStatsCountHover.tsx b/src/components/NoteStats/NoteStatsCountHover.tsx
index fe112e97..713a6d4f 100644
--- a/src/components/NoteStats/NoteStatsCountHover.tsx
+++ b/src/components/NoteStats/NoteStatsCountHover.tsx
@@ -1,5 +1,8 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import Emoji from '@/components/Emoji'
+import { useLongPressAction } from '@/hooks/use-long-press-action'
+import { useNoteStatsDetailOpenMode } from '@/hooks/use-note-stats-detail-open-mode'
import Username from '@/components/Username'
import {
DISCUSSION_DOWNVOTE_DISPLAY,
@@ -20,7 +23,7 @@ import type { TNoteStats } from '@/services/note-stats.service'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { useUserTrust } from '@/contexts/user-trust-context'
import { TEmoji } from '@/types'
-import { useMemo, type ReactNode } from 'react'
+import { useMemo, useState, type PointerEvent, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
function formatZapLineAmount(amount: number) {
@@ -130,6 +133,13 @@ function ReactionGroupsList({
)
}
+const statsCountTriggerClass =
+ 'underline decoration-dotted decoration-muted-foreground/45 underline-offset-2'
+
+function stopTriggerBubble(e: { stopPropagation: () => void }) {
+ e.stopPropagation()
+}
+
export function NoteStatsCountHover({
enabled,
children,
@@ -141,33 +151,82 @@ export function NoteStatsCountHover({
content: ReactNode
className?: string
}) {
+ const { t } = useTranslation()
+ const openMode = useNoteStatsDetailOpenMode()
+ const [popoverOpen, setPopoverOpen] = useState(false)
+ const longPress = useLongPressAction(() => setPopoverOpen(true), {
+ enabled: enabled && openMode === 'longPress'
+ })
+
if (!enabled) {
return <>{children}>
}
+ const trigger = (
+ {
+ stopTriggerBubble(e)
+ if (longPress.consumeIfLongPress()) return
+ }}
+ onMouseDown={stopTriggerBubble}
+ onTouchStart={stopTriggerBubble}
+ {...(openMode === 'longPress'
+ ? {
+ onPointerDown: (e: PointerEvent) => {
+ stopTriggerBubble(e)
+ longPress.onPointerDown()
+ },
+ onPointerUp: (e: PointerEvent) => {
+ stopTriggerBubble(e)
+ longPress.onPointerUp()
+ },
+ onPointerLeave: (e: PointerEvent) => {
+ stopTriggerBubble(e)
+ longPress.onPointerLeave()
+ },
+ onPointerCancel: (e: PointerEvent) => {
+ stopTriggerBubble(e)
+ longPress.onPointerCancel()
+ }
+ }
+ : {})}
+ >
+ {children}
+
+ )
+
+ const panel = (
+
+ {content}
+
+ )
+
+ if (openMode === 'longPress') {
+ return (
+
+ {trigger}
+
+ {panel}
+
+
+ )
+ }
+
return (
-
- e.stopPropagation()}
- onMouseDown={(e) => e.stopPropagation()}
- onTouchStart={(e) => e.stopPropagation()}
- >
- {children}
-
-
- e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
- >
- {content}
+ {trigger}
+
+ {panel}
)
diff --git a/src/hooks/use-note-stats-detail-open-mode.ts b/src/hooks/use-note-stats-detail-open-mode.ts
new file mode 100644
index 00000000..9cab3678
--- /dev/null
+++ b/src/hooks/use-note-stats-detail-open-mode.ts
@@ -0,0 +1,20 @@
+import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
+import { useEffect, useState } from 'react'
+
+/** Desktop: hover card. Touch / narrow: long-press popover. */
+export type NoteStatsDetailOpenMode = 'hover' | 'longPress'
+
+export function useNoteStatsDetailOpenMode(): NoteStatsDetailOpenMode {
+ const isSmallScreen = useScreenSizeOptional()?.isSmallScreen ?? false
+ const [touchPrimary, setTouchPrimary] = useState(false)
+
+ useEffect(() => {
+ const mq = window.matchMedia('(hover: none), (pointer: coarse)')
+ const update = () => setTouchPrimary(mq.matches)
+ update()
+ mq.addEventListener('change', update)
+ return () => mq.removeEventListener('change', update)
+ }, [])
+
+ return isSmallScreen || touchPrimary ? 'longPress' : 'hover'
+}
diff --git a/src/hooks/useFetchEvent.tsx b/src/hooks/useFetchEvent.tsx
index 8ce23e85..8194a8cb 100644
--- a/src/hooks/useFetchEvent.tsx
+++ b/src/hooks/useFetchEvent.tsx
@@ -1,5 +1,5 @@
import { getNoteBech32Id } from '@/lib/event'
-import { useDeletedEvent } from '@/providers/DeletedEventProvider'
+import { useIsEventDeleted } from '@/providers/DeletedEventProvider'
import { useReplyIngress } from '@/hooks/useReplyIngress'
import { eventService } from '@/services/client.service'
import { navigationEventStore } from '@/services/navigation-event-store'
@@ -11,7 +11,7 @@ export function useFetchEvent(
initialEvent?: Event,
fetchOpts?: { relayHints?: string[] }
) {
- const { isEventDeleted } = useDeletedEvent()
+ const isEventDeleted = useIsEventDeleted()
const { addReplies } = useReplyIngress()
const [error, setError] = useState(null)
const [event, setEvent] = useState(initialEvent)
diff --git a/src/hooks/useFetchThreadContextEvent.tsx b/src/hooks/useFetchThreadContextEvent.tsx
index eb3c5ca6..5b6c18f7 100644
--- a/src/hooks/useFetchThreadContextEvent.tsx
+++ b/src/hooks/useFetchThreadContextEvent.tsx
@@ -2,7 +2,7 @@ import { THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS } from '@/constants'
import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
-import { useDeletedEvent } from '@/providers/DeletedEventProvider'
+import { useIsEventDeleted } from '@/providers/DeletedEventProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useReplyIngress } from '@/hooks/useReplyIngress'
import { getNoteBech32Id, getParentETag, getRootETag } from '@/lib/event'
@@ -49,7 +49,7 @@ export function useFetchThreadContextEvent(
) {
const { pubkey: viewerPubkey } = useNostr()
const { blockedRelays } = useFavoriteRelays()
- const { isEventDeleted } = useDeletedEvent()
+ const isEventDeleted = useIsEventDeleted()
const { addReplies } = useReplyIngress()
const [error, setError] = useState(null)
const [event, setEvent] = useState(initialEvent)
diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts
index c5edb561..7aad6371 100644
--- a/src/i18n/locales/cs.ts
+++ b/src/i18n/locales/cs.ts
@@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more',
+ 'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up',
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index e69f6f5c..d25afd31 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -46,6 +46,7 @@ export default {
'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more',
+ 'noteStats.longPressForDetails': 'Gedrückt halten für Details',
'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 6d8821f4..6f986b99 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -43,6 +43,7 @@ export default {
'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more',
+ 'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up',
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index 8229a38f..df257ef2 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more',
+ 'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up',
diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts
index 317f7676..2d3486e6 100644
--- a/src/i18n/locales/fr.ts
+++ b/src/i18n/locales/fr.ts
@@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more',
+ 'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up',
diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts
index 9f9ff889..d25610ab 100644
--- a/src/i18n/locales/nl.ts
+++ b/src/i18n/locales/nl.ts
@@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more',
+ 'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up',
diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts
index 0d63712a..1f20c43a 100644
--- a/src/i18n/locales/pl.ts
+++ b/src/i18n/locales/pl.ts
@@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more',
+ 'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up',
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index efdc0769..0a637c33 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more',
+ 'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up',
diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts
index 5e9766cb..e7fac2b8 100644
--- a/src/i18n/locales/tr.ts
+++ b/src/i18n/locales/tr.ts
@@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more',
+ 'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up',
diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts
index 3ecd79ad..4d74b4e9 100644
--- a/src/i18n/locales/zh.ts
+++ b/src/i18n/locales/zh.ts
@@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more',
+ 'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up',
diff --git a/src/providers/DeletedEventProvider.tsx b/src/providers/DeletedEventProvider.tsx
index 032e977c..220c7f22 100644
--- a/src/providers/DeletedEventProvider.tsx
+++ b/src/providers/DeletedEventProvider.tsx
@@ -15,14 +15,26 @@ type TDeletedEventContext = {
const DeletedEventContext = createContext(undefined)
+const noopIsEventDeleted = () => false
+
+/** Returns undefined outside provider (e.g. Asciidoc `createRoot` embeds before wrappers mount). */
+export function useDeletedEventOptional(): TDeletedEventContext | undefined {
+ return useContext(DeletedEventContext)
+}
+
export const useDeletedEvent = () => {
- const context = useContext(DeletedEventContext)
+ const context = useDeletedEventOptional()
if (!context) {
throw new Error('useDeletedEvent must be used within a DeletedEventProvider')
}
return context
}
+/** Safe for hooks used inside optional provider trees (embedded notes, etc.). */
+export function useIsEventDeleted(): (event: NostrEvent) => boolean {
+ return useDeletedEventOptional()?.isEventDeleted ?? noopIsEventDeleted
+}
+
export function DeletedEventProvider({ children }: { children: React.ReactNode }) {
const [tombstoneKeys, setTombstoneKeys] = useState>(() => new Set())
const [tombstoneEpoch, setTombstoneEpoch] = useState(0)