From 357e6401c80d428ee2c923b0994b5a5a3f3bf467 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 24 May 2026 11:35:18 +0200 Subject: [PATCH] make note stats mobile-friendly --- src/components/Embedded/EmbeddedNote.tsx | 4 +- .../Embedded/EmbeddedNoteProviders.tsx | 11 ++ .../Note/AsciidocArticle/AsciidocArticle.tsx | 7 +- .../NoteStats/NoteStatsCountHover.tsx | 103 ++++++++++++++---- src/hooks/use-note-stats-detail-open-mode.ts | 20 ++++ src/hooks/useFetchEvent.tsx | 4 +- src/hooks/useFetchThreadContextEvent.tsx | 4 +- src/i18n/locales/cs.ts | 1 + src/i18n/locales/de.ts | 1 + src/i18n/locales/en.ts | 1 + src/i18n/locales/es.ts | 1 + src/i18n/locales/fr.ts | 1 + src/i18n/locales/nl.ts | 1 + src/i18n/locales/pl.ts | 1 + src/i18n/locales/ru.ts | 1 + src/i18n/locales/tr.ts | 1 + src/i18n/locales/zh.ts | 1 + src/providers/DeletedEventProvider.tsx | 14 ++- 18 files changed, 147 insertions(+), 30 deletions(-) create mode 100644 src/components/Embedded/EmbeddedNoteProviders.tsx create mode 100644 src/hooks/use-note-stats-detail-open-mode.ts 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)