diff --git a/src/components/Profile/ProfileInteractions.tsx b/src/components/Profile/ProfileInteractions.tsx new file mode 100644 index 0000000..8a67b80 --- /dev/null +++ b/src/components/Profile/ProfileInteractions.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [refreshToken, setRefreshToken] = useState(0) + const bottomRef = useRef(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() + 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 ( +
+
No interactions to show
+
+ ) + } + + if (isLoading && events.length === 0) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) + } + + if (!filteredEvents.length && !isLoading) { + return ( +
+
+ {searchQuery.trim() ? 'No interactions match your search' : 'No interactions found'} +
+
+ ) + } + + return ( +
+ {isRefreshing && ( +
🔄 Refreshing interactions...
+ )} + {searchQuery.trim() && ( +
+ Showing {displayedEvents.length} of {filteredEvents.length} interactions +
+ )} +
+ {displayedEvents.map((event) => ( + + ))} +
+ {displayedEvents.length < filteredEvents.length && ( +
+
Loading more...
+
+ )} +
+ ) + } +) + +ProfileInteractions.displayName = 'ProfileInteractions' + +export default ProfileInteractions + diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index c39e8cb..63fcfa3 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -43,8 +43,10 @@ import SmartFollowings from './SmartFollowings' import SmartMuteLink from './SmartMuteLink' import SmartRelays from './SmartRelays' import ProfileMedia from './ProfileMedia' +import ProfileInteractions from './ProfileInteractions' +import { toFollowPacks } from '@/lib/link' -type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' +type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you' export default function Profile({ id }: { id?: string }) { const { t } = useTranslation() @@ -134,9 +136,11 @@ export default function Profile({ id }: { id?: string }) { const profileBookmarksRef = useRef<{ refresh: () => void }>(null) const profileArticlesRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null) const profileMediaRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null) + const profileInteractionsRef = useRef<{ refresh: () => void; getEvents?: () => Event[] }>(null) const [articleEvents, setArticleEvents] = useState([]) const [postEvents, setPostEvents] = useState([]) const [mediaEvents, setMediaEvents] = useState([]) + const [interactionEvents, setInteractionEvents] = useState([]) const isFollowingYou = useMemo(() => { // This will be handled by the FollowedBy component @@ -156,38 +160,52 @@ export default function Profile({ id }: { id?: string }) { profileArticlesRef.current?.refresh() } else if (activeTab === 'media') { profileMediaRef.current?.refresh() + } else if (activeTab === 'you') { + profileInteractionsRef.current?.refresh() } else { profileBookmarksRef.current?.refresh() } } // Define tabs with refresh buttons - const tabs = useMemo(() => [ - { - value: 'posts', - label: 'Posts' - }, - { - value: 'articles', - label: 'Articles' - }, - { - value: 'media', - label: 'Media' - }, - { - value: 'pins', - label: 'Pins' - }, - { - value: 'bookmarks', - label: 'Bookmarks' - }, - { - value: 'interests', - label: 'Interests' + const tabs = useMemo(() => { + const baseTabs = [ + { + value: 'posts', + label: 'Posts' + }, + { + value: 'articles', + label: 'Articles' + }, + { + value: 'media', + label: 'Media' + }, + { + value: 'pins', + label: 'Pins' + }, + { + value: 'bookmarks', + label: 'Bookmarks' + }, + { + value: 'interests', + label: 'Interests' + } + ] + + // Add "You" tab if viewing another user's profile and logged in + if (!isSelf && accountPubkey) { + baseTabs.push({ + value: 'you', + label: 'You' + }) } - ], []) + + return baseTabs + }, [isSelf, accountPubkey]) useEffect(() => { if (!profile?.pubkey) return @@ -245,13 +263,22 @@ export default function Profile({ id }: { id?: string }) {
{isSelf ? ( - +
+ + +
) : ( <> {!!lightningAddress && } @@ -442,6 +469,16 @@ export default function Profile({ id }: { id?: string }) { searchQuery={searchQuery} /> )} + {activeTab === 'you' && accountPubkey && ( + + )}
) diff --git a/src/lib/link.ts b/src/lib/link.ts index 8e0e4ba..23053be 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -75,6 +75,7 @@ export const toProfileEditor = () => '/profile-editor' export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}` export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews` export const toMuteList = () => '/mutes' +export const toFollowPacks = () => '/follow-packs' export const toChachiChat = (relay: string, d: string) => { return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}` diff --git a/src/pages/secondary/FollowPacksPage/index.tsx b/src/pages/secondary/FollowPacksPage/index.tsx new file mode 100644 index 0000000..31ad4c8 --- /dev/null +++ b/src/pages/secondary/FollowPacksPage/index.tsx @@ -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( + ({ index, hideTitlebar = false }, ref) => { + const { t } = useTranslation() + const { pubkey } = useNostr() + const { followings, follow } = useFollowList() + const [packs, setPacks] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [followingPacks, setFollowingPacks] = useState>(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() + + 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 ( + +
+
{t('Please log in')}
+
{t('You need to be logged in to browse follow packs')}
+
+
+ ) + } + + return ( + +
+ {!isLoading && packs.length > 0 && ( +
+ +
+ )} + + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + + ))} +
+ ) : packs.length === 0 ? ( +
+
{t('No follow packs found')}
+
{t('There are no follow packs available at the moment')}
+
+ ) : filteredPacks.length === 0 ? ( +
+
{t('No packs match your search')}
+
{t('Try a different search term')}
+
+ ) : ( +
+ {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 ( + + + {getPackTitle(pack)} + {getPackDescription(pack) && ( + {getPackDescription(pack)} + )} + + +
+ + {t('{{count}} profiles', { count: packPubkeys.length })} +
+ + {packPubkeys.length > 0 && ( +
+ {packPubkeys.slice(0, 5).map((pubkey) => ( + + ))} + {packPubkeys.length > 5 && ( +
+ +{packPubkeys.length - 5} +
+ )} +
+ )} + + +
+
+ ) + })} +
+ )} +
+
+ ) +}) + +FollowPacksPage.displayName = 'FollowPacksPage' +export default FollowPacksPage + diff --git a/src/routes.tsx b/src/routes.tsx index 2222d08..fa56a90 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -18,6 +18,7 @@ import SearchPage from './pages/secondary/SearchPage' import SettingsPage from './pages/secondary/SettingsPage' import TranslationPage from './pages/secondary/TranslationPage' import WalletPage from './pages/secondary/WalletPage' +import FollowPacksPage from './pages/secondary/FollowPacksPage' const ROUTES = [ { path: '/notes', element: }, @@ -37,7 +38,8 @@ const ROUTES = [ { path: '/settings/translation', element: }, { path: '/settings/rss-feeds', element: }, { path: '/profile-editor', element: }, - { path: '/mutes', element: } + { path: '/mutes', element: }, + { path: '/follow-packs', element: } ] export const routes = ROUTES.map(({ path, element }) => ({