5 changed files with 601 additions and 34 deletions
@ -0,0 +1,283 @@
@@ -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 @@
@@ -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