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.
 
 
 

244 lines
9.4 KiB

import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants'
import { useFollowList } from '@/providers/FollowListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { getPubkeysFromPTags } from '@/lib/tag'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState, forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import client from '@/services/client.service'
import { FAST_READ_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { Users, Loader2 } from 'lucide-react'
import logger from '@/lib/logger'
import ProfileSearchBar from '@/components/ui/ProfileSearchBar'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
const FollowPacksPage = forwardRef<HTMLDivElement, { index?: number; hideTitlebar?: boolean }>(
({ index, hideTitlebar = false }, ref) => {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { followings, follow } = useFollowList()
const [packs, setPacks] = useState<Event[]>([])
const [isLoading, setIsLoading] = useState(true)
const [followingPacks, setFollowingPacks] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
const fetchPacks = async () => {
if (!pubkey) return
setIsLoading(true)
try {
const relayUrls = FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url)
// Fetch kind 39089 events (starter packs)
const events = await client.fetchEvents(relayUrls, [{
kinds: [39089],
limit: 100
}])
// Sort by created_at descending
events.sort((a, b) => b.created_at - a.created_at)
setPacks(events)
// Check which packs the user is already following all members of
const followingSet = new Set(followings)
const packsFollowingAll = new Set<string>()
events.forEach(pack => {
const packPubkeys = getPubkeysFromPTags(pack.tags)
if (packPubkeys.length > 0 && packPubkeys.every(p => followingSet.has(p))) {
packsFollowingAll.add(pack.id)
}
})
setFollowingPacks(packsFollowingAll)
} catch (error) {
logger.error('Failed to fetch follow packs', { error })
toast.error(t('Failed to load follow packs'))
} finally {
setIsLoading(false)
}
}
fetchPacks()
}, [pubkey, followings])
const handleFollowPack = async (pack: Event) => {
if (!pubkey) {
toast.error(t('Please log in to follow'))
return
}
const packPubkeys = getPubkeysFromPTags(pack.tags)
const followingSet = new Set(followings)
const toFollow = packPubkeys.filter(p => !followingSet.has(p))
if (toFollow.length === 0) {
toast.info(t('You are already following all members of this pack'))
return
}
try {
// Follow all pubkeys in the pack
for (const pubkeyToFollow of toFollow) {
await follow(pubkeyToFollow)
}
toast.success(t('Followed {{count}} users', { count: toFollow.length }))
// Update followingPacks if all members are now followed
if (packPubkeys.every(p => followingSet.has(p) || toFollow.includes(p))) {
setFollowingPacks(prev => new Set([...prev, pack.id]))
}
} catch (error) {
logger.error('Failed to follow pack', { error })
toast.error(t('Failed to follow pack') + ': ' + (error as Error).message)
}
}
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 filteredPacks = useMemo(() => {
if (!searchQuery.trim()) {
return packs
}
const query = searchQuery.toLowerCase().trim()
return packs.filter(pack => {
const titleTag = pack.tags.find(tag => tag[0] === 'title' || tag[0] === 'name')
const title = (titleTag?.[1] || t('Follow Pack')).toLowerCase()
const descTag = pack.tags.find(tag => tag[0] === 'description' || tag[0] === 'd')
const description = (descTag?.[1] || '').toLowerCase()
return title.includes(query) || description.includes(query)
})
}, [packs, searchQuery, t])
if (!pubkey) {
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Browse Follow Packs')} hideBackButton={hideTitlebar}>
<div className="flex flex-col items-center justify-center py-16">
<div className="text-lg font-semibold mb-2">{t('Please log in')}</div>
<div className="text-sm text-muted-foreground">{t('You need to be logged in to browse follow packs')}</div>
</div>
</SecondaryPageLayout>
)
}
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Browse Follow Packs')} hideBackButton={hideTitlebar} displayScrollToTopButton>
<div className="space-y-4 p-4">
{!isLoading && packs.length > 0 && (
<div className="flex items-center gap-2">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search follow packs by name...')}
className="w-full max-w-md"
/>
</div>
)}
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-full mt-2" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))}
</div>
) : packs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16">
<div className="text-lg font-semibold mb-2">{t('No follow packs found')}</div>
<div className="text-sm text-muted-foreground">{t('There are no follow packs available at the moment')}</div>
</div>
) : filteredPacks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16">
<div className="text-lg font-semibold mb-2">{t('No packs match your search')}</div>
<div className="text-sm text-muted-foreground">{t('Try a different search term')}</div>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPacks.map((pack) => {
const packPubkeys = getPubkeysFromPTags(pack.tags)
const followingSet = new Set(followings)
const alreadyFollowingAll = packPubkeys.length > 0 && packPubkeys.every(p => followingSet.has(p))
const toFollowCount = packPubkeys.filter(p => !followingSet.has(p)).length
return (
<Card key={pack.id}>
<CardHeader>
<CardTitle className="text-lg">{getPackTitle(pack)}</CardTitle>
{getPackDescription(pack) && (
<CardDescription className="line-clamp-2">{getPackDescription(pack)}</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="size-4" />
<span>{t('{{count}} profiles', { count: packPubkeys.length })}</span>
</div>
{packPubkeys.length > 0 && (
<div className="flex -space-x-2">
{packPubkeys.slice(0, 5).map((pubkey) => (
<SimpleUserAvatar
key={pubkey}
userId={pubkey}
size="small"
className="border-2 border-background"
/>
))}
{packPubkeys.length > 5 && (
<div className="size-8 rounded-full border-2 border-background bg-muted flex items-center justify-center text-xs">
+{packPubkeys.length - 5}
</div>
)}
</div>
)}
<Button
className="w-full"
onClick={() => handleFollowPack(pack)}
disabled={alreadyFollowingAll}
variant={alreadyFollowingAll ? 'secondary' : 'default'}
>
{alreadyFollowingAll ? (
t('Following All')
) : (
<>
{t('Follow')} {toFollowCount > 0 && `(${toFollowCount})`}
</>
)}
</Button>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
</SecondaryPageLayout>
)
})
FollowPacksPage.displayName = 'FollowPacksPage'
export default FollowPacksPage