Browse Source

bug-fixes

add zaps, comments, and reactions to profile
imwald
Silberengel 1 month ago
parent
commit
ff19b596b7
  1. 7
      src/components/PostEditor/PostContent.tsx
  2. 8
      src/components/PostEditor/index.tsx
  3. 179
      src/components/Profile/ProfileHeaderInteractions.tsx
  4. 5
      src/components/Profile/SmartFollowings.tsx
  5. 73
      src/components/Profile/index.tsx
  6. 59
      src/components/ProfileOptions/index.tsx
  7. 6
      src/constants.ts
  8. 128
      src/hooks/useProfileBadges.tsx
  9. 116
      src/hooks/useProfileInteractions.tsx
  10. 1
      src/i18n/locales/de.ts
  11. 1
      src/i18n/locales/en.ts

7
src/components/PostEditor/PostContent.tsx

@ -92,7 +92,8 @@ export default function PostContent({ @@ -92,7 +92,8 @@ export default function PostContent({
close,
openFrom,
initialHighlightData,
initialPublicMessageTo
initialPublicMessageTo,
onPublishSuccess
}: {
defaultContent?: string
parentEvent?: Event
@ -101,6 +102,8 @@ export default function PostContent({ @@ -101,6 +102,8 @@ export default function PostContent({
initialHighlightData?: HighlightData
/** When set, opens in public message mode with this pubkey in the mention list. */
initialPublicMessageTo?: string
/** Called after a reply/post is successfully published, before closing. */
onPublishSuccess?: () => void
}) {
const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
@ -927,6 +930,7 @@ export default function PostContent({ @@ -927,6 +930,7 @@ export default function PostContent({
mergePublishedReplyIntoThread(cleanEvent, relayStatuses)
}
onPublishSuccess?.()
close()
} catch (error) {
// AggregateError = "Failed to publish to any relay" is already logged in NostrProvider with relayStatuses; avoid duplicate noise
@ -968,6 +972,7 @@ export default function PostContent({ @@ -968,6 +972,7 @@ export default function PostContent({
}
postEditorCache.clearPostCache({ defaultContent, parentEvent })
if (draftEvent) deleteDraftEventCache(draftEvent)
onPublishSuccess?.()
close()
}
} else {

8
src/components/PostEditor/index.tsx

@ -27,7 +27,8 @@ export default function PostEditor({ @@ -27,7 +27,8 @@ export default function PostEditor({
setOpen,
openFrom,
initialHighlightData,
initialPublicMessageTo
initialPublicMessageTo,
onPublishSuccess
}: {
defaultContent?: string
parentEvent?: Event
@ -37,6 +38,8 @@ export default function PostEditor({ @@ -37,6 +38,8 @@ export default function PostEditor({
initialHighlightData?: import('./HighlightEditor').HighlightData
/** When set, opens in public message mode with this pubkey in the mention list. */
initialPublicMessageTo?: string
/** Called after a reply/post is successfully published, before closing. */
onPublishSuccess?: () => void
}) {
const { isSmallScreen } = useScreenSize()
@ -58,9 +61,10 @@ export default function PostEditor({ @@ -58,9 +61,10 @@ export default function PostEditor({
openFrom={openFrom}
initialHighlightData={initialHighlightData}
initialPublicMessageTo={initialPublicMessageTo}
onPublishSuccess={onPublishSuccess}
/>
)
}, [effectiveDefaultContent, parentEvent, openFrom, setOpen, initialHighlightData, initialPublicMessageTo])
}, [effectiveDefaultContent, parentEvent, openFrom, setOpen, initialHighlightData, initialPublicMessageTo, onPublishSuccess])
if (isSmallScreen) {
return (

179
src/components/Profile/ProfileHeaderInteractions.tsx

@ -0,0 +1,179 @@ @@ -0,0 +1,179 @@
import Content from '@/components/Content'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { formatAmount } from '@/lib/lightning'
import { toNote, toProfile } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import Emoji from '@/components/Emoji'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import type { TProfileZap } from '@/hooks/useProfileInteractions'
import type { TProfileBadge } from '@/hooks/useProfileBadges'
import { Zap, MessageCircle, ThumbsUp } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next'
import { Event } from 'nostr-tools'
type Props = {
zaps: TProfileZap[]
reactions: Event[]
comments: Event[]
badges: TProfileBadge[]
loading: boolean
badgesLoading: boolean
}
const ZAPS_PER_ROW = 4
const ZAP_ROWS = 3
const MAX_ZAPS = ZAPS_PER_ROW * ZAP_ROWS
const BADGES_PER_ROW = 4
const BADGE_ROWS = 2
const MAX_BADGES = BADGES_PER_ROW * BADGE_ROWS
function ZapBadge({ zap }: { zap: TProfileZap }) {
const { push } = useSecondaryPage()
return (
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border border-yellow-400/40 hover:bg-yellow-400/10 cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toProfile(zap.pubkey))}
>
<UserAvatar userId={zap.pubkey} size="tiny" className="shrink-0" />
<Zap className="size-3 shrink-0 text-yellow-500 fill-yellow-500" strokeWidth={2} aria-hidden />
<span className="font-semibold tabular-nums text-xs text-foreground truncate">{formatAmount(zap.amount)}</span>
</button>
)
}
function ReactionBadge({ event }: { event: Event }) {
const { push } = useSecondaryPage()
const emojiInfos = getEmojiInfosFromEmojiTags(event.tags)
const displayContent = event.content.trim() || (emojiInfos[0] ? emojiInfos[0].shortcode : '+')
const isPlus = displayContent === '+'
return (
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border hover:bg-muted cursor-pointer min-w-0 w-full"
onClick={() => push(toProfile(event.pubkey))}
>
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
{isPlus ? (
<ThumbsUp className="size-3 shrink-0 text-primary" aria-hidden />
) : typeof displayContent === 'string' && !displayContent.startsWith(':') ? (
<span className="text-xs shrink-0">{displayContent}</span>
) : (
<Emoji emoji={emojiInfos[0] ?? displayContent} classNames={{ img: 'size-3' }} />
)}
<Username userId={event.pubkey} className="truncate text-xs text-muted-foreground min-w-0" skeletonClassName="h-3" />
</button>
)
}
function CommentBadge({ event }: { event: Event }) {
const { push } = useSecondaryPage()
return (
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(event.id))}
>
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
<MessageCircle className="size-3 shrink-0 text-primary" aria-hidden />
<span className="truncate text-xs text-muted-foreground min-w-0">
<Content content={event.content} className="text-xs [&_p]:text-xs [&_p]:m-0 [&_p]:inline" />
</span>
</button>
)
}
function BadgeItem({ badge }: { badge: TProfileBadge }) {
const imageUrl = badge.thumb ?? badge.image
const label = badge.name ?? badge.a.split(':').pop() ?? ''
if (!imageUrl) {
return (
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted text-xs text-muted-foreground" title={label}>
{label.slice(0, 2)}
</div>
)
}
return (
<div className="relative size-12 shrink-0">
<img
src={imageUrl}
alt={label}
title={label}
className="size-12 rounded-lg border object-cover bg-muted"
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none'
const fallback = e.currentTarget.nextElementSibling as HTMLElement
if (fallback) fallback.classList.remove('hidden')
}}
/>
<div className="hidden absolute inset-0 flex items-center justify-center rounded-lg border bg-muted text-xs text-muted-foreground" title={label}>
{label.slice(0, 2)}
</div>
</div>
)
}
export default function ProfileHeaderInteractions({ zaps, reactions, comments, badges, loading, badgesLoading }: Props) {
const { t } = useTranslation()
const displayZaps = zaps.slice(0, MAX_ZAPS)
const displayBadges = badges.slice(0, MAX_BADGES)
const Section = ({ title, isEmpty, isLoading, children, skeletonCount = 6 }: {
title: string
isEmpty: boolean
isLoading: boolean
children: React.ReactNode
skeletonCount?: number
}) => (
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-1.5">{title}</div>
{isLoading && isEmpty ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{Array.from({ length: skeletonCount }).map((_, i) => (
<Skeleton key={i} className="h-8 rounded-md min-w-0" />
))}
</div>
) : isEmpty ? (
<div className="text-xs text-muted-foreground py-1">{t('None')}</div>
) : (
children
)}
</div>
)
return (
<div className="py-2 space-y-3 w-full min-w-0 overflow-visible">
<Section title={t('Zaps')} isEmpty={displayZaps.length === 0} isLoading={loading}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 grid-rows-3 gap-1.5">
{displayZaps.map((item) => (
<ZapBadge key={`zap-${item.pr}`} zap={item} />
))}
</div>
</Section>
<Section title={t('Likes')} isEmpty={reactions.length === 0} isLoading={loading}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{reactions.map((item) => (
<ReactionBadge key={`reaction-${item.id}`} event={item} />
))}
</div>
</Section>
<Section title={t('Comments')} isEmpty={comments.length === 0} isLoading={loading}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{comments.map((item) => (
<CommentBadge key={`comment-${item.id}`} event={item} />
))}
</div>
</Section>
<Section title={t('Badges')} isEmpty={displayBadges.length === 0} isLoading={badgesLoading} skeletonCount={8}>
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 grid-rows-2 gap-1.5">
{displayBadges.map((badge) => (
<BadgeItem key={`${badge.a}-${badge.awardId}`} badge={badge} />
))}
</div>
</Section>
</div>
)
}

5
src/components/Profile/SmartFollowings.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { useFetchFollowings } from '@/hooks'
import { toFollowingList } from '@/lib/link'
import { useSmartFollowingListNavigation } from '@/PageManager'
import { useFollowList } from '@/providers/FollowListProvider'
import { useFollowListOptional } from '@/providers/FollowListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next'
@ -9,7 +9,8 @@ import { useTranslation } from 'react-i18next' @@ -9,7 +9,8 @@ import { useTranslation } from 'react-i18next'
export default function SmartFollowings({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { pubkey: accountPubkey } = useNostr()
const { followings: selfFollowings } = useFollowList()
const followList = useFollowListOptional()
const selfFollowings = followList?.followings ?? []
const { followings, isFetching } = useFetchFollowings(pubkey)
const { navigateToFollowingList } = useSmartFollowingListNavigation()

73
src/components/Profile/index.tsx

@ -13,7 +13,9 @@ import { Button } from '@/components/ui/button' @@ -13,7 +13,9 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks'
import { kinds, type NostrEvent } from 'nostr-tools'
import { createReactionDraftEvent } from '@/lib/draft-event'
import { getPaymentInfoFromEvent } from '@/lib/event-metadata'
import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { toProfileEditor } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey'
import { usePrimaryPage } from '@/contexts/primary-page-context'
@ -28,7 +30,7 @@ import { @@ -28,7 +30,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link } from 'lucide-react'
import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link, MessageCircle, ThumbsUp } from 'lucide-react'
import {
useEffect,
useLayoutEffect,
@ -47,6 +49,7 @@ import ProfileFeedWithPins from './ProfileFeedWithPins' @@ -47,6 +49,7 @@ import ProfileFeedWithPins from './ProfileFeedWithPins'
import ProfileMediaFeed from './ProfileMediaFeed'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { TNoteListRef } from '@/components/NoteList'
import ProfileHeaderInteractions from './ProfileHeaderInteractions'
import SmartFollowings from './SmartFollowings'
import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays'
@ -59,6 +62,8 @@ import { @@ -59,6 +62,8 @@ import {
} from '@/components/ScheduleVideoCallDialog'
import RawEventDialog from '@/components/NoteOptions/RawEventDialog'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useProfileInteractions } from '@/hooks/useProfileInteractions'
import { useProfileBadges } from '@/hooks/useProfileBadges'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service'
@ -182,7 +187,7 @@ export default function Profile({ @@ -182,7 +187,7 @@ export default function Profile({
const mediaFeedRef = useRef<TNoteListRef>(null)
const { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey } = useNostr()
const { pubkey: accountPubkey, publish, checkLogin } = useNostr()
const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null)
const [profileEvent, setProfileEvent] = useState<NostrEvent | undefined>(undefined)
const [openZapDialog, setOpenZapDialog] = useState(false)
@ -191,6 +196,8 @@ export default function Profile({ @@ -191,6 +196,8 @@ export default function Profile({
const [openScheduleOwnCall, setOpenScheduleOwnCall] = useState(false)
const [openScheduleInPersonMeeting, setOpenScheduleInPersonMeeting] = useState(false)
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
const [openSelfReply, setOpenSelfReply] = useState(false)
const [selfReacting, setSelfReacting] = useState(false)
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
@ -280,6 +287,10 @@ export default function Profile({ @@ -280,6 +287,10 @@ export default function Profile({
[profile]
)
const isSelf = accountPubkey === profile?.pubkey
const { zaps: profileZaps, reactions: profileReactions, comments: profileComments, loading: profileInteractionsLoading, refresh: refreshProfileInteractions } =
useProfileInteractions(profile?.pubkey, profileEvent)
const { badges: profileBadges, loading: profileBadgesLoading, refresh: refreshProfileBadges } =
useProfileBadges(profile?.pubkey)
/** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo(() => {
@ -343,6 +354,8 @@ export default function Profile({ @@ -343,6 +354,8 @@ export default function Profile({
const m = r as MutableRefObject<{ refresh: () => void } | null>
m.current = {
refresh: () => {
refreshProfileInteractions()
refreshProfileBadges()
postsFeedRef.current?.refresh()
mediaFeedRef.current?.refresh()
}
@ -350,7 +363,7 @@ export default function Profile({ @@ -350,7 +363,7 @@ export default function Profile({
return () => {
m.current = null
}
}, [])
}, [refreshProfileInteractions, refreshProfileBadges])
useEffect(() => {
if (!profile?.pubkey) return
@ -414,6 +427,7 @@ export default function Profile({ @@ -414,6 +427,7 @@ export default function Profile({
? (url) => setOpenCallInviteTo({ pubkey, url })
: undefined
}
onProfileInteractionsRefresh={refreshProfileInteractions}
/>
{isSelf ? (
<DropdownMenu>
@ -423,6 +437,38 @@ export default function Profile({ @@ -423,6 +437,38 @@ export default function Profile({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{profileEvent && (
<>
<DropdownMenuItem onClick={() => setOpenSelfReply(true)}>
<MessageCircle />
{t('Reply')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (!profileEvent) return
checkLogin(async () => {
if (selfReacting) return
setSelfReacting(true)
try {
const reaction = createReactionDraftEvent(profileEvent, '+')
const evt = await publish(reaction)
if (evt) {
showSimplePublishSuccess(t('Reaction published'))
refreshProfileInteractions()
}
} finally {
setSelfReacting(false)
}
})
}}
disabled={selfReacting}
>
<ThumbsUp />
{selfReacting ? t('Publishing...') : t('Like')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => setOpenScheduleOwnCall(true)}>
<Calendar />
{t('Schedule a video call')}
@ -458,14 +504,23 @@ export default function Profile({ @@ -458,14 +504,23 @@ export default function Profile({
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
) : null}
{profileEvent && isSelf && (
<PostEditor
parentEvent={profileEvent}
open={openSelfReply}
setOpen={setOpenSelfReply}
onPublishSuccess={refreshProfileInteractions}
/>
)}
{!isSelf ? (
<>
{mergedPaymentMethods.some((m) => m.type === 'lightning') && (
<ProfileZapButton pubkey={pubkey} openZapDialog={openZapDialog} setOpenZapDialog={setOpenZapDialog} />
)}
<FollowButton pubkey={pubkey} />
</>
)}
) : null}
</div>
<div className="pt-2 md:pl-56">
<div className="flex gap-2 items-center">
@ -485,6 +540,14 @@ export default function Profile({ @@ -485,6 +540,14 @@ export default function Profile({
<PubkeyCopy pubkey={pubkey} />
<NpubQrCode pubkey={pubkey} />
</div>
<ProfileHeaderInteractions
zaps={profileZaps}
reactions={profileReactions}
comments={profileComments}
badges={profileBadges}
loading={profileInteractionsLoading}
badgesLoading={profileBadgesLoading}
/>
<Collapsible>
<ProfileAbout
about={about}

59
src/components/ProfileOptions/index.tsx

@ -18,8 +18,11 @@ import client from '@/services/client.service' @@ -18,8 +18,11 @@ import client from '@/services/client.service'
import { replaceableEventService } from '@/services/client.service'
import { nip66Service } from '@/services/nip66.service'
import RawEventDialog from '@/components/NoteOptions/RawEventDialog'
import { Bell, BellOff, Copy, Ellipsis, MessageCircle, Send, Video, SatelliteDish, Code } from 'lucide-react'
import { Bell, BellOff, Copy, Ellipsis, ThumbsUp, MessageCircle, Send, Video, SatelliteDish, Code } from 'lucide-react'
import { useMemo, useState, useEffect } from 'react'
import { createReactionDraftEvent } from '@/lib/draft-event'
import PostEditor from '@/components/PostEditor'
import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Event } from 'nostr-tools'
@ -28,7 +31,8 @@ export default function ProfileOptions({ @@ -28,7 +31,8 @@ export default function ProfileOptions({
pubkey,
profileEvent,
onSendPublicMessage,
onSendCallInvite
onSendCallInvite,
onProfileInteractionsRefresh
}: {
pubkey: string
/** Optional profile event (kind 0) for republishing and viewing JSON */
@ -37,13 +41,17 @@ export default function ProfileOptions({ @@ -37,13 +41,17 @@ export default function ProfileOptions({
onSendPublicMessage?: () => void
/** Opens the post editor to send the call invite URL as a public message to this profile. */
onSendCallInvite?: (url: string) => void
/** Called after Like or Reply to refresh profile header interactions. */
onProfileInteractionsRefresh?: () => void
}) {
const { t } = useTranslation()
const { pubkey: accountPubkey, profile } = useNostr()
const { pubkey: accountPubkey, profile, publish, checkLogin } = useNostr()
const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
const [openReply, setOpenReply] = useState(false)
const [reacting, setReacting] = useState(false)
const [monitoringListRelayCount, setMonitoringListRelayCount] = useState<number | null>(null)
const [localProfileEvent, setLocalProfileEvent] = useState<Event | undefined>(profileEvent)
@ -143,6 +151,26 @@ export default function ProfileOptions({ @@ -143,6 +151,26 @@ export default function ProfileOptions({
})
}
const eventToUse = localProfileEvent || profileEvent
const handleLike = () => {
if (!eventToUse) return
checkLogin(async () => {
if (reacting) return
setReacting(true)
try {
const reaction = createReactionDraftEvent(eventToUse, '+')
const evt = await publish(reaction)
if (evt) {
showSimplePublishSuccess(t('Reaction published'))
onProfileInteractionsRefresh?.()
}
} finally {
setReacting(false)
}
})
}
if (pubkey === accountPubkey) return null
const callInviteUrl =
@ -160,6 +188,19 @@ export default function ProfileOptions({ @@ -160,6 +188,19 @@ export default function ProfileOptions({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{eventToUse && (
<>
<DropdownMenuItem onClick={() => setOpenReply(true)}>
<MessageCircle />
{t('Reply')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLike} disabled={reacting}>
<ThumbsUp />
{reacting ? t('Publishing...') : t('Like')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{onSendPublicMessage && (
<DropdownMenuItem onClick={onSendPublicMessage}>
<MessageCircle />
@ -244,8 +285,16 @@ export default function ProfileOptions({ @@ -244,8 +285,16 @@ export default function ProfileOptions({
</>
)}
</DropdownMenuContent>
{(localProfileEvent || profileEvent) && (
<RawEventDialog
{eventToUse && (
<PostEditor
parentEvent={eventToUse}
open={openReply}
setOpen={setOpenReply}
onPublishSuccess={onProfileInteractionsRefresh}
/>
)}
{(localProfileEvent || profileEvent) && (
<RawEventDialog
event={(localProfileEvent || profileEvent)!}
isOpen={isRawEventDialogOpen}
onClose={() => setIsRawEventDialogOpen(false)}

6
src/constants.ts

@ -296,7 +296,11 @@ export const ExtendedKind = { @@ -296,7 +296,11 @@ export const ExtendedKind = {
/** NIP-52 Calendar event RSVP */
CALENDAR_EVENT_RSVP: 31925,
/** NIP-A7 Spells: portable relay query filters (kind 777) */
SPELL: 777
SPELL: 777,
/** NIP-58 Badges: profile badges list (addressable, d=profile_badges) */
PROFILE_BADGES: 30008,
/** NIP-58 Badges: badge definition (addressable) */
BADGE_DEFINITION: 30009
}
/** NIP-52 calendar event kinds (addressable by d-tag); use in isReplaceableEvent. */

128
src/hooks/useProfileBadges.tsx

@ -0,0 +1,128 @@ @@ -0,0 +1,128 @@
import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind } from '@/constants'
import { queryService, replaceableEventService } from '@/services/client.service'
import { useCallback, useEffect, useRef, useState } from 'react'
import { tagNameEquals } from '@/lib/tag'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
export type TProfileBadge = {
/** Badge definition coordinate (e.g. "30009:alice:bravery") */
a: string
/** Badge award event id */
awardId: string
/** Human-readable name from definition */
name?: string
/** High-res image URL */
image?: string
/** Thumbnail URL (prefer thumb over image for grid display) */
thumb?: string
}
/** Parse a-tag "30009:pubkey:d" into { kind, pubkey, d } */
function parseATag(aTag: string): { kind: number; pubkey: string; d: string } | null {
const parts = aTag.split(':')
if (parts.length < 3) return null
const kind = parseInt(parts[0], 10)
if (isNaN(kind)) return null
return { kind, pubkey: parts[1], d: parts[2] }
}
/** NIP-58: Fetches profile badges (kind 30008) and resolves badge definitions (kind 30009). */
export function useProfileBadges(pubkey: string | undefined) {
const { pubkey: accountPubkey } = useNostr()
const { blockedRelays } = useFavoriteRelays()
const [badges, setBadges] = useState<TProfileBadge[]>([])
const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0)
const fetchBadges = useCallback(async () => {
if (!pubkey) {
setBadges([])
return
}
const myFetchId = (fetchIdRef.current += 1)
setLoading(true)
try {
const relayUrls = await buildComprehensiveRelayList({
authorPubkey: pubkey,
userPubkey: accountPubkey ?? undefined,
blockedRelays: [...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS],
includeFastReadRelays: true,
includeSearchableRelays: true,
includeProfileFetchRelays: true,
includeLocalRelays: true
})
const events = await queryService.fetchEvents(
relayUrls,
{ authors: [pubkey], kinds: [ExtendedKind.PROFILE_BADGES], '#d': ['profile_badges'] },
undefined
)
const profileBadgesEvent = events.sort((a, b) => b.created_at - a.created_at)[0]
if (!profileBadgesEvent || myFetchId !== fetchIdRef.current) {
if (myFetchId === fetchIdRef.current) setBadges([])
return
}
const tags = profileBadgesEvent.tags
const pairs: { a: string; e: string }[] = []
for (let i = 0; i < tags.length - 1; i++) {
const [tagNameA, aVal] = tags[i]
const [tagNameE, eVal] = tags[i + 1]
if (tagNameA === 'a' && tagNameE === 'e' && aVal && eVal && /^[a-f0-9]{64}$/i.test(eVal)) {
pairs.push({ a: aVal, e: eVal })
}
}
if (pairs.length === 0) {
setBadges([])
return
}
const result: TProfileBadge[] = []
for (const { a, e } of pairs) {
const parsed = parseATag(a)
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) {
result.push({ a, awardId: e })
continue
}
const defEvent = await replaceableEventService.fetchReplaceableEvent(
parsed.pubkey,
parsed.kind,
parsed.d
)
const name = defEvent?.tags.find(tagNameEquals('name'))?.[1]
const image = defEvent?.tags.find(tagNameEquals('image'))?.[1]
const thumb = defEvent?.tags.find(tagNameEquals('thumb'))?.[1]
result.push({
a,
awardId: e,
name: name ?? parsed.d,
image,
thumb: thumb ?? image
})
}
if (myFetchId !== fetchIdRef.current) return
setBadges(result)
} catch {
if (myFetchId !== fetchIdRef.current) return
setBadges([])
} finally {
if (myFetchId === fetchIdRef.current) setLoading(false)
}
}, [pubkey, accountPubkey, blockedRelays])
useEffect(() => {
fetchBadges()
}, [fetchBadges])
return { badges, loading, refresh: fetchBadges }
}

116
src/hooks/useProfileInteractions.tsx

@ -0,0 +1,116 @@ @@ -0,0 +1,116 @@
import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { queryService } from '@/services/client.service'
import { Event, Filter, kinds } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
export type TProfileZap = {
pr: string
pubkey: string
amount: number
created_at: number
comment?: string
}
/** Fetches zaps, reactions (likes), and comments for a profile. */
export function useProfileInteractions(pubkey: string | undefined, profileEvent: Event | undefined) {
const { pubkey: accountPubkey } = useNostr()
const { blockedRelays } = useFavoriteRelays()
const [zaps, setZaps] = useState<TProfileZap[]>([])
const [reactions, setReactions] = useState<Event[]>([])
const [comments, setComments] = useState<Event[]>([])
const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0)
const fetchAll = useCallback(async () => {
if (!pubkey) {
setZaps([])
setReactions([])
setComments([])
return
}
const myFetchId = (fetchIdRef.current += 1)
setLoading(true)
try {
const relayUrls = await buildComprehensiveRelayList({
authorPubkey: pubkey,
userPubkey: accountPubkey ?? undefined,
blockedRelays: [...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS],
includeFastReadRelays: true,
includeSearchableRelays: true,
includeProfileFetchRelays: true,
includeLocalRelays: true
})
const filters: Filter[] = [{ '#p': [pubkey], kinds: [kinds.Zap], limit: 100 }]
if (profileEvent) {
filters.push({
'#e': [profileEvent.id],
kinds: [kinds.Reaction, ExtendedKind.COMMENT],
limit: 50
})
}
const collectedZaps: TProfileZap[] = []
const collectedReactions: Event[] = []
const collectedComments: Event[] = []
const seenZaps = new Set<string>()
const seenReactions = new Set<string>()
await queryService.fetchEvents(relayUrls, filters, {
onevent: (evt) => {
if (evt.kind === kinds.Zap) {
const info = getZapInfoFromEvent(evt)
if (!info || info.recipientPubkey !== pubkey || !info.amount || info.amount <= 0) return
if (seenZaps.has(evt.id)) return
seenZaps.add(evt.id)
collectedZaps.push({
pr: evt.id,
pubkey: info.senderPubkey ?? evt.pubkey,
amount: info.amount,
created_at: evt.created_at,
comment: info.comment
})
} else if (evt.kind === kinds.Reaction || evt.kind === ExtendedKind.COMMENT) {
if (seenReactions.has(evt.id)) return
seenReactions.add(evt.id)
if (evt.kind === kinds.Reaction) {
collectedReactions.push(evt)
} else {
collectedComments.push(evt)
}
}
}
})
if (myFetchId !== fetchIdRef.current) return
collectedZaps.sort((a, b) => b.amount - a.amount)
collectedReactions.sort((a, b) => b.created_at - a.created_at)
collectedComments.sort((a, b) => b.created_at - a.created_at)
setZaps(collectedZaps)
setReactions(collectedReactions)
setComments(collectedComments)
} catch {
if (myFetchId !== fetchIdRef.current) return
} finally {
if (myFetchId === fetchIdRef.current) setLoading(false)
}
}, [pubkey, profileEvent?.id, accountPubkey, blockedRelays])
useEffect(() => {
fetchAll()
}, [fetchAll])
return { zaps, reactions, comments, loading, refresh: fetchAll }
}
/** @deprecated Use useProfileInteractions instead. Returns zaps only for compatibility. */
export function useProfileZaps(pubkey: string | undefined) {
const result = useProfileInteractions(pubkey, undefined)
return { zaps: result.zaps, loading: result.loading, refresh: result.refresh }
}

1
src/i18n/locales/de.ts

@ -422,6 +422,7 @@ export default { @@ -422,6 +422,7 @@ export default {
All: 'Alle',
Reactions: 'Reaktionen',
Zaps: 'Zaps',
Badges: 'Abzeichen',
'Enjoying Jumble?': 'Gefällt dir Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊':
'Deine Spende hilft mir, Jumble zu pflegen und zu verbessern! 😊',

1
src/i18n/locales/en.ts

@ -415,6 +415,7 @@ export default { @@ -415,6 +415,7 @@ export default {
All: 'All',
Reactions: 'Reactions',
Zaps: 'Zaps',
Badges: 'Badges',
'Enjoying Jumble?': 'Enjoying Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊':
'Your donation helps me maintain Jumble and make it better! 😊',

Loading…
Cancel
Save