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. 89
      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 {
} from '@/lib/nostr-land-relay-eligibility' } from '@/lib/nostr-land-relay-eligibility'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useIsEventDeleted } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReply } from '@/providers/ReplyProvider'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -213,7 +213,7 @@ function EmbeddedNoteFetched({
allowLiveEmbeds: boolean allowLiveEmbeds: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent() const isEventDeleted = useIsEventDeleted()
const { addReplies } = useReply() const { addReplies } = useReply()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { inboxRelayUrls } = useViewerInboxRelayUrls() const { inboxRelayUrls } = useViewerInboxRelayUrls()

11
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 (
<DeletedEventProvider>
<ReplyProvider>{children}</ReplyProvider>
</DeletedEventProvider>
)
}

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

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

89
src/components/NoteStats/NoteStatsCountHover.tsx

@ -1,5 +1,8 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import Emoji from '@/components/Emoji' 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 Username from '@/components/Username'
import { import {
DISCUSSION_DOWNVOTE_DISPLAY, DISCUSSION_DOWNVOTE_DISPLAY,
@ -20,7 +23,7 @@ import type { TNoteStats } from '@/services/note-stats.service'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { useMemo, type ReactNode } from 'react' import { useMemo, useState, type PointerEvent, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
function formatZapLineAmount(amount: number) { 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({ export function NoteStatsCountHover({
enabled, enabled,
children, children,
@ -141,33 +151,82 @@ export function NoteStatsCountHover({
content: ReactNode content: ReactNode
className?: string className?: string
}) { }) {
const { t } = useTranslation()
const openMode = useNoteStatsDetailOpenMode()
const [popoverOpen, setPopoverOpen] = useState(false)
const longPress = useLongPressAction(() => setPopoverOpen(true), {
enabled: enabled && openMode === 'longPress'
})
if (!enabled) { if (!enabled) {
return <>{children}</> return <>{children}</>
} }
return ( const trigger = (
<HoverCard openDelay={220} closeDelay={80}>
<HoverCardTrigger asChild>
<span <span
className={cn( className={cn(
'cursor-help underline decoration-dotted decoration-muted-foreground/45 underline-offset-2', statsCountTriggerClass,
openMode === 'hover' ? 'cursor-help' : 'cursor-default touch-manipulation',
className className
)} )}
onClick={(e) => e.stopPropagation()} title={openMode === 'longPress' ? t('noteStats.longPressForDetails') : undefined}
onMouseDown={(e) => e.stopPropagation()} onClick={(e) => {
onTouchStart={(e) => e.stopPropagation()} 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} {children}
</span> </span>
</HoverCardTrigger> )
<HoverCardContent
side="top" const panel = (
align="center" <div
className="z-[100] w-72 p-3" className="min-w-0"
onPointerDown={(e) => e.stopPropagation()} onPointerDown={stopTriggerBubble}
onClick={(e) => e.stopPropagation()} onClick={stopTriggerBubble}
> >
{content} {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>{trigger}</HoverCardTrigger>
<HoverCardContent side="top" align="center" className="z-[100] w-72 p-3">
{panel}
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
) )

20
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'
}

4
src/hooks/useFetchEvent.tsx

@ -1,5 +1,5 @@
import { getNoteBech32Id } from '@/lib/event' import { getNoteBech32Id } from '@/lib/event'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useIsEventDeleted } from '@/providers/DeletedEventProvider'
import { useReplyIngress } from '@/hooks/useReplyIngress' import { useReplyIngress } from '@/hooks/useReplyIngress'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import { navigationEventStore } from '@/services/navigation-event-store' import { navigationEventStore } from '@/services/navigation-event-store'
@ -11,7 +11,7 @@ export function useFetchEvent(
initialEvent?: Event, initialEvent?: Event,
fetchOpts?: { relayHints?: string[] } fetchOpts?: { relayHints?: string[] }
) { ) {
const { isEventDeleted } = useDeletedEvent() const isEventDeleted = useIsEventDeleted()
const { addReplies } = useReplyIngress() const { addReplies } = useReplyIngress()
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(initialEvent) 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'
import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useIsEventDeleted } from '@/providers/DeletedEventProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useReplyIngress } from '@/hooks/useReplyIngress' import { useReplyIngress } from '@/hooks/useReplyIngress'
import { getNoteBech32Id, getParentETag, getRootETag } from '@/lib/event' import { getNoteBech32Id, getParentETag, getRootETag } from '@/lib/event'
@ -49,7 +49,7 @@ export function useFetchThreadContextEvent(
) { ) {
const { pubkey: viewerPubkey } = useNostr() const { pubkey: viewerPubkey } = useNostr()
const { blockedRelays } = useFavoriteRelays() const { blockedRelays } = useFavoriteRelays()
const { isEventDeleted } = useDeletedEvent() const isEventDeleted = useIsEventDeleted()
const { addReplies } = useReplyIngress() const { addReplies } = useReplyIngress()
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(initialEvent) const [event, setEvent] = useState<Event | undefined>(initialEvent)

1
src/i18n/locales/cs.ts

@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:', 'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:', 'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more', 'n more interactors': '{{count}} more',
'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up', 'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down', 'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up', 'Arrow up': 'Arrow up',

1
src/i18n/locales/de.ts

@ -46,6 +46,7 @@ export default {
'Liked by:': 'Liked by:', 'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:', 'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more', 'n more interactors': '{{count}} more',
'noteStats.longPressForDetails': 'Gedrückt halten für Details',
'Thumbs up': 'Thumbs up', 'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down', 'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up', 'Arrow up': 'Arrow up',

1
src/i18n/locales/en.ts

@ -43,6 +43,7 @@ export default {
'Liked by:': 'Liked by:', 'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:', 'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more', 'n more interactors': '{{count}} more',
'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up', 'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down', 'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up', 'Arrow up': 'Arrow up',

1
src/i18n/locales/es.ts

@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:', 'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:', 'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more', 'n more interactors': '{{count}} more',
'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up', 'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down', 'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up', 'Arrow up': 'Arrow up',

1
src/i18n/locales/fr.ts

@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:', 'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:', 'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more', 'n more interactors': '{{count}} more',
'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up', 'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down', 'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up', 'Arrow up': 'Arrow up',

1
src/i18n/locales/nl.ts

@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:', 'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:', 'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more', 'n more interactors': '{{count}} more',
'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up', 'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down', 'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up', 'Arrow up': 'Arrow up',

1
src/i18n/locales/pl.ts

@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:', 'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:', 'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more', 'n more interactors': '{{count}} more',
'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up', 'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down', 'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up', 'Arrow up': 'Arrow up',

1
src/i18n/locales/ru.ts

@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:', 'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:', 'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more', 'n more interactors': '{{count}} more',
'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up', 'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down', 'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up', 'Arrow up': 'Arrow up',

1
src/i18n/locales/tr.ts

@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:', 'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:', 'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more', 'n more interactors': '{{count}} more',
'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up', 'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down', 'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up', 'Arrow up': 'Arrow up',

1
src/i18n/locales/zh.ts

@ -45,6 +45,7 @@ export default {
'Liked by:': 'Liked by:', 'Liked by:': 'Liked by:',
'Disliked by:': 'Disliked by:', 'Disliked by:': 'Disliked by:',
'n more interactors': '{{count}} more', 'n more interactors': '{{count}} more',
'noteStats.longPressForDetails': 'Long-press for details',
'Thumbs up': 'Thumbs up', 'Thumbs up': 'Thumbs up',
'Thumbs down': 'Thumbs down', 'Thumbs down': 'Thumbs down',
'Arrow up': 'Arrow up', 'Arrow up': 'Arrow up',

14
src/providers/DeletedEventProvider.tsx

@ -15,14 +15,26 @@ type TDeletedEventContext = {
const DeletedEventContext = createContext<TDeletedEventContext | undefined>(undefined) 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 = () => { export const useDeletedEvent = () => {
const context = useContext(DeletedEventContext) const context = useDeletedEventOptional()
if (!context) { if (!context) {
throw new Error('useDeletedEvent must be used within a DeletedEventProvider') throw new Error('useDeletedEvent must be used within a DeletedEventProvider')
} }
return context 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 }) { export function DeletedEventProvider({ children }: { children: React.ReactNode }) {
const [tombstoneKeys, setTombstoneKeys] = useState<Set<string>>(() => new Set()) const [tombstoneKeys, setTombstoneKeys] = useState<Set<string>>(() => new Set())
const [tombstoneEpoch, setTombstoneEpoch] = useState(0) const [tombstoneEpoch, setTombstoneEpoch] = useState(0)

Loading…
Cancel
Save