Browse Source

make note stats mobile-friendly

imwald
Silberengel 3 weeks ago
parent
commit
357e6401c8
  1. 4
      src/components/Embedded/EmbeddedNote.tsx
  2. 11
      src/components/Embedded/EmbeddedNoteProviders.tsx
  3. 7
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  4. 103
      src/components/NoteStats/NoteStatsCountHover.tsx
  5. 20
      src/hooks/use-note-stats-detail-open-mode.ts
  6. 4
      src/hooks/useFetchEvent.tsx
  7. 4
      src/hooks/useFetchThreadContextEvent.tsx
  8. 1
      src/i18n/locales/cs.ts
  9. 1
      src/i18n/locales/de.ts
  10. 1
      src/i18n/locales/en.ts
  11. 1
      src/i18n/locales/es.ts
  12. 1
      src/i18n/locales/fr.ts
  13. 1
      src/i18n/locales/nl.ts
  14. 1
      src/i18n/locales/pl.ts
  15. 1
      src/i18n/locales/ru.ts
  16. 1
      src/i18n/locales/tr.ts
  17. 1
      src/i18n/locales/zh.ts
  18. 14
      src/providers/DeletedEventProvider.tsx

4
src/components/Embedded/EmbeddedNote.tsx

@ -21,7 +21,7 @@ import { @@ -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({ @@ -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()

11
src/components/Embedded/EmbeddedNoteProviders.tsx

@ -0,0 +1,11 @@ @@ -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 (
<DeletedEventProvider>
<ReplyProvider>{children}</ReplyProvider>
</DeletedEventProvider>
)
}

7
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -28,6 +28,7 @@ import EmbeddedCitation from '@/components/EmbeddedCitation' @@ -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({ @@ -1080,7 +1081,11 @@ export default function AsciidocArticle({
// Use React to render the component, with error handling
try {
const root = createRoot(container)
root.render(<EmbeddedNote noteId={bech32Id} />)
root.render(
<EmbeddedNoteProviders>
<EmbeddedNote noteId={bech32Id} />
</EmbeddedNoteProviders>
)
reactRootsRef.current.set(container, root)
} catch (error) {
logger.error('Failed to render nostr note', { bech32Id, error })

103
src/components/NoteStats/NoteStatsCountHover.tsx

@ -1,5 +1,8 @@ @@ -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' @@ -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({ @@ -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({ @@ -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 = (
<span
className={cn(
statsCountTriggerClass,
openMode === 'hover' ? 'cursor-help' : 'cursor-default touch-manipulation',
className
)}
title={openMode === 'longPress' ? t('noteStats.longPressForDetails') : undefined}
onClick={(e) => {
stopTriggerBubble(e)
if (longPress.consumeIfLongPress()) return
}}
onMouseDown={stopTriggerBubble}
onTouchStart={stopTriggerBubble}
{...(openMode === 'longPress'
? {
onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {
stopTriggerBubble(e)
longPress.onPointerDown()
},
onPointerUp: (e: PointerEvent<HTMLSpanElement>) => {
stopTriggerBubble(e)
longPress.onPointerUp()
},
onPointerLeave: (e: PointerEvent<HTMLSpanElement>) => {
stopTriggerBubble(e)
longPress.onPointerLeave()
},
onPointerCancel: (e: PointerEvent<HTMLSpanElement>) => {
stopTriggerBubble(e)
longPress.onPointerCancel()
}
}
: {})}
>
{children}
</span>
)
const panel = (
<div
className="min-w-0"
onPointerDown={stopTriggerBubble}
onClick={stopTriggerBubble}
>
{content}
</div>
)
if (openMode === 'longPress') {
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent side="top" align="center" className="z-[100] w-72 p-3">
{panel}
</PopoverContent>
</Popover>
)
}
return (
<HoverCard openDelay={220} closeDelay={80}>
<HoverCardTrigger asChild>
<span
className={cn(
'cursor-help underline decoration-dotted decoration-muted-foreground/45 underline-offset-2',
className
)}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
{children}
</span>
</HoverCardTrigger>
<HoverCardContent
side="top"
align="center"
className="z-[100] w-72 p-3"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{content}
<HoverCardTrigger asChild>{trigger}</HoverCardTrigger>
<HoverCardContent side="top" align="center" className="z-[100] w-72 p-3">
{panel}
</HoverCardContent>
</HoverCard>
)

20
src/hooks/use-note-stats-detail-open-mode.ts

@ -0,0 +1,20 @@ @@ -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'
}

4
src/hooks/useFetchEvent.tsx

@ -1,5 +1,5 @@ @@ -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( @@ -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<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(initialEvent)

4
src/hooks/useFetchThreadContextEvent.tsx

@ -2,7 +2,7 @@ import { THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS } from '@/constants' @@ -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( @@ -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<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(initialEvent)

1
src/i18n/locales/cs.ts

@ -45,6 +45,7 @@ export default { @@ -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',

1
src/i18n/locales/de.ts

@ -46,6 +46,7 @@ export default { @@ -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',

1
src/i18n/locales/en.ts

@ -43,6 +43,7 @@ export default { @@ -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',

1
src/i18n/locales/es.ts

@ -45,6 +45,7 @@ export default { @@ -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',

1
src/i18n/locales/fr.ts

@ -45,6 +45,7 @@ export default { @@ -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',

1
src/i18n/locales/nl.ts

@ -45,6 +45,7 @@ export default { @@ -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',

1
src/i18n/locales/pl.ts

@ -45,6 +45,7 @@ export default { @@ -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',

1
src/i18n/locales/ru.ts

@ -45,6 +45,7 @@ export default { @@ -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',

1
src/i18n/locales/tr.ts

@ -45,6 +45,7 @@ export default { @@ -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',

1
src/i18n/locales/zh.ts

@ -45,6 +45,7 @@ export default { @@ -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',

14
src/providers/DeletedEventProvider.tsx

@ -15,14 +15,26 @@ type TDeletedEventContext = { @@ -15,14 +15,26 @@ type TDeletedEventContext = {
const DeletedEventContext = createContext<TDeletedEventContext | undefined>(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<Set<string>>(() => new Set())
const [tombstoneEpoch, setTombstoneEpoch] = useState(0)

Loading…
Cancel
Save