5 changed files with 601 additions and 34 deletions
@ -0,0 +1,283 @@ |
|||||||
|
import NoteCard from '@/components/NoteCard' |
||||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { getZapInfoFromEvent } from '@/lib/event-metadata' |
||||||
|
import { Event, kinds } from 'nostr-tools' |
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef, useCallback } from 'react' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { FAST_READ_RELAY_URLS } from '@/constants' |
||||||
|
import { normalizeUrl } from '@/lib/url' |
||||||
|
import { useZap } from '@/providers/ZapProvider' |
||||||
|
|
||||||
|
const INITIAL_SHOW_COUNT = 25 |
||||||
|
const LOAD_MORE_COUNT = 25 |
||||||
|
|
||||||
|
interface ProfileInteractionsProps { |
||||||
|
accountPubkey: string |
||||||
|
profilePubkey: string |
||||||
|
topSpace?: number |
||||||
|
searchQuery?: string |
||||||
|
onEventsChange?: (events: Event[]) => void |
||||||
|
} |
||||||
|
|
||||||
|
const ProfileInteractions = forwardRef< |
||||||
|
{ refresh: () => void; getEvents?: () => Event[] }, |
||||||
|
ProfileInteractionsProps |
||||||
|
>( |
||||||
|
( |
||||||
|
{ |
||||||
|
accountPubkey, |
||||||
|
profilePubkey, |
||||||
|
topSpace, |
||||||
|
searchQuery = '', |
||||||
|
onEventsChange |
||||||
|
}, |
||||||
|
ref |
||||||
|
) => { |
||||||
|
const { zapReplyThreshold } = useZap() |
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false) |
||||||
|
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) |
||||||
|
const [events, setEvents] = useState<Event[]>([]) |
||||||
|
const [isLoading, setIsLoading] = useState(true) |
||||||
|
const [refreshToken, setRefreshToken] = useState(0) |
||||||
|
const bottomRef = useRef<HTMLDivElement>(null) |
||||||
|
|
||||||
|
const fetchInteractions = useCallback(async () => { |
||||||
|
setIsLoading(true) |
||||||
|
try { |
||||||
|
const relayUrls = FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) |
||||||
|
|
||||||
|
// Fetch events where accountPubkey interacted with profilePubkey
|
||||||
|
// 1. Replies: accountPubkey replied to profilePubkey's notes
|
||||||
|
// 2. Zaps: accountPubkey zapped profilePubkey
|
||||||
|
// 3. Mentions: accountPubkey mentioned profilePubkey
|
||||||
|
// 4. Replies to accountPubkey: profilePubkey replied to accountPubkey's notes
|
||||||
|
|
||||||
|
const filters: any[] = [] |
||||||
|
|
||||||
|
// Get profilePubkey's notes to find replies to them
|
||||||
|
const profileNotes = await client.fetchEvents(relayUrls, [{ |
||||||
|
authors: [profilePubkey], |
||||||
|
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.DISCUSSION], |
||||||
|
limit: 100 |
||||||
|
}]) |
||||||
|
|
||||||
|
const profileNoteIds = profileNotes.map(e => e.id) |
||||||
|
|
||||||
|
// Replies from accountPubkey to profilePubkey's notes
|
||||||
|
if (profileNoteIds.length > 0) { |
||||||
|
filters.push({ |
||||||
|
authors: [accountPubkey], |
||||||
|
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], |
||||||
|
'#e': profileNoteIds, |
||||||
|
limit: 100 |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Zaps from accountPubkey to profilePubkey
|
||||||
|
filters.push({ |
||||||
|
authors: [accountPubkey], |
||||||
|
kinds: [kinds.Zap], |
||||||
|
'#p': [profilePubkey], |
||||||
|
limit: 100 |
||||||
|
}) |
||||||
|
|
||||||
|
// Mentions: accountPubkey mentioned profilePubkey
|
||||||
|
filters.push({ |
||||||
|
authors: [accountPubkey], |
||||||
|
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.PUBLIC_MESSAGE], |
||||||
|
'#p': [profilePubkey], |
||||||
|
limit: 100 |
||||||
|
}) |
||||||
|
|
||||||
|
// Get accountPubkey's notes to find replies from profilePubkey
|
||||||
|
const accountNotes = await client.fetchEvents(relayUrls, [{ |
||||||
|
authors: [accountPubkey], |
||||||
|
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.DISCUSSION], |
||||||
|
limit: 100 |
||||||
|
}]) |
||||||
|
|
||||||
|
const accountNoteIds = accountNotes.map(e => e.id) |
||||||
|
|
||||||
|
// Replies from profilePubkey to accountPubkey's notes
|
||||||
|
if (accountNoteIds.length > 0) { |
||||||
|
filters.push({ |
||||||
|
authors: [profilePubkey], |
||||||
|
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], |
||||||
|
'#e': accountNoteIds, |
||||||
|
limit: 100 |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Zaps from profilePubkey to accountPubkey
|
||||||
|
filters.push({ |
||||||
|
authors: [profilePubkey], |
||||||
|
kinds: [kinds.Zap], |
||||||
|
'#p': [accountPubkey], |
||||||
|
limit: 100 |
||||||
|
}) |
||||||
|
|
||||||
|
// Mentions: profilePubkey mentioned accountPubkey
|
||||||
|
filters.push({ |
||||||
|
authors: [profilePubkey], |
||||||
|
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.PUBLIC_MESSAGE], |
||||||
|
'#p': [accountPubkey], |
||||||
|
limit: 100 |
||||||
|
}) |
||||||
|
|
||||||
|
const allEvents = await client.fetchEvents(relayUrls, filters) |
||||||
|
|
||||||
|
// Deduplicate and filter
|
||||||
|
const seenIds = new Set<string>() |
||||||
|
const uniqueEvents = allEvents.filter(event => { |
||||||
|
if (seenIds.has(event.id)) return false |
||||||
|
seenIds.add(event.id) |
||||||
|
|
||||||
|
// Filter zap receipts below threshold
|
||||||
|
if (event.kind === ExtendedKind.ZAP_RECEIPT) { |
||||||
|
const zapInfo = getZapInfoFromEvent(event) |
||||||
|
if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) { |
||||||
|
return false |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return true |
||||||
|
}) |
||||||
|
|
||||||
|
// Sort by created_at descending
|
||||||
|
uniqueEvents.sort((a, b) => b.created_at - a.created_at) |
||||||
|
|
||||||
|
setEvents(uniqueEvents) |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to fetch interactions', error) |
||||||
|
setEvents([]) |
||||||
|
} finally { |
||||||
|
setIsLoading(false) |
||||||
|
setIsRefreshing(false) |
||||||
|
} |
||||||
|
}, [accountPubkey, profilePubkey, zapReplyThreshold]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!accountPubkey || !profilePubkey) return |
||||||
|
fetchInteractions() |
||||||
|
}, [accountPubkey, profilePubkey, refreshToken, fetchInteractions]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
onEventsChange?.(events) |
||||||
|
}, [events, onEventsChange]) |
||||||
|
|
||||||
|
useImperativeHandle( |
||||||
|
ref, |
||||||
|
() => ({ |
||||||
|
refresh: () => { |
||||||
|
setIsRefreshing(true) |
||||||
|
setRefreshToken((prev) => prev + 1) |
||||||
|
}, |
||||||
|
getEvents: () => events |
||||||
|
}), |
||||||
|
[events] |
||||||
|
) |
||||||
|
|
||||||
|
const filteredEvents = useMemo(() => { |
||||||
|
if (!searchQuery.trim()) { |
||||||
|
return events |
||||||
|
} |
||||||
|
const query = searchQuery.toLowerCase().trim() |
||||||
|
return events.filter((event) => { |
||||||
|
const contentLower = event.content.toLowerCase() |
||||||
|
if (contentLower.includes(query)) return true |
||||||
|
return event.tags.some((tag) => { |
||||||
|
if (tag.length <= 1) return false |
||||||
|
const tagValue = tag[1] |
||||||
|
return tagValue && tagValue.toLowerCase().includes(query) |
||||||
|
}) |
||||||
|
}) |
||||||
|
}, [events, searchQuery]) |
||||||
|
|
||||||
|
// Reset showCount when filters change
|
||||||
|
useEffect(() => { |
||||||
|
setShowCount(INITIAL_SHOW_COUNT) |
||||||
|
}, [searchQuery]) |
||||||
|
|
||||||
|
// Pagination: slice to showCount for display
|
||||||
|
const displayedEvents = useMemo(() => { |
||||||
|
return filteredEvents.slice(0, showCount) |
||||||
|
}, [filteredEvents, showCount]) |
||||||
|
|
||||||
|
// IntersectionObserver for infinite scroll
|
||||||
|
useEffect(() => { |
||||||
|
if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return |
||||||
|
|
||||||
|
const observer = new IntersectionObserver( |
||||||
|
(entries) => { |
||||||
|
if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) { |
||||||
|
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length)) |
||||||
|
} |
||||||
|
}, |
||||||
|
{ threshold: 0.1 } |
||||||
|
) |
||||||
|
|
||||||
|
observer.observe(bottomRef.current) |
||||||
|
|
||||||
|
return () => { |
||||||
|
observer.disconnect() |
||||||
|
} |
||||||
|
}, [displayedEvents.length, filteredEvents.length]) |
||||||
|
|
||||||
|
if (!accountPubkey || !profilePubkey) { |
||||||
|
return ( |
||||||
|
<div className="flex justify-center items-center py-8"> |
||||||
|
<div className="text-sm text-muted-foreground">No interactions to show</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (isLoading && events.length === 0) { |
||||||
|
return ( |
||||||
|
<div className="space-y-2"> |
||||||
|
{Array.from({ length: 3 }).map((_, i) => ( |
||||||
|
<Skeleton key={i} className="h-32 w-full" /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (!filteredEvents.length && !isLoading) { |
||||||
|
return ( |
||||||
|
<div className="flex justify-center items-center py-8"> |
||||||
|
<div className="text-sm text-muted-foreground"> |
||||||
|
{searchQuery.trim() ? 'No interactions match your search' : 'No interactions found'} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ marginTop: topSpace || 0 }}> |
||||||
|
{isRefreshing && ( |
||||||
|
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing interactions...</div> |
||||||
|
)} |
||||||
|
{searchQuery.trim() && ( |
||||||
|
<div className="px-4 py-2 text-sm text-muted-foreground"> |
||||||
|
Showing {displayedEvents.length} of {filteredEvents.length} interactions |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<div className="space-y-2"> |
||||||
|
{displayedEvents.map((event) => ( |
||||||
|
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
{displayedEvents.length < filteredEvents.length && ( |
||||||
|
<div ref={bottomRef} className="h-10 flex items-center justify-center"> |
||||||
|
<div className="text-sm text-muted-foreground">Loading more...</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
ProfileInteractions.displayName = 'ProfileInteractions' |
||||||
|
|
||||||
|
export default ProfileInteractions |
||||||
|
|
||||||
@ -0,0 +1,244 @@ |
|||||||
|
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 |
||||||
|
|
||||||
Loading…
Reference in new issue