This commit is contained in:
parent
45f66fd139
commit
eb9cf7f361
@ -1,8 +1,6 @@
|
|||||||
import "./SearchBox.css";
|
import "./SearchBox.css";
|
||||||
|
|
||||||
import { unixNow } from "@snort/shared";
|
|
||||||
import { NostrLink, tryParseNostrLink } from "@snort/system";
|
import { NostrLink, tryParseNostrLink } from "@snort/system";
|
||||||
import { socialGraphInstance } from "@snort/system";
|
|
||||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
@ -10,11 +8,9 @@ import { useLocation, useNavigate } from "react-router-dom";
|
|||||||
import Icon from "@/Components/Icons/Icon";
|
import Icon from "@/Components/Icons/Icon";
|
||||||
import Spinner from "@/Components/Icons/Spinner";
|
import Spinner from "@/Components/Icons/Spinner";
|
||||||
import ProfileImage from "@/Components/User/ProfileImage";
|
import ProfileImage from "@/Components/User/ProfileImage";
|
||||||
import fuzzySearch, { FuzzySearchResult } from "@/Db/FuzzySearch";
|
import useProfileSearch from "@/Hooks/useProfileSearch";
|
||||||
import { fetchNip05Pubkey } from "@/Utils/Nip05/Verifier";
|
import { fetchNip05Pubkey } from "@/Utils/Nip05/Verifier";
|
||||||
|
|
||||||
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "../../Feed/TimelineFeed";
|
|
||||||
|
|
||||||
const MAX_RESULTS = 3;
|
const MAX_RESULTS = 3;
|
||||||
|
|
||||||
export default function SearchBox() {
|
export default function SearchBox() {
|
||||||
@ -29,53 +25,7 @@ export default function SearchBox() {
|
|||||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||||
const resultListRef = useRef<HTMLDivElement | null>(null);
|
const resultListRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const options: TimelineFeedOptions = {
|
const results = useProfileSearch(search);
|
||||||
method: "LIMIT_UNTIL",
|
|
||||||
window: undefined,
|
|
||||||
now: unixNow(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const subject: TimelineSubject = {
|
|
||||||
type: "profile_keyword",
|
|
||||||
discriminator: search,
|
|
||||||
items: search ? [search] : [],
|
|
||||||
relay: undefined,
|
|
||||||
streams: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { main } = useTimelineFeed(subject, options);
|
|
||||||
|
|
||||||
const [results, setResults] = useState<FuzzySearchResult[]>([]);
|
|
||||||
useEffect(() => {
|
|
||||||
const searchString = search.trim();
|
|
||||||
const fuseResults = fuzzySearch.search(searchString);
|
|
||||||
|
|
||||||
const followDistanceNormalizationFactor = 3;
|
|
||||||
|
|
||||||
const combinedResults = fuseResults.map(result => {
|
|
||||||
const fuseScore = result.score === undefined ? 1 : result.score;
|
|
||||||
const followDistance =
|
|
||||||
socialGraphInstance.getFollowDistance(result.item.pubkey) / followDistanceNormalizationFactor;
|
|
||||||
|
|
||||||
const startsWithSearchString = [result.item.name, result.item.display_name, result.item.nip05].some(
|
|
||||||
field => field && field.toLowerCase?.().startsWith(searchString.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
const boostFactor = startsWithSearchString ? 0.25 : 1;
|
|
||||||
|
|
||||||
const weightForFuseScore = 0.8;
|
|
||||||
const weightForFollowDistance = 0.2;
|
|
||||||
|
|
||||||
const combinedScore = (fuseScore * weightForFuseScore + followDistance * weightForFollowDistance) * boostFactor;
|
|
||||||
|
|
||||||
return { ...result, combinedScore };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by combined score, lower is better
|
|
||||||
combinedResults.sort((a, b) => a.combinedScore - b.combinedScore);
|
|
||||||
|
|
||||||
setResults(combinedResults.map(r => r.item));
|
|
||||||
}, [search, main]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
|
@ -17,7 +17,7 @@ import useLogin from "@/Hooks/useLogin";
|
|||||||
import useModeration from "@/Hooks/useModeration";
|
import useModeration from "@/Hooks/useModeration";
|
||||||
import { System } from "@/system";
|
import { System } from "@/system";
|
||||||
|
|
||||||
export default function TrendingNotes({ count = Infinity, small = false }: { count: number; small: boolean }) {
|
export default function TrendingNotes({ count = Infinity, small = false }: { count?: number; small?: boolean }) {
|
||||||
const api = new NostrBandApi();
|
const api = new NostrBandApi();
|
||||||
const { lang } = useLocale();
|
const { lang } = useLocale();
|
||||||
const trendingNotesUrl = api.trendingNotesUrl(lang);
|
const trendingNotesUrl = api.trendingNotesUrl(lang);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { dedupe } from "@snort/shared";
|
import { dedupe } from "@snort/shared";
|
||||||
import { HexKey } from "@snort/system";
|
import { HexKey } from "@snort/system";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode, useMemo } from "react";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
import { FollowsFeed } from "@/Cache";
|
import { FollowsFeed } from "@/Cache";
|
||||||
@ -35,6 +35,8 @@ export default function FollowListBase({
|
|||||||
const { id, follows } = useLogin(s => ({ id: s.id, follows: s.follows }));
|
const { id, follows } = useLogin(s => ({ id: s.id, follows: s.follows }));
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
|
|
||||||
|
const profilePreviewOptions = useMemo(() => ({ about: showAbout, profileCards: true }), [showAbout]);
|
||||||
|
|
||||||
async function followAll() {
|
async function followAll() {
|
||||||
if (publisher) {
|
if (publisher) {
|
||||||
const newFollows = dedupe([...pubkeys, ...follows.item]);
|
const newFollows = dedupe([...pubkeys, ...follows.item]);
|
||||||
@ -57,12 +59,13 @@ export default function FollowListBase({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{pubkeys?.map(a => (
|
{pubkeys?.map((a, index) => (
|
||||||
<ProfilePreview
|
<ProfilePreview
|
||||||
pubkey={a}
|
pubkey={a}
|
||||||
key={a}
|
key={a}
|
||||||
options={{ about: showAbout, profileCards: true }}
|
options={profilePreviewOptions}
|
||||||
actions={profileActions?.(a)}
|
actions={profileActions?.(a)}
|
||||||
|
waitUntilInView={index > 10}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,7 +2,7 @@ import "./ProfilePreview.css";
|
|||||||
|
|
||||||
import { HexKey, UserMetadata } from "@snort/system";
|
import { HexKey, UserMetadata } from "@snort/system";
|
||||||
import { useUserProfile } from "@snort/system-react";
|
import { useUserProfile } from "@snort/system-react";
|
||||||
import { ReactNode } from "react";
|
import { memo, ReactNode } from "react";
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
import FollowButton from "@/Components/User/FollowButton";
|
import FollowButton from "@/Components/User/FollowButton";
|
||||||
@ -19,10 +19,11 @@ export interface ProfilePreviewProps {
|
|||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
waitUntilInView?: boolean;
|
||||||
}
|
}
|
||||||
export default function ProfilePreview(props: ProfilePreviewProps) {
|
export default memo(function ProfilePreview(props: ProfilePreviewProps) {
|
||||||
const pubkey = props.pubkey;
|
const pubkey = props.pubkey;
|
||||||
const { ref, inView } = useInView({ triggerOnce: true });
|
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "500px" });
|
||||||
const user = useUserProfile(inView ? pubkey : undefined);
|
const user = useUserProfile(inView ? pubkey : undefined);
|
||||||
const options = {
|
const options = {
|
||||||
about: true,
|
about: true,
|
||||||
@ -43,7 +44,7 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
|
|||||||
className={`justify-between profile-preview${props.className ? ` ${props.className}` : ""}`}
|
className={`justify-between profile-preview${props.className ? ` ${props.className}` : ""}`}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={handleClick}>
|
onClick={handleClick}>
|
||||||
{inView && (
|
{(!props.waitUntilInView || inView) && (
|
||||||
<>
|
<>
|
||||||
<ProfileImage
|
<ProfileImage
|
||||||
pubkey={pubkey}
|
pubkey={pubkey}
|
||||||
@ -62,4 +63,4 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { CachedMetadata } from "@snort/system";
|
import { CachedMetadata, NostrEvent } from "@snort/system";
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
|
|
||||||
export type FuzzySearchResult = {
|
export type FuzzySearchResult = {
|
||||||
@ -16,7 +16,7 @@ const fuzzySearch = new Fuse<FuzzySearchResult>([], {
|
|||||||
|
|
||||||
const profileTimestamps = new Map<string, number>(); // is this somewhere in cache?
|
const profileTimestamps = new Map<string, number>(); // is this somewhere in cache?
|
||||||
|
|
||||||
export const addEventToFuzzySearch = ev => {
|
export const addEventToFuzzySearch = (ev: NostrEvent) => {
|
||||||
if (ev.kind !== 0) {
|
if (ev.kind !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -26,6 +26,7 @@ export const addEventToFuzzySearch = ev => {
|
|||||||
if (existing > ev.created_at) {
|
if (existing > ev.created_at) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// for some reason we get duplicates even though this should be removing existing profile on update
|
||||||
fuzzySearch.remove(doc => doc.pubkey === ev.pubkey);
|
fuzzySearch.remove(doc => doc.pubkey === ev.pubkey);
|
||||||
}
|
}
|
||||||
profileTimestamps.set(ev.pubkey, ev.created_at);
|
profileTimestamps.set(ev.pubkey, ev.created_at);
|
||||||
|
72
packages/app/src/Hooks/useProfileSearch.tsx
Normal file
72
packages/app/src/Hooks/useProfileSearch.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { unixNow } from "@snort/shared";
|
||||||
|
import { socialGraphInstance } from "@snort/system";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import fuzzySearch from "@/Db/FuzzySearch";
|
||||||
|
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
|
||||||
|
|
||||||
|
export default function useProfileSearch(search: string) {
|
||||||
|
const options: TimelineFeedOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
method: "LIMIT_UNTIL",
|
||||||
|
window: undefined,
|
||||||
|
now: unixNow(),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subject: TimelineSubject = useMemo(
|
||||||
|
() => ({
|
||||||
|
type: "profile_keyword",
|
||||||
|
discriminator: search,
|
||||||
|
items: search ? [search] : [],
|
||||||
|
relay: undefined,
|
||||||
|
streams: false,
|
||||||
|
}),
|
||||||
|
[search],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { main } = useTimelineFeed(subject, options);
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
const searchString = search.trim();
|
||||||
|
const fuseResults = fuzzySearch.search(searchString);
|
||||||
|
|
||||||
|
const followDistanceNormalizationFactor = 3;
|
||||||
|
const seenIds = new Set();
|
||||||
|
|
||||||
|
const combinedResults = fuseResults
|
||||||
|
.map(result => {
|
||||||
|
const fuseScore = result.score === undefined ? 1 : result.score;
|
||||||
|
const followDistance =
|
||||||
|
socialGraphInstance.getFollowDistance(result.item.pubkey) / followDistanceNormalizationFactor;
|
||||||
|
|
||||||
|
const startsWithSearchString = [result.item.name, result.item.display_name, result.item.nip05].some(
|
||||||
|
field => field && field.toLowerCase?.().startsWith(searchString.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const boostFactor = startsWithSearchString ? 0.25 : 1;
|
||||||
|
|
||||||
|
const weightForFuseScore = 0.8;
|
||||||
|
const weightForFollowDistance = 0.2;
|
||||||
|
|
||||||
|
const combinedScore = (fuseScore * weightForFuseScore + followDistance * weightForFollowDistance) * boostFactor;
|
||||||
|
|
||||||
|
return { ...result, combinedScore };
|
||||||
|
})
|
||||||
|
// Sort by combined score, lower is better
|
||||||
|
.sort((a, b) => a.combinedScore - b.combinedScore)
|
||||||
|
.filter(r => {
|
||||||
|
// for some reason we get duplicates even though fuzzySearch should be removing existing profile on update
|
||||||
|
if (seenIds.has(r.item.pubkey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seenIds.add(r.item.pubkey);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return combinedResults.map(r => r.item);
|
||||||
|
}, [search, main]);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
@ -1,17 +1,25 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import Timeline from "@/Components/Feed/Timeline";
|
import Timeline from "@/Components/Feed/Timeline";
|
||||||
import UsersFeed from "@/Components/Feed/UsersFeed";
|
|
||||||
import Tabs, { Tab } from "@/Components/Tabs/Tabs";
|
import Tabs, { Tab } from "@/Components/Tabs/Tabs";
|
||||||
import TrendingNotes from "@/Components/Trending/TrendingPosts";
|
import TrendingNotes from "@/Components/Trending/TrendingPosts";
|
||||||
import TrendingUsers from "@/Components/Trending/TrendingUsers";
|
import TrendingUsers from "@/Components/Trending/TrendingUsers";
|
||||||
|
import FollowListBase from "@/Components/User/FollowListBase";
|
||||||
|
import useProfileSearch from "@/Hooks/useProfileSearch";
|
||||||
import { debounce } from "@/Utils";
|
import { debounce } from "@/Utils";
|
||||||
|
|
||||||
const NOTES = 0;
|
const NOTES = 0;
|
||||||
const PROFILES = 1;
|
const PROFILES = 1;
|
||||||
|
|
||||||
|
const Profiles = ({ keyword }: { keyword: string }) => {
|
||||||
|
const results = useProfileSearch(keyword);
|
||||||
|
const ids = useMemo(() => results.map(r => r.pubkey), [results]);
|
||||||
|
const content = keyword ? <FollowListBase pubkeys={ids} showAbout={true} /> : <TrendingUsers />;
|
||||||
|
return <div className="px-3">{content}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
const SearchPage = () => {
|
const SearchPage = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
@ -37,8 +45,8 @@ const SearchPage = () => {
|
|||||||
}, [keyword]);
|
}, [keyword]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setKeyword(params.keyword);
|
setKeyword(params.keyword ?? "");
|
||||||
setSearch(params.keyword); // Also update the search input field
|
setSearch(params.keyword ?? ""); // Also update the search input field
|
||||||
}, [params.keyword]);
|
}, [params.keyword]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -46,34 +54,25 @@ const SearchPage = () => {
|
|||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
function tabContent() {
|
function tabContent() {
|
||||||
|
if (tab.value === PROFILES) {
|
||||||
|
return <Profiles keyword={search} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
switch (tab.value) {
|
|
||||||
case PROFILES:
|
|
||||||
return <TrendingUsers />;
|
|
||||||
case NOTES:
|
|
||||||
return <TrendingNotes />;
|
return <TrendingNotes />;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab.value == PROFILES) {
|
|
||||||
// render UsersFeed
|
|
||||||
return <UsersFeed keyword={keyword} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pf = tab.value == PROFILES;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortOptions()}
|
{sortOptions()}
|
||||||
<Timeline
|
<Timeline
|
||||||
key={keyword + (pf ? "_p" : "")}
|
key={keyword}
|
||||||
subject={{
|
subject={{
|
||||||
type: pf ? "profile_keyword" : "post_keyword",
|
type: "post_keyword",
|
||||||
items: [keyword + (sortPopular ? " sort:popular" : "")],
|
items: [keyword + (sortPopular ? " sort:popular" : "")],
|
||||||
discriminator: keyword,
|
discriminator: keyword,
|
||||||
}}
|
}}
|
||||||
postsOnly={false}
|
postsOnly={false}
|
||||||
noSort={pf && sortPopular}
|
|
||||||
method={"LIMIT_UNTIL"}
|
method={"LIMIT_UNTIL"}
|
||||||
loadMore={false}
|
loadMore={false}
|
||||||
/>
|
/>
|
||||||
|
Loading…
Reference in New Issue
Block a user