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.
 
 
 
 

195 lines
6.8 KiB

import { resolveHttpMediaUrl } from '@/lib/badge-definition-media'
import { getImetaInfosFromEvent } from '@/lib/event'
import { getPubkeysFromPTags } from '@/lib/tag'
import logger from '@/lib/logger'
import { cn } from '@/lib/utils'
import { useFollowListOptional } from '@/providers/follow-list-context'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools'
import { Users } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import UserAvatar, { SimpleUserAvatar } from '@/components/UserAvatar'
import Username from '@/components/Username'
import { Button } from '@/components/ui/button'
/** NIP-style `image` tags on kind 39089; falls back to first NIP-94 `imeta` URL. */
function followPackBannerUrlFromEvent(event: Event): string | undefined {
for (const t of event.tags) {
if (t[0] === 'image' && t[1]) {
const u = resolveHttpMediaUrl(t[1])
if (u) return u
}
}
for (const im of getImetaInfosFromEvent(event)) {
const u = resolveHttpMediaUrl(im.url)
if (u) return u
}
return undefined
}
export default function FollowPackPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const { pubkey, canManageIdentity } = useNostr()
const followList = useFollowListOptional()
const followings = followList?.followings ?? []
const { mutePubkeySet } = useMuteList()
const [busy, setBusy] = useState(false)
const [bannerFailed, setBannerFailed] = useState(false)
const packPubkeys = useMemo(() => getPubkeysFromPTags(event.tags), [event.tags])
const bannerUrl = useMemo(() => followPackBannerUrlFromEvent(event), [event])
useEffect(() => {
setBannerFailed(false)
}, [event.id])
const getPackTitle = (pack: Event): string => {
const titleTag = pack.tags.find((tag) => tag[0] === 'title' || tag[0] === 'name')
return titleTag?.[1] || t('Follow Pack')
}
const getPackDescription = (pack: Event): string => {
const descTag = pack.tags.find((tag) => tag[0] === 'description' || tag[0] === 'd')
return descTag?.[1] || ''
}
const title = getPackTitle(event)
const description = getPackDescription(event)
const followingSet = useMemo(() => new Set(followings), [followings])
const availablePubkeys = useMemo(
() => packPubkeys.filter((p) => !muteSetHas(mutePubkeySet, p)),
[packPubkeys, mutePubkeySet]
)
const alreadyFollowingAll =
availablePubkeys.length > 0 && availablePubkeys.every((p) => followingSet.has(p))
const toFollowCount = availablePubkeys.filter((p) => !followingSet.has(p)).length
const handleFollowPack = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation()
if (!pubkey) {
toast.error(t('Please log in to follow'))
return
}
if (!followList) return
const { followMany } = followList
const toFollow = packPubkeys.filter((p) => !followingSet.has(p) && !muteSetHas(mutePubkeySet, p))
if (toFollow.length === 0) {
const mutedCount = packPubkeys.filter((p) => muteSetHas(mutePubkeySet, p) && !followingSet.has(p)).length
if (mutedCount > 0) {
toast.info(t('All available members are already followed or muted'))
} else {
toast.info(t('You are already following all members of this pack'))
}
return
}
setBusy(true)
try {
await followMany(toFollow)
toast.success(t('Followed {{count}} users', { count: toFollow.length }))
} catch (error) {
logger.error('Failed to follow pack', { error })
toast.error(t('Failed to follow pack') + ': ' + (error as Error).message)
} finally {
setBusy(false)
}
},
[pubkey, followList, packPubkeys, followingSet, mutePubkeySet, t]
)
return (
<div className={cn('overflow-hidden rounded-lg border bg-muted/30', className)}>
{bannerUrl && !bannerFailed ? (
<div className="relative w-full max-h-52 overflow-hidden bg-muted">
<img
src={bannerUrl}
alt={title}
className="h-auto w-full max-h-52 object-cover object-center"
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onError={() => setBannerFailed(true)}
/>
</div>
) : null}
<div className="p-3">
<div className="mb-2 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm text-muted-foreground">[{t('Follow Pack')}]</span>
<span className="text-sm font-semibold">{title}</span>
</div>
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<span className="shrink-0">{t('Follow pack by')}:</span>
<UserAvatar userId={event.pubkey} size="xSmall" className="shrink-0" />
<Username
userId={event.pubkey}
className="min-w-0 truncate font-medium text-foreground"
skeletonClassName="h-3"
/>
</div>
</div>
{description ? (
<div className="mb-3 line-clamp-2 text-sm text-muted-foreground">{description}</div>
) : null}
<div className="mb-3 flex items-center gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="size-4" />
<span>{t('{{count}} profiles', { count: availablePubkeys.length })}</span>
</div>
{availablePubkeys.length > 0 ? (
<div className="flex -space-x-2">
{availablePubkeys.slice(0, 5).map((pk) => (
<SimpleUserAvatar
key={pk}
userId={pk}
size="small"
className="border-2 border-background"
/>
))}
{availablePubkeys.length > 5 ? (
<div className="flex size-7 items-center justify-center rounded-full border-2 border-background bg-muted text-xs text-muted-foreground">
+{availablePubkeys.length - 5}
</div>
) : null}
</div>
) : null}
</div>
{!canManageIdentity ? (
<p className="text-sm text-muted-foreground">{t('Please log in to follow')}</p>
) : !followList ? null : (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={alreadyFollowingAll || busy}
onClick={handleFollowPack}
>
{alreadyFollowingAll ? (
t('Following All')
) : (
<>
{t('Follow')}
{toFollowCount > 0 ? ` (${toFollowCount})` : ''}
</>
)}
</Button>
)}
</div>
</div>
)
}