add communities to search

This commit is contained in:
hzrd149 2023-11-12 14:39:26 +09:00
parent 313eb19840
commit 6d701a7b24
6 changed files with 212 additions and 131 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add option to search communities in search view

View File

@ -0,0 +1,26 @@
import { Kind } from "nostr-tools";
import { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import GenericNoteTimeline from "../../components/timeline-page/generic-note-timeline";
export default function ArticleSearchResults({ search }: { search: string }) {
const searchRelays = useRelaySelectionRelays();
const timeline = useTimelineLoader(
`${search}-article-search`,
searchRelays,
{ search: search || "", kinds: [Kind.Article] },
{ enabled: !!search },
);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<GenericNoteTimeline timeline={timeline} />
</IntersectionObserverProvider>
);
}

View File

@ -0,0 +1,45 @@
import { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
import { useRef } from "react";
import { getEventUID } from "../../helpers/nostr/events";
import { NostrEvent } from "../../types/nostr-event";
import CommunityCard from "../communities/components/community-card";
import useSubject from "../../hooks/use-subject";
function CommunityResult({ community }: { community: NostrEvent }) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(community));
return (
<div ref={ref}>
<CommunityCard community={community} maxW="xl" />
</div>
);
}
export default function CommunitySearchResults({ search }: { search: string }) {
const searchRelays = useRelaySelectionRelays();
const timeline = useTimelineLoader(
`${search}-community-search`,
searchRelays,
{ search: search || "", kinds: [COMMUNITY_DEFINITION_KIND] },
{ enabled: !!search },
);
const communities = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<IntersectionObserverProvider callback={callback}>
{communities.map((community) => (
<CommunityResult key={getEventUID(community)} community={community} />
))}
</IntersectionObserverProvider>
</IntersectionObserverProvider>
);
}

View File

