From 3fe40c2ca3b5de9f48716e5fda272f28ec330466 Mon Sep 17 00:00:00 2001 From: Kevin O'Connell Date: Wed, 4 Sep 2024 12:07:14 -0400 Subject: [PATCH] add channel search --- package-lock.json | 8 + package.json | 1 + src/app/[identifier]/page.tsx | 2 +- src/components/CastSearch.tsx | 363 +++++++++++++++++++++------------- 4 files changed, 239 insertions(+), 135 deletions(-) diff --git a/package-lock.json b/package-lock.json index d16a30a..37a9dd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "uuid": "^10.0.0" }, "devDependencies": { + "@types/lodash": "^4.17.7", "@types/node": "^20", "@types/nprogress": "^0.2.3", "@types/react": "^18", @@ -4117,6 +4118,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", diff --git a/package.json b/package.json index a0cb072..682a877 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "uuid": "^10.0.0" }, "devDependencies": { + "@types/lodash": "^4.17.7", "@types/node": "^20", "@types/nprogress": "^0.2.3", "@types/react": "^18", diff --git a/src/app/[identifier]/page.tsx b/src/app/[identifier]/page.tsx index 9c83a19..1773cee 100644 --- a/src/app/[identifier]/page.tsx +++ b/src/app/[identifier]/page.tsx @@ -40,7 +40,7 @@ export default function Page({ params }: ResponseProps) { {isSearch ? ( - + ) : ( )} diff --git a/src/components/CastSearch.tsx b/src/components/CastSearch.tsx index 60fd362..85ee9cd 100644 --- a/src/components/CastSearch.tsx +++ b/src/components/CastSearch.tsx @@ -2,10 +2,8 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Input } from '@/components/ui/input'; import { NeynarCastCard, NeynarProfileCard } from '@neynar/react'; -import { useRouter } from 'next/navigation'; -import useSearchParamsWithoutSuspense from '@/hooks/useSearchParamsWithoutSuspense'; import { Loader2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import debounce from 'lodash/debounce'; interface User { fid: number; @@ -24,22 +22,39 @@ interface Cast { }; } -const CastSearch = ({ query }: { query: string }) => { - const params = useSearchParamsWithoutSuspense(); - const router = useRouter(); +interface SearchParams { + query: string; + authorFid: string; + channelId: string; +} + +interface Channel { + id: string; + name: string; + description: string; + image_url: string; +} +const CastSearch = ({ initialQuery }: { initialQuery: string }) => { const [username, setUsername] = useState(''); - const [authorFid, setAuthorFid] = useState(''); const [channelId, setChannelId] = useState(''); + + const [searchParams, setSearchParams] = useState({ + query: initialQuery, + authorFid: '', + channelId: '', + }); const [casts, setCasts] = useState([]); const [searchUsers, setSearchUsers] = useState([]); const [inputUsers, setInputUsers] = useState([]); + const [inputChannels, setInputChannels] = useState([]); const [userCursor, setUserCursor] = useState(''); const [castCursor, setCastCursor] = useState(''); const [loading, setLoading] = useState({ users: false, casts: false, inputUsers: false, + inputChannels: false, }); const [showUserDropdown, setShowUserDropdown] = useState(false); @@ -49,109 +64,75 @@ const CastSearch = ({ query }: { query: string }) => { const castObserverRef = useRef(null); const lastUserRef = useRef(null); const lastCastRef = useRef(null); - - // Initialize filters from URL params - useEffect(() => { - if (params) { - setAuthorFid(params.get('authorFid') || ''); - setChannelId(params.get('channelId') || ''); - } - }, [params]); + const [showChannelDropdown, setShowChannelDropdown] = useState(false); + const channelDropdownRef = useRef(null); const handleShowMore = (identifier: string) => { window.open(`/${identifier}`, '_blank', 'noopener,noreferrer'); }; - const fetchInputUsers = useCallback( - async (inputUsername: string) => { - if (loading.inputUsers || inputUsername.length < 1) return; - - setLoading((prev) => ({ ...prev, inputUsers: true })); - try { - const userUrl = new URL( - 'https://api.neynar.com/v2/farcaster/user/search' - ); - userUrl.searchParams.append('q', inputUsername); - userUrl.searchParams.append('limit', '5'); - - const userResponse = await fetch(userUrl, { - headers: { - Accept: 'application/json', - api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', - }, - }); - - if (!userResponse.ok) - throw new Error('User search network response was not ok'); - const userData = await userResponse.json(); - setInputUsers(userData.result.users); - } catch (error) { - console.error('Error fetching input users:', error); - } finally { - setLoading((prev) => ({ ...prev, inputUsers: false })); + const fetchSearchUsers = useCallback(async (newSearch: boolean = false) => { + if (loading.users || (!newSearch && !userCursor)) return; + + setLoading((prev) => ({ ...prev, users: true })); + try { + const userUrl = new URL( + 'https://api.neynar.com/v2/farcaster/user/search' + ); + userUrl.searchParams.append('q', searchParams.query); + userUrl.searchParams.append('limit', '10'); + if (userCursor && !newSearch) + userUrl.searchParams.append('cursor', userCursor); + + const userResponse = await fetch(userUrl, { + headers: { + Accept: 'application/json', + api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', + }, + }); + + if (!userResponse.ok) + throw new Error('User search network response was not ok'); + const userData = await userResponse.json(); + setSearchUsers((prevUsers) => + newSearch + ? userData.result.users + : [...prevUsers, ...userData.result.users] + ); + if (userData.result.next) { + setUserCursor(userData.result.next.cursor); + } else { + setUserCursor(''); } - }, - [loading.inputUsers] - ); + } catch (error) { + console.error('Error fetching search users:', error); + } finally { + setLoading((prev) => ({ ...prev, users: false })); + } + }, []); - const fetchSearchUsers = useCallback( + const fetchCasts = useCallback( async (newSearch: boolean = false) => { - if (loading.users || (!newSearch && !userCursor)) return; + if (loading.casts || (!newSearch && !castCursor)) return; - setLoading((prev) => ({ ...prev, users: true })); + setLoading((prev) => ({ ...prev, casts: true })); try { - const userUrl = new URL( - 'https://api.neynar.com/v2/farcaster/user/search' - ); - userUrl.searchParams.append('q', query); - userUrl.searchParams.append('limit', '10'); - if (userCursor && !newSearch) - userUrl.searchParams.append('cursor', userCursor); + let castUrl = 'https://api.neynar.com/v2/farcaster/cast/search?'; - const userResponse = await fetch(userUrl, { - headers: { - Accept: 'application/json', - api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', - }, - }); + // Append query parameters + castUrl += `q=${encodeURIComponent(initialQuery)}&limit=20`; - if (!userResponse.ok) - throw new Error('User search network response was not ok'); - const userData = await userResponse.json(); - setSearchUsers((prevUsers) => - newSearch - ? userData.result.users - : [...prevUsers, ...userData.result.users] - ); - if (userData.result.next) { - setUserCursor(userData.result.next.cursor); - } else { - setUserCursor(''); + if (castCursor && !newSearch) { + castUrl += `&cursor=${encodeURIComponent(castCursor)}`; } - } catch (error) { - console.error('Error fetching search users:', error); - } finally { - setLoading((prev) => ({ ...prev, users: false })); - } - }, - [query, userCursor, loading.users, params] - ); - const fetchCasts = useCallback( - async (newSearch: boolean = false) => { - if (loading.casts || (!newSearch && !castCursor)) return; + if (searchParams.authorFid) { + castUrl += `&author_fid=${encodeURIComponent(searchParams.authorFid)}`; + } - setLoading((prev) => ({ ...prev, casts: true })); - try { - const castUrl = new URL( - 'https://api.neynar.com/v2/farcaster/cast/search' - ); - castUrl.searchParams.append('q', query); - castUrl.searchParams.append('limit', '20'); - if (castCursor && !newSearch) - castUrl.searchParams.append('cursor', castCursor); - if (authorFid) castUrl.searchParams.append('author_fid', authorFid); - if (channelId) castUrl.searchParams.append('channel_id', channelId); + if (searchParams.channelId) { + castUrl += `&channel_id=${encodeURIComponent(searchParams.channelId)}`; + } const castResponse = await fetch(castUrl, { headers: { @@ -179,23 +160,84 @@ const CastSearch = ({ query }: { query: string }) => { setLoading((prev) => ({ ...prev, casts: false })); } }, - [query, castCursor, authorFid, channelId, loading.casts, params] + [searchParams] ); - const performSearch = useCallback( - (newSearch: boolean = true) => { - if (newSearch) { - setSearchUsers([]); - setCasts([]); - setUserCursor(''); - setCastCursor(''); - } - fetchSearchUsers(newSearch); - fetchCasts(newSearch); - }, - [fetchSearchUsers, fetchCasts] + const performSearch = useCallback(() => { + setSearchUsers([]); + setCasts([]); + setUserCursor(''); + setCastCursor(''); + fetchSearchUsers(true); + fetchCasts(true); + }, [fetchSearchUsers, fetchCasts]); + + // Debounced search function + const debouncedSearch = useCallback( + debounce(() => { + performSearch(); + }, 300), + [performSearch] ); + const fetchInputUsers = useCallback(async (inputUsername: string) => { + if (loading.inputUsers || inputUsername.length < 1) return; + + setLoading((prev) => ({ ...prev, inputUsers: true })); + try { + const userUrl = new URL( + 'https://api.neynar.com/v2/farcaster/user/search' + ); + userUrl.searchParams.append('q', inputUsername); + userUrl.searchParams.append('limit', '5'); + + const userResponse = await fetch(userUrl, { + headers: { + Accept: 'application/json', + api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', + }, + }); + + if (!userResponse.ok) + throw new Error('User search network response was not ok'); + const userData = await userResponse.json(); + setInputUsers(userData.result.users); + } catch (error) { + console.error('Error fetching input users:', error); + } finally { + setLoading((prev) => ({ ...prev, inputUsers: false })); + } + }, []); + + const fetchInputChannels = useCallback(async (inputChannelId: string) => { + if (loading.inputChannels || inputChannelId.length < 1) return; + + setLoading((prev) => ({ ...prev, inputChannels: true })); + try { + const channelUrl = new URL( + 'https://api.neynar.com/v2/farcaster/channel/search' + ); + channelUrl.searchParams.append('q', inputChannelId); + channelUrl.searchParams.append('limit', '5'); + + const channelResponse = await fetch(channelUrl, { + headers: { + Accept: 'application/json', + api_key: process.env.NEXT_PUBLIC_NEYNAR_API_KEY || '', + }, + }); + + if (!channelResponse.ok) + throw new Error('Channel search network response was not ok'); + const channelData = await channelResponse.json(); + setInputChannels(channelData.channels); + } catch (error) { + console.error('Error fetching input channels:', error); + } finally { + setLoading((prev) => ({ ...prev, inputChannels: false })); + } + }, []); + const handleUsernameChange = useCallback( (e: React.ChangeEvent) => { const newUsername = e.target.value; @@ -204,35 +246,25 @@ const CastSearch = ({ query }: { query: string }) => { setShowUserDropdown(true); } else { setShowUserDropdown(false); + setSearchParams((prev) => ({ ...prev, authorFid: '' })); } fetchInputUsers(newUsername); }, [fetchInputUsers] ); - const handleUserSelect = useCallback( - (user: User) => { - setAuthorFid(user.fid.toString()); - setUsername(user.username); - setShowUserDropdown(false); - const newParams = new URLSearchParams(params as URLSearchParams); - newParams.set('authorFid', user.fid.toString()); - router.push(`/${query}?${newParams.toString()}`); - performSearch(true); - }, - [params, query, router, performSearch] - ); + const handleUserSelect = useCallback((user: User) => { + setUsername(user.username); + setSearchParams((prev) => ({ ...prev, authorFid: user.fid.toString() })); + setShowUserDropdown(false); + }, []); const handleSearch = useCallback( (e: React.FormEvent) => { e.preventDefault(); - const newParams = new URLSearchParams(); - if (authorFid) newParams.append('authorFid', authorFid); - if (channelId) newParams.append('channelId', channelId); - router.push(`/${query}?${newParams.toString()}`); - performSearch(true); + debouncedSearch(); }, - [authorFid, channelId, query, router, performSearch] + [debouncedSearch] ); // Set up Intersection Observers @@ -277,12 +309,10 @@ const CastSearch = ({ query }: { query: string }) => { } }, [searchUsers, casts]); - // Initial fetch when component mounts + // Trigger search when searchParams change useEffect(() => { - if (query || authorFid || channelId) { - performSearch(true); - } - }, []); // Empty dependency array ensures this only runs once on mount + debouncedSearch(); + }, [searchParams, debouncedSearch]); // Handle click outside of dropdown useEffect(() => { @@ -325,8 +355,50 @@ const CastSearch = ({ query }: { query: string }) => { [handleSearch] ); + // ... (rest of the existing functions) + + const handleChannelIdChange = useCallback( + (e: React.ChangeEvent) => { + const newChannelId = e.target.value; + setChannelId(newChannelId); + if (newChannelId.length > 0) { + setShowChannelDropdown(true); + } else { + setShowChannelDropdown(false); + setSearchParams((prev) => ({ ...prev, channelId: '' })); + } + fetchInputChannels(newChannelId); + }, + [fetchInputChannels] + ); + + const handleChannelSelect = useCallback((channel: Channel) => { + setChannelId(channel.name); + setSearchParams((prev) => ({ ...prev, channelId: channel.id })); + setShowChannelDropdown(false); + }, []); + + // ... (rest of the existing useEffects) + + // Add a new useEffect for handling clicks outside the channel dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + channelDropdownRef.current && + !channelDropdownRef.current.contains(event.target as Node) + ) { + setShowChannelDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + return ( -
+
@@ -374,11 +446,34 @@ const CastSearch = ({ query }: { query: string }) => { setChannelId(e.target.value)} + onChange={handleChannelIdChange} onKeyDown={handleChannelIdKeyDown} placeholder="Channel ID (optional)" className="w-full sm:w-auto rounded-none sm:min-w-96" /> + {showChannelDropdown && ( +
+
    + {inputChannels.map((channel) => ( +
  • handleChannelSelect(channel)} + > + {channel.name} + {channel.name} +
  • + ))} +
+
+ )}
@@ -428,7 +523,7 @@ const CastSearch = ({ query }: { query: string }) => {
- Loading more users... + Loading more users
)}