You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

350 lines
13 KiB

import Content from '@/components/Content'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import ProfileBadgeDetailDialog from './ProfileBadgeDetailDialog'
import { replaceableEventDedupeKey } from '@/lib/event'
import { formatAmount } from '@/lib/lightning'
import { cn } from '@/lib/utils'
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 type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks'
import { Flag, Zap, MessageCircle, ThumbsDown, ThumbsUp, Users } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { Event } from 'nostr-tools'
type Props = {
profilePubkey: string
badgeRelayUrls: string[]
zaps: TProfileZap[]
reactions: Event[]
comments: Event[]
badges: TProfileBadge[]
followPacks: TProfileFollowPack[]
reports: Event[]
loading: boolean
badgesLoading: boolean
followPacksLoading: boolean
reportsLoading: boolean
/** When false (logged out), the Reports section is omitted — reports use the viewer’s relays only. */
reportsEnabled: boolean
}
const ZAPS_PER_ROW = 4
const ZAP_ROWS = 3
const MAX_ZAPS = ZAPS_PER_ROW * ZAP_ROWS
const LIKES_GRID_COLS = 4
const LIKES_GRID_ROWS = 3
const MAX_LIKES = LIKES_GRID_COLS * LIKES_GRID_ROWS
const BADGES_PER_ROW = 6
const BADGE_ROWS = 2
const MAX_BADGES = BADGES_PER_ROW * BADGE_ROWS
const BADGE_TILE_PX = 96
const MAX_FOLLOW_PACKS = 8
const MAX_REPORTS = 12
function reportSummaryFromEvent(event: Event): string {
const reportTag = event.tags.find((t) => t[0] === 'report')
const reason = reportTag?.[1]?.trim()
if (reason) return reason
const text = event.content.trim().replace(/\s+/g, ' ')
if (text) return text.length > 48 ? `${text.slice(0, 45)}` : text
return '—'
}
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 === '+'
const isMinus = 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 />
) : isMinus ? (
<ThumbsDown className="size-3 shrink-0 text-muted-foreground" 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 ReportBadge({ event }: { event: Event }) {
const { push } = useSecondaryPage()
const summary = reportSummaryFromEvent(event)
return (
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border border-destructive/25 hover:bg-muted cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(event.id))}
title={summary}
>
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
<Flag className="size-3 shrink-0 text-destructive" strokeWidth={2} aria-hidden />
<span className="truncate text-xs text-muted-foreground min-w-0">{summary}</span>
</button>
)
}
function FollowPackBadge({ pack }: { pack: TProfileFollowPack }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const authorPk = pack.event.pubkey
return (
<button
type="button"
className="flex flex-col gap-1 px-2 py-1.5 rounded-md bg-muted/80 border hover:bg-muted cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(pack.event.id))}
title={pack.title}
>
<div className="flex min-w-0 items-center gap-1.5">
<Users className="size-3 shrink-0 text-primary" aria-hidden />
<span className="truncate text-xs font-medium text-foreground min-w-0">{pack.title}</span>
</div>
<div className="flex min-w-0 items-center gap-1.5 ps-4">
<span className="shrink-0 text-xs text-muted-foreground">{t('Follow pack by')}:</span>
<UserAvatar userId={authorPk} size="xSmall" className="shrink-0" />
<Username
userId={authorPk}
className="min-w-0 truncate text-xs font-medium text-foreground"
skeletonClassName="h-3.5"
/>
</div>
</button>
)
}
function BadgeItem({
badge,
onOpenDetail
}: {
badge: TProfileBadge
onOpenDetail: (b: TProfileBadge) => void
}) {
const { t } = useTranslation()
const imageUrl = badge.thumb ?? badge.image
const label = badge.name ?? badge.a.split(':').pop() ?? ''
return (
<button
type="button"
className="relative shrink-0 rounded-lg border bg-muted p-0 overflow-hidden cursor-pointer transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
style={{ width: BADGE_TILE_PX, height: BADGE_TILE_PX }}
title={label}
aria-label={label ? `${t('Badge details')}: ${label}` : t('Badge details')}
onClick={() => onOpenDetail(badge)}
>
{imageUrl ? (
<>
<img
src={imageUrl}
alt=""
className="size-full rounded-lg object-cover pointer-events-none"
loading="lazy"
onError={(e) => {
e.currentTarget.style.visibility = 'hidden'
const fallback = e.currentTarget.nextElementSibling as HTMLElement | null
fallback?.classList.remove('hidden')
}}
/>
<div className="hidden absolute inset-0 flex items-center justify-center rounded-lg bg-muted p-1 text-center text-xs text-muted-foreground pointer-events-none">
{label.slice(0, 3)}
</div>
</>
) : (
<div className="flex size-full items-center justify-center rounded-lg p-1 text-center text-xs text-muted-foreground">
{label.slice(0, 3)}
</div>
)}
</button>
)
}
export default function ProfileHeaderInteractions({
profilePubkey,
badgeRelayUrls,
zaps,
reactions,
comments,
badges,
followPacks,
reports,
loading,
badgesLoading,
followPacksLoading,
reportsLoading,
reportsEnabled
}: Props) {
const { t } = useTranslation()
const [badgeDialogOpen, setBadgeDialogOpen] = useState(false)
const [selectedBadge, setSelectedBadge] = useState<TProfileBadge | null>(null)
const displayZaps = zaps.slice(0, MAX_ZAPS)
const displayReactions = reactions.slice(0, MAX_LIKES)
const displayBadges = badges.slice(0, MAX_BADGES)
const displayFollowPacks = followPacks.slice(0, MAX_FOLLOW_PACKS)
const displayReports = reports.slice(0, MAX_REPORTS)
const Section = ({
title,
isEmpty,
isLoading,
children,
skeletonCount = 6,
skeletonItemClassName,
skeletonGridClassName
}: {
title: string
isEmpty: boolean
isLoading: boolean
children: React.ReactNode
skeletonCount?: number
skeletonItemClassName?: string
skeletonGridClassName?: string
}) => (
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-1.5">{title}</div>
{isLoading && isEmpty ? (
<div
className={cn(
'grid gap-1.5',
skeletonGridClassName ?? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4'
)}
>
{Array.from({ length: skeletonCount }).map((_, i) => (
<Skeleton key={i} className={cn('h-8 rounded-md min-w-0', skeletonItemClassName)} />
))}
</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 gap-1.5 auto-rows-min">
{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 auto-rows-min">
{displayReactions.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={12}
skeletonGridClassName="grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-7 gap-1"
skeletonItemClassName="aspect-square h-24 w-full rounded-lg"
>
<div className="flex flex-wrap gap-1">
{displayBadges.map((badge, index) => (
<BadgeItem
key={`${badge.a}-${badge.awardId}-${index}`}
badge={badge}
onOpenDetail={(b) => {
setSelectedBadge(b)
setBadgeDialogOpen(true)
}}
/>
))}
</div>
</Section>
<ProfileBadgeDetailDialog
open={badgeDialogOpen}
onOpenChange={(o) => {
setBadgeDialogOpen(o)
if (!o) setSelectedBadge(null)
}}
badge={selectedBadge}
profilePubkey={profilePubkey}
relayUrls={badgeRelayUrls}
/>
<Section
title={t('In Follow Packs')}
isEmpty={displayFollowPacks.length === 0}
isLoading={followPacksLoading}
skeletonCount={6}
skeletonItemClassName="h-14"
>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{displayFollowPacks.map((pack) => (
<FollowPackBadge key={replaceableEventDedupeKey(pack.event)} pack={pack} />
))}
</div>
</Section>
{reportsEnabled ? (
<Section title={t('Reports')} isEmpty={displayReports.length === 0} isLoading={reportsLoading}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{displayReports.map((item) => (
<ReportBadge key={`report-${item.id}`} event={item} />
))}
</div>
</Section>
) : null}
</div>
)
}