@ -1,144 +1,22 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Box, Button, ButtonGroup, Flex, IconButton, Input, Link, Text, useDisclosure } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { useCallback, useEffect, useState } from "react";
import { Button, ButtonGroup, Flex, IconButton, Input, Link, useDisclosure } from "@chakra-ui/react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useAsync } from "react-use";
import { SEARCH_RELAYS } from "../../const";
import { NostrEvent } from "../../types/nostr-event";
import { safeDecode } from "../../helpers/nip19";
import { getMatchHashtag } from "../../helpers/regexp";
import { parseKind0Event } from "../../helpers/user-metadata";
import { readablizeSats } from "../../helpers/bolt11";
import { searchParamsToJson } from "../../helpers/url";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { CopyToClipboardIcon, NotesIcon, QrCodeIcon } from "../../components/icons";
import { CommunityIcon, CopyToClipboardIcon, NotesIcon, QrCodeIcon } from "../../components/icons";
import QrScannerModal from "../../components/qr-scanner-modal";
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import UserAvatar from "../../components/user-avatar";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types";
import { UserLink } from "../../components/user-link";
import trustedUserStatsService, { NostrBandUserStats } from "../../services/trusted-user-stats";
import RelaySelectionProvider from "../../providers/relay-selection-provider";
import VerticalPageLayout from "../../components/vertical-page-layout";
import User01 from "../../components/icons/user-01";
import GenericNoteTimeline from "../../components/timeline-page/generic-note-timeline";
import Feather from "../../components/icons/feather";
function ProfileResult({ profile }: { profile: NostrEvent }) {
const metadata = parseKind0Event(profile);
const aboutContent = useMemo(() => {
if (!metadata.about) return null;
let content: EmbedableContent = [metadata.about.trim()];
content = embedNostrLinks(content);
content = embedUrls(content, [renderGenericUrl]);
return content;
}, [metadata.about]);
const { value: stats } = useAsync(() => trustedUserStatsService.getUserStats(profile.pubkey), [profile.pubkey]);
return (
<Box>
<UserAvatar pubkey={profile.pubkey} noProxy mr="2" float="left" />
<UserLink pubkey={profile.pubkey} fontWeight="bold" fontSize="xl" isTruncated />
<br />
<UserDnsIdentityIcon pubkey={profile.pubkey} />
<br />
<Box whiteSpace="pre" overflow="hidden" maxH="xs" isTruncated>
{aboutContent}
</Box>
{stats && (
<>{stats.followers_pubkey_count && <Text>Followers: {readablizeSats(stats.followers_pubkey_count)}</Text>}</>
)}
</Box>
);
}
function ProfileSearchResults({ search }: { search: string }) {
const searchRelays = useRelaySelectionRelays();
const timeline = useTimelineLoader(
`${search}-profile-search`,
searchRelays,
{ search: search || "", kinds: [Kind.Metadata] },
{ enabled: !!search },
);
const profiles = useSubject(timeline?.timeline) ?? [];
const [profileStats, setProfileStats] = useState<Record<string, NostrBandUserStats>>({});
useEffect(() => {
for (const profile of profiles) {
trustedUserStatsService.getUserStats(profile.pubkey).then((stats) => {
if (!stats) return;
setProfileStats((dir) => ({ ...dir, [stats.pubkey]: stats }));
});
}
}, [profiles]);
const sortedProfiles = useMemo(() => {
return profiles.sort(
(a, b) =>
(profileStats[b.pubkey]?.followers_pubkey_count ?? 0) - (profileStats[a.pubkey]?.followers_pubkey_count ?? 0),
);
}, [profileStats, profiles]);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
{sortedProfiles.map((event) => (
<ProfileResult key={event.id} profile={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</IntersectionObserverProvider>
);
}
function NoteSearchResults({ search }: { search: string }) {
const searchRelays = useRelaySelectionRelays();
const timeline = useTimelineLoader(
`${search}-note-search`,
searchRelays,
{ search: search || "", kinds: [Kind.Text] },
{ enabled: !!search },
);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<GenericNoteTimeline timeline={timeline} />
</IntersectionObserverProvider>
);
}
function ArticleSearchResults({ search }: { search: string }) {
const searchRelays = useRelaySelectionRelays();
const timeline = useTimelineLoader(
`${search}-article-search`,
searchRelays,
{ search: search || "", kinds: [Kind.Article] },
{ enabled: !!search },
);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<GenericNoteTimeline timeline={timeline} />
</IntersectionObserverProvider>
);
}
import ProfileSearchResults from "./profile-results";
import NoteSearchResults from "./note-results";
import ArticleSearchResults from "./article-results";
import CommunitySearchResults from "./community-results";
export function SearchPage() {
const navigate = useNavigate();
@ -199,6 +77,9 @@ export function SearchPage() {
case "articles":
SearchResults = ArticleSearchResults;
break;
case "communities":
SearchResults = CommunitySearchResults;
break;
}
return (
@ -219,7 +100,7 @@ export function SearchPage() {
</form>
<Flex gap="2">
<ButtonGroup size="sm" isAttached variant="outline">
<ButtonGroup size="sm" isAttached variant="outline" flexWrap="wrap">
<Button
leftIcon={<User01 />}
colorScheme={type === "users" ? "primary" : undefined}
@ -241,6 +122,13 @@ export function SearchPage() {
>
Articles
</Button>
<Button
leftIcon={<CommunityIcon />}
colorScheme={type === "communities" ? "primary" : undefined}
onClick={() => mergeSearchParams({ type: "communities" })}
>
Communities
</Button>
</ButtonGroup>
<RelaySelectionButton ml="auto" size="sm" />
</Flex>

View File

@ -0,0 +1,26 @@
import { Kind } from "nostr-tools";
import { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import GenericNoteTimeline from "../../components/timeline-page/generic-note-timeline";
export default function NoteSearchResults({ search }: { search: string }) {
const searchRelays = useRelaySelectionRelays();
const timeline = useTimelineLoader(
`${search}-note-search`,
searchRelays,
{ search: search || "", kinds: [Kind.Text] },
{ enabled: !!search },
);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<GenericNoteTimeline timeline={timeline} />
</IntersectionObserverProvider>
);
}

View File

@ -0,0 +1,91 @@
import { useEffect, useMemo, useState } from "react";
import { Box, Text } from "@chakra-ui/react";
import { useAsync } from "react-use";
import { NostrEvent } from "../../types/nostr-event";
import { parseKind0Event } from "../../helpers/user-metadata";
import { readablizeSats } from "../../helpers/bolt11";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import UserAvatar from "../../components/user-avatar";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types";
import { UserLink } from "../../components/user-link";
import trustedUserStatsService, { NostrBandUserStats } from "../../services/trusted-user-stats";
import { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { Kind } from "nostr-tools";
import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
function ProfileResult({ profile }: { profile: NostrEvent }) {
const metadata = parseKind0Event(profile);
const aboutContent = useMemo(() => {
if (!metadata.about) return null;
let content: EmbedableContent = [metadata.about.trim()];
content = embedNostrLinks(content);
content = embedUrls(content, [renderGenericUrl]);
return content;
}, [metadata.about]);
const { value: stats } = useAsync(() => trustedUserStatsService.getUserStats(profile.pubkey), [profile.pubkey]);
return (
<Box>
<UserAvatar pubkey={profile.pubkey} noProxy mr="2" float="left" />
<UserLink pubkey={profile.pubkey} fontWeight="bold" fontSize="xl" isTruncated />
<br />
<UserDnsIdentityIcon pubkey={profile.pubkey} />
<br />
<Box whiteSpace="pre" overflow="hidden" maxH="xs" isTruncated>
{aboutContent}
</Box>
{stats && (
<>{stats.followers_pubkey_count && <Text>Followers: {readablizeSats(stats.followers_pubkey_count)}</Text>}</>
)}
</Box>
);
}
export default function ProfileSearchResults({ search }: { search: string }) {
const searchRelays = useRelaySelectionRelays();
const timeline = useTimelineLoader(
`${search}-profile-search`,
searchRelays,
{ search: search || "", kinds: [Kind.Metadata] },
{ enabled: !!search },
);
const profiles = useSubject(timeline?.timeline) ?? [];
const [profileStats, setProfileStats] = useState<Record<string, NostrBandUserStats>>({});
useEffect(() => {
for (const profile of profiles) {
trustedUserStatsService.getUserStats(profile.pubkey).then((stats) => {
if (!stats) return;
setProfileStats((dir) => ({ ...dir, [stats.pubkey]: stats }));
});
}
}, [profiles]);
const sortedProfiles = useMemo(() => {
return profiles.sort(
(a, b) =>
(profileStats[b.pubkey]?.followers_pubkey_count ?? 0) - (profileStats[a.pubkey]?.followers_pubkey_count ?? 0),
);
}, [profileStats, profiles]);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
{sortedProfiles.map((event) => (
<ProfileResult key={event.id} profile={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</IntersectionObserverProvider>
);
}