diff --git a/src/components/social/FollowingSystem.tsx b/src/components/social/FollowingSystem.tsx index 37ca0b29..7bb4a746 100644 --- a/src/components/social/FollowingSystem.tsx +++ b/src/components/social/FollowingSystem.tsx @@ -1,6 +1,6 @@ 'use client'; import Image from 'next/image'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Search, UserCircle } from 'lucide-react'; import { useFollowUser } from '@/hooks/useSocialFeatures'; import { apiClient } from '@/lib/api'; @@ -40,11 +40,10 @@ function UserRow({ user }: { user: SocialUser }) { @@ -56,8 +55,18 @@ export default function FollowingSystem({ userId }: FollowingSystemProps) { const [tab, setTab] = useState('followers'); const [users, setUsers] = useState([]); const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); const [loading, setLoading] = useState(false); + // Debounce the search input query by 300ms + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(query); + }, 300); + + return () => clearTimeout(timer); + }, [query]); + useEffect(() => { setLoading(true); apiClient @@ -67,7 +76,11 @@ export default function FollowingSystem({ userId }: FollowingSystemProps) { .finally(() => setLoading(false)); }, [tab, userId]); - const filtered = users.filter((u) => u.name.toLowerCase().includes(query.toLowerCase())); + // Memoize the filtered user results based on the debounced query + const filtered = useMemo(() => { + const lowercaseQuery = debouncedQuery.toLowerCase(); + return users.filter((u) => u.name.toLowerCase().includes(lowercaseQuery)); + }, [users, debouncedQuery]); return (
@@ -77,11 +90,10 @@ export default function FollowingSystem({ userId }: FollowingSystemProps) { @@ -118,4 +130,4 @@ export default function FollowingSystem({ userId }: FollowingSystemProps) {
); -} +} \ No newline at end of file diff --git a/src/components/social/TopicFeed.tsx b/src/components/social/TopicFeed.tsx index cfeb9bd4..13eda963 100644 --- a/src/components/social/TopicFeed.tsx +++ b/src/components/social/TopicFeed.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState, useMemo } from 'react'; import Link from 'next/link'; import Image from 'next/image'; import { @@ -62,11 +62,10 @@ function SortBar({ current, onChange, postCount }: SortBarProps) { key={value} onClick={() => onChange(value)} aria-pressed={current === value} - className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${ - current === value + className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${current === value ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200' - }`} + }`} > {icon} {label} @@ -82,6 +81,8 @@ interface TopicFeedProps { slug: string; } +const ESTIMATED_ROW_HEIGHT = 140; // Approximate height per post item in pixels + export default function TopicFeed({ slug }: TopicFeedProps) { const { topic, @@ -97,7 +98,28 @@ export default function TopicFeed({ slug }: TopicFeedProps) { followLoading, error, } = useTopicFeed(slug); + const sentinelRef = useRef(null); + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [containerHeight, setContainerHeight] = useState(600); + + // Track container scroll and viewport height for virtualization + useEffect(() => { + const handleScroll = () => { + if (containerRef.current) { + setScrollTop(containerRef.current.scrollTop); + } + }; + const el = containerRef.current; + if (el) { + setContainerHeight(el.clientHeight || 600); + el.addEventListener('scroll', handleScroll, { passive: true }); + } + return () => { + if (el) el.removeEventListener('scroll', handleScroll); + }; + }, []); // Infinite scroll via IntersectionObserver useEffect(() => { @@ -113,6 +135,22 @@ export default function TopicFeed({ slug }: TopicFeedProps) { return () => observer.disconnect(); }, [loadMore]); + // Virtualization window calculations + const totalPosts = posts.length; + const buffer = 5; + const startIndex = Math.max(0, Math.floor(scrollTop / ESTIMATED_ROW_HEIGHT) - buffer); + const endIndex = Math.min( + totalPosts, + Math.ceil((scrollTop + containerHeight) / ESTIMATED_ROW_HEIGHT) + buffer + ); + + const virtualPosts = useMemo(() => { + return posts.slice(startIndex, endIndex); + }, [posts, startIndex, endIndex]); + + const topSpacerHeight = startIndex * ESTIMATED_ROW_HEIGHT; + const bottomSpacerHeight = Math.max(0, (totalPosts - endIndex) * ESTIMATED_ROW_HEIGHT); + return (
{/* Topic header */} @@ -150,11 +188,10 @@ export default function TopicFeed({ slug }: TopicFeedProps) { onClick={toggleFollow} disabled={followLoading} aria-label={topic.isFollowing ? `Unfollow #${topic.name}` : `Follow #${topic.name}`} - className={`shrink-0 px-4 py-1.5 rounded-full text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-60 ${ - topic.isFollowing + className={`shrink-0 px-4 py-1.5 rounded-full text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-60 ${topic.isFollowing ? 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400' : 'bg-blue-600 text-white hover:bg-blue-700' - }`} + }`} > {followLoading ? '…' : topic.isFollowing ? 'Following' : 'Follow'} @@ -181,8 +218,11 @@ export default function TopicFeed({ slug }: TopicFeedProps) { {/* Sort bar */} - {/* Posts list */} -
+ {/* Posts list container with virtualization */} +
{/* Initial loading skeletons */} {loading && posts.length === 0 && @@ -226,8 +266,11 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
)} - {/* Post items */} - {posts.map((post) => ( + {/* Virtualized Top Spacer */} + {topSpacerHeight > 0 &&