Merge pull request #69 from AustinKelsay/bugfix-search-keyword-matching

Bugfix search keyword matching
This commit is contained in:
Austin Kelsay 2025-04-27 14:20:49 -07:00 committed by GitHub
commit e7d8677806
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 388 additions and 66 deletions

View File

@ -7,16 +7,49 @@ import { Message } from 'primereact/message';
import GenericButton from '@/components/buttons/GenericButton';
import useWindowWidth from '@/hooks/useWindowWidth';
import { BookOpen } from 'lucide-react';
import { highlightText, getTextWithMatchContext } from '@/utils/text';
import { generateNaddr } from '@/utils/nostr';
const ContentDropdownItem = ({ content, onSelect }) => {
const { returnImageProxy } = useImageProxy();
const windowWidth = useWindowWidth();
const isMobile = windowWidth <= 600;
// Get match information if available
const matches = content?._matches || {};
// Handle content selection with naddress
const handleSelect = () => {
// Create a copy of the content object with naddress information
const contentWithNaddr = { ...content };
// If content has pubkey, kind, and identifier (d tag), generate naddr
if (content.pubkey && content.kind && (content.d || content.identifier)) {
// Use the appropriate identifier (d tag value)
const identifier = content.d || content.identifier;
// Generate naddress
contentWithNaddr.naddress = generateNaddr(
content.pubkey,
content.kind,
identifier
);
// Log success or failure
if (contentWithNaddr.naddress) {
console.log(`Generated naddress for ${content.type || 'content'}: ${contentWithNaddr.naddress}`);
} else {
console.warn('Failed to generate naddress:', { pubkey: content.pubkey, kind: content.kind, identifier });
}
}
onSelect(contentWithNaddr);
};
return (
<div
className="group px-6 py-5 border-b border-gray-700/50 cursor-pointer hover:bg-gray-800/30 transition-colors duration-200"
onClick={() => onSelect(content)}
onClick={handleSelect}
>
<div className={`flex ${isMobile ? 'flex-col' : 'flex-row'} gap-5`}>
<div
@ -39,7 +72,13 @@ const ContentDropdownItem = ({ content, onSelect }) => {
<div>
<div className="flex justify-between items-start gap-4 mb-2">
<h3 className="text-xl font-bold text-[#f8f8ff] group-hover:text-white transition-colors duration-200">
{content?.title || content?.name}
{matches.title
? highlightText(
content?.title || content?.name,
matches.title.term,
'bg-yellow-500/30 text-white font-bold px-0.5 rounded'
)
: (content?.title || content?.name)}
</h3>
{content?.price > 0 ? (
@ -59,7 +98,13 @@ const ContentDropdownItem = ({ content, onSelect }) => {
{content?.summary && (
<p className="text-neutral-50/80 line-clamp-2 mb-3 text-sm leading-relaxed group-hover:text-neutral-50/90 transition-colors duration-200">
{content.summary}
{matches.description
? highlightText(
getTextWithMatchContext(content.summary, matches.description.term, 60),
matches.description.term,
'bg-yellow-500/30 text-white font-medium px-0.5 rounded'
)
: content.summary}
</p>
)}
</div>
@ -91,7 +136,7 @@ const ContentDropdownItem = ({ content, onSelect }) => {
iconPos="right"
onClick={e => {
e.stopPropagation();
onSelect(content);
handleSelect();
}}
className="items-center py-1 shadow-sm hover:shadow-md transition-shadow duration-200"
/>

View File

@ -8,6 +8,7 @@ import Image from 'next/image';
import { formatTimestampToHowLongAgo } from '@/utils/time';
import NostrIcon from '/public/images/nostr.png';
import { useImageProxy } from '@/hooks/useImageProxy';
import { highlightText, getTextWithMatchContext } from '@/utils/text';
const MessageDropdownItem = ({ message, onSelect }) => {
const { ndk } = useNDKContext();
@ -20,6 +21,9 @@ const MessageDropdownItem = ({ message, onSelect }) => {
// Stable reference to message to prevent infinite loops
const messageRef = useMemo(() => message, [message?.id]);
// Get match information
const matches = useMemo(() => message?._matches || {}, [message]);
// Determine the platform once when component mounts or message changes
const determinePlatform = useCallback(() => {
@ -85,6 +89,7 @@ const MessageDropdownItem = ({ message, onSelect }) => {
avatarProxy: authorPicture ? returnImageProxy(authorPicture) : null,
type: 'nostr',
id: messageRef.id,
_matches: messageRef._matches
});
}
} else if (currentPlatform === 'discord') {
@ -104,6 +109,7 @@ const MessageDropdownItem = ({ message, onSelect }) => {
channel: messageRef?.channel || 'discord',
type: 'discord',
id: messageRef.id,
_matches: messageRef._matches
});
}
} else if (currentPlatform === 'stackernews') {
@ -122,6 +128,7 @@ const MessageDropdownItem = ({ message, onSelect }) => {
channel: '~devs',
type: 'stackernews',
id: messageRef.id,
_matches: messageRef._matches
});
}
}
@ -164,6 +171,16 @@ const MessageDropdownItem = ({ message, onSelect }) => {
const messageDate = messageData.timestamp
? formatTimestampToHowLongAgo(messageData.timestamp)
: '';
// Get the content with highlighting if there's a match
const contentMatches = messageData._matches?.content || messageData._matches?.title;
const displayContent = contentMatches
? highlightText(
getTextWithMatchContext(messageData.content, contentMatches.term, 60),
contentMatches.term,
'bg-yellow-500/30 text-white font-medium px-0.5 rounded'
)
: messageData.content;
return (
<div className="flex flex-col">
@ -190,7 +207,7 @@ const MessageDropdownItem = ({ message, onSelect }) => {
<div className="text-xs text-gray-400">{messageDate}</div>
</div>
<p className="text-neutral-50/90 whitespace-pre-wrap mb-3 line-clamp-3">
{messageData.content}
{displayContent}
</p>
<div className="flex flex-wrap gap-2">
@ -254,8 +271,19 @@ const MessageDropdownItem = ({ message, onSelect }) => {
{messageData?.timestamp ? formatTimestampToHowLongAgo(messageData.timestamp) : ''}
</div>
</div>
<p className="text-neutral-50/80 whitespace-pre-wrap mb-3 text-sm leading-relaxed group-hover:text-neutral-50/90 transition-colors duration-200">
{messageData?.content}
{messageData?._matches?.content || messageData?._matches?.title
? highlightText(
getTextWithMatchContext(
messageData?.content,
messageData?._matches?.content?.term || messageData?._matches?.title?.term,
80
),
messageData?._matches?.content?.term || messageData?._matches?.title?.term,
'bg-yellow-500/30 text-white font-medium px-0.5 rounded'
)
: messageData?.content}
</p>
<div className="flex flex-wrap gap-2">

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
import { OverlayPanel } from 'primereact/overlaypanel';
@ -9,26 +9,30 @@ import { useCommunitySearch } from '@/hooks/useCommunitySearch';
import { useRouter } from 'next/router';
import useWindowWidth from '@/hooks/useWindowWidth';
import { useNDKContext } from '@/context/NDKContext';
import { ProgressSpinner } from 'primereact/progressspinner';
const SEARCH_OPTIONS = [
{ name: 'Content', code: 'content', icon: 'pi pi-video' },
{ name: 'Community', code: 'community', icon: 'pi pi-users' },
];
const SEARCH_DELAY = 300; // ms
const MIN_SEARCH_LENGTH = 3;
const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
const { searchContent, searchResults: contentResults } = useContentSearch();
const { searchCommunity, searchResults: communityResults } = useCommunitySearch();
const router = useRouter();
const windowWidth = useWindowWidth();
const [selectedSearchOption, setSelectedSearchOption] = useState({
name: 'Content',
code: 'content',
icon: 'pi pi-video',
});
const searchOptions = [
{ name: 'Content', code: 'content', icon: 'pi pi-video' },
{ name: 'Community', code: 'community', icon: 'pi pi-users' },
];
const [selectedSearchOption, setSelectedSearchOption] = useState(SEARCH_OPTIONS[0]);
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const op = useRef(null);
const { ndk, reInitializeNDK } = useNDKContext();
const searchTimeout = useRef(null);
// Handle search option template rendering
const selectedOptionTemplate = (option, props) => {
if (isDesktopNav) {
// For desktop nav bar, just show the icon
@ -50,33 +54,66 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
return <i className={option.icon + ' text-transparent text-xs'} />;
};
// Handle search input changes
const handleSearch = e => {
const term = e.target.value;
setSearchTerm(term);
if (selectedSearchOption.code === 'content') {
searchContent(term);
setSearchResults(contentResults);
} else if (selectedSearchOption.code === 'community' && ndk) {
searchCommunity(term);
setSearchResults(communityResults);
// Clear any existing timeout to avoid unnecessary API calls
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
if (!isMobileSearch && term.length > 2) {
// Set loading state if term length is sufficient
if (term.length >= MIN_SEARCH_LENGTH) {
setIsSearching(true);
}
// Set a timeout to avoid searching on each keystroke
searchTimeout.current = setTimeout(() => {
if (term.length >= MIN_SEARCH_LENGTH) {
if (selectedSearchOption.code === 'content') {
searchContent(term);
} else if (selectedSearchOption.code === 'community' && ndk) {
searchCommunity(term);
}
} else {
setIsSearching(false);
}
}, SEARCH_DELAY);
// Show/hide overlay panel based on search term length
if (!isMobileSearch && term.length >= MIN_SEARCH_LENGTH) {
op.current.show(e);
} else if (!isMobileSearch) {
op.current.hide();
}
};
// Update search results when option or results change
useEffect(() => {
if (selectedSearchOption.code === 'content') {
setSearchResults(contentResults);
} else if (selectedSearchOption.code === 'community') {
setSearchResults(communityResults);
}
}, [selectedSearchOption, contentResults, communityResults]);
// Once we have results, set isSearching to false
if (searchTerm.length >= MIN_SEARCH_LENGTH) {
setIsSearching(false);
}
}, [selectedSearchOption, contentResults, communityResults, searchTerm]);
// Cleanup the timeout on component unmount
useEffect(() => {
return () => {
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
};
}, []);
// Handle WebSocket errors and reinitialize NDK if needed
useEffect(() => {
const handleError = event => {
if (event.message && event.message.includes('wss://relay.devs.tools')) {
@ -92,11 +129,22 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
};
}, [reInitializeNDK]);
// Handle item selection from search results
const handleContentSelect = content => {
if (selectedSearchOption.code === 'content') {
if (content?.type === 'course') {
router.push(`/course/${content?.d || content?.id}`);
if (content?.naddress) {
// Use naddress for course if available
router.push(`/course/${content.naddress}`);
} else {
// Fallback to d or id
router.push(`/course/${content?.d || content?.id}`);
}
} else if (content?.naddress) {
// Use naddress for other content if available
router.push(`/details/${content.naddress}`);
} else {
// Fallback to ID if naddress is not available
router.push(`/details/${content.id}`);
}
} else if (selectedSearchOption.code === 'community') {
@ -111,10 +159,17 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
}
}
// Reset search state
resetSearch();
};
// Reset search state
const resetSearch = () => {
setSearchTerm('');
searchContent('');
searchCommunity('');
setSearchResults([]);
setIsSearching(false);
if (op.current) {
op.current.hide();
@ -133,11 +188,23 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
}
};
// Render search results
const renderSearchResults = () => {
if (searchResults.length === 0 && searchTerm.length > 2) {
// Show loading spinner while searching
if (isSearching) {
return (
<div className="flex items-center justify-center p-6">
<ProgressSpinner style={{ width: '50px', height: '50px' }} strokeWidth="4" />
</div>
);
}
// Show no results message
if (searchResults.length === 0 && searchTerm.length >= MIN_SEARCH_LENGTH) {
return <div className="p-4 text-center text-gray-400">No results found</div>;
}
// Render appropriate item component based on type
return searchResults.map((item, index) =>
item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? (
<MessageDropdownItem key={index} message={item} onSelect={handleContentSelect} />
@ -147,6 +214,28 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
);
};
// When search option changes, trigger search with current term
const handleSearchOptionChange = e => {
setSelectedSearchOption(e.value);
// If there's a search term, run the search again with the new option
if (searchTerm.length >= MIN_SEARCH_LENGTH) {
setIsSearching(true);
if (e.value.code === 'content') {
searchContent(searchTerm);
} else if (e.value.code === 'community' && ndk) {
searchCommunity(searchTerm);
}
}
};
// Derived styles based on screen size
const searchWidth = useMemo(() => {
if (windowWidth > 845) return 'w-[300px]';
if (isMobileSearch || windowWidth <= 600) return 'w-full';
return 'w-[160px]';
}, [windowWidth, isMobileSearch]);
return (
<>
<div className={`${isDesktopNav ? 'w-full max-w-md' : 'w-full'}`}>
@ -185,8 +274,8 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
},
}}
value={selectedSearchOption}
onChange={e => setSelectedSearchOption(e.value)}
options={searchOptions}
onChange={handleSearchOptionChange}
options={SEARCH_OPTIONS}
optionLabel="name"
dropdownIcon={<i className="pi pi-chevron-down text-gray-400 ml-1" />}
valueTemplate={selectedOptionTemplate}
@ -208,9 +297,9 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
<i className="pi pi-search text-gray-400 absolute left-4 z-10" />
<InputText
className={`
${windowWidth > 845 ? 'w-[300px]' : isMobileSearch || windowWidth <= 600 ? 'w-full' : 'w-[160px]'}
${isMobileSearch ? 'bg-transparent border-none pl-12 text-lg' : ''}
`}
${searchWidth}
${isMobileSearch ? 'bg-transparent border-none pl-12 text-lg' : ''}
`}
value={searchTerm}
onChange={handleSearch}
placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`}
@ -224,10 +313,10 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
{isMobileSearch && (
<div className="flex items-center gap-2 mb-3">
{searchOptions.map(option => (
{SEARCH_OPTIONS.map(option => (
<button
key={option.code}
onClick={() => setSelectedSearchOption(option)}
onClick={() => handleSearchOptionChange({ value: option })}
className={`flex items-center gap-2 px-4 py-2 rounded-full ${
selectedSearchOption.code === option.code
? 'bg-gray-700 text-white'
@ -253,8 +342,8 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
},
}}
value={selectedSearchOption}
onChange={e => setSelectedSearchOption(e.value)}
options={searchOptions}
onChange={handleSearchOptionChange}
options={SEARCH_OPTIONS}
optionLabel="name"
placeholder="Search"
dropdownIcon={
@ -280,7 +369,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
)}
{/* Mobile Search Results */}
{isMobileSearch && searchTerm.length > 2 && (
{isMobileSearch && searchTerm.length >= MIN_SEARCH_LENGTH && (
<div
className="fixed inset-x-0 bottom-0 top-[165px] bg-gray-900 overflow-y-auto"
style={{ touchAction: 'pan-y' }}

View File

@ -26,25 +26,73 @@ export const useCommunitySearch = () => {
const lowercaseTerm = term.toLowerCase();
// Discord search - match only on message content
const filteredDiscord = (discordData || [])
.filter(message => message.content.toLowerCase().includes(lowercaseTerm))
.map(message => ({ ...message, type: 'discord' }));
.filter(message => {
if (!message.content) return false;
return message.content.toLowerCase().includes(lowercaseTerm);
})
.map(message => ({
...message,
type: 'discord',
_matches: {
content: {
text: message.content,
term: lowercaseTerm
}
}
}));
// Nostr search - match only on message content
const filteredNostr = (nostrData || [])
.filter(message => message.content.toLowerCase().includes(lowercaseTerm))
.map(message => ({ ...message, type: 'nostr' }));
.filter(message => {
if (!message.content) return false;
return message.content.toLowerCase().includes(lowercaseTerm);
})
.map(message => ({
...message,
type: 'nostr',
_matches: {
content: {
text: message.content,
term: lowercaseTerm
}
}
}));
// StackerNews search - match only on title
const filteredStackerNews = (stackerNewsData || [])
.filter(item => item.title.toLowerCase().includes(lowercaseTerm))
.map(item => ({ ...item, type: 'stackernews' }));
.filter(item => {
if (!item.title) return false;
return item.title.toLowerCase().includes(lowercaseTerm);
})
.map(item => ({
...item,
type: 'stackernews',
_matches: {
title: {
text: item.title,
term: lowercaseTerm
}
}
}));
// Combine and sort the results
const combinedResults = [...filteredDiscord, ...filteredNostr, ...filteredStackerNews].sort(
(a, b) => {
const dateA =
a.type === 'nostr' ? a.created_at * 1000 : new Date(a.timestamp || a.createdAt);
const dateB =
b.type === 'nostr' ? b.created_at * 1000 : new Date(b.timestamp || b.createdAt);
return dateB - dateA;
// Get timestamps in a consistent format (milliseconds)
const getTimestamp = item => {
if (item.type === 'nostr') {
return item.created_at * 1000;
} else if (item.type === 'discord') {
return new Date(item.timestamp).getTime();
} else if (item.type === 'stackernews') {
return new Date(item.createdAt).getTime();
}
return 0;
};
return getTimestamp(b) - getTimestamp(a);
}
);

View File

@ -20,7 +20,7 @@ export const useContentSearch = () => {
};
const events = await ndk.fetchEvents(filter);
const parsedEvents = new Set();
const parsedEvents = [];
events.forEach(event => {
let parsed;
if (event.kind === 30004) {
@ -28,7 +28,7 @@ export const useContentSearch = () => {
} else {
parsed = parseEvent(event);
}
parsedEvents.add(parsed);
parsedEvents.push(parsed);
});
setAllContent(parsedEvents);
} catch (error) {
@ -44,17 +44,36 @@ export const useContentSearch = () => {
const searchContent = term => {
if (term.length > 2) {
const filtered = Array.from(allContent).filter(content => {
const searchTerm = term.toLowerCase();
const filtered = allContent.filter(content => {
// Prepare fields to search in
const searchableTitle = (content?.title || content?.name || '').toLowerCase();
const searchableDescription = (
content?.summary ||
content?.description ||
''
).toLowerCase();
const searchTerm = term.toLowerCase();
return searchableTitle.includes(searchTerm) || searchableDescription.includes(searchTerm);
const searchableDescription = (content?.summary || content?.description || '').toLowerCase();
// Find matches in title
const titleMatch = searchableTitle.includes(searchTerm);
// Find matches in description
const descriptionMatch = searchableDescription.includes(searchTerm);
// Store match information (only for title and description)
if (titleMatch || descriptionMatch) {
content._matches = {
title: titleMatch ? {
text: content?.title || content?.name || '',
term: searchTerm
} : null,
description: descriptionMatch ? {
text: content?.summary || content?.description || '',
term: searchTerm
} : null
};
return true;
}
return false;
});
setSearchResults(filtered);
} else {
setSearchResults([]);

View File

@ -221,6 +221,44 @@ export const hexToNpub = hex => {
return nip19.npubEncode(hex);
};
/**
* Generates a Nostr address (naddr) from event details
*
* @param {string} pubkey - The public key of the content creator
* @param {number} kind - The event kind
* @param {string} identifier - The 'd' tag value
* @param {Array} relays - Optional array of relay URLs
* @returns {string} - The naddr string
*/
export const generateNaddr = (pubkey, kind, identifier, relays = []) => {
try {
// Convert npub to hex if needed
let hexPubkey = pubkey;
if (pubkey.startsWith('npub')) {
try {
const { data } = nip19.decode(pubkey);
hexPubkey = data;
} catch (e) {
console.error('Error decoding npub:', e);
}
}
// Create the address data
const addressData = {
pubkey: hexPubkey,
kind: parseInt(kind),
identifier,
relays
};
// Generate and return the naddr
return nip19.naddrEncode(addressData);
} catch (error) {
console.error('Error generating naddr:', error);
return null;
}
};
export function validateEvent(event) {
if (typeof event.kind !== 'number') return 'Invalid kind';
if (typeof event.content !== 'string') return 'Invalid content';

View File

@ -1,13 +1,68 @@
export const highlightText = (text, query) => {
if (!query) return text;
const parts = text.split(new RegExp(`(${query})`, 'gi'));
return parts.map((part, index) =>
part.toLowerCase() === query.toLowerCase() ? (
<span key={index} className="text-yellow-300">
import React from 'react';
/**
* Highlights occurrences of a search term within a text string
*
* @param {string} text - The original text to process
* @param {string} term - The search term to highlight
* @param {string} className - CSS class name to apply to highlighted text
* @returns {JSX.Element[]} - Array of text and highlighted spans
*/
export const highlightText = (text, term, className = 'bg-yellow-300/30 text-white') => {
if (!text || !term || term.length < 2) {
return text;
}
const parts = String(text).split(new RegExp(`(${term})`, 'gi'));
return parts.map((part, index) => {
const isMatch = part.toLowerCase() === term.toLowerCase();
return isMatch ? (
<span key={index} className={className}>
{part}
</span>
) : (
part
)
);
);
});
};
/**
* Truncates text around the first match of a search term
*
* @param {string} text - The original text to process
* @param {string} term - The search term to find
* @param {number} contextLength - Number of characters to include before and after match
* @returns {string} - Truncated text with match in the middle
*/
export const getTextWithMatchContext = (text, term, contextLength = 40) => {
if (!text || !term || term.length < 2) {
return text;
}
const lowerText = text.toLowerCase();
const lowerTerm = term.toLowerCase();
const matchIndex = lowerText.indexOf(lowerTerm);
if (matchIndex === -1) {
return text.length > contextLength * 2
? text.substring(0, contextLength * 2) + '...'
: text;
}
const start = Math.max(0, matchIndex - contextLength);
const end = Math.min(text.length, matchIndex + term.length + contextLength);
let result = text.substring(start, end);
if (start > 0) {
result = '...' + result;
}
if (end < text.length) {
result = result + '...';
}
return result;
};