Sort search results by follower count

This commit is contained in:
hzrd149 2023-09-10 10:11:16 -05:00
parent 2b78f4fff2
commit 03fb66156d
6 changed files with 80 additions and 57 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Sort search results by follower count

View File

@ -1,5 +1,5 @@
import relayScoreboardService from "../services/relay-scoreboard"; import relayScoreboardService from "../services/relay-scoreboard";
import { RawIncomingNostrEvent, NostrEvent } from "../types/nostr-event"; import { RawIncomingNostrEvent, NostrEvent, CountResponse } from "../types/nostr-event";
import { NostrOutgoingMessage } from "../types/nostr-query"; import { NostrOutgoingMessage } from "../types/nostr-query";
import { Subject } from "./subject"; import { Subject } from "./subject";
@ -14,6 +14,10 @@ export type IncomingNotice = {
message: string; message: string;
relay: Relay; relay: Relay;
}; };
export type IncomingCount = {
type: "COUNT";
relay: Relay;
} & CountResponse;
export type IncomingEOSE = { export type IncomingEOSE = {
type: "EOSE"; type: "EOSE";
subId: string; subId: string;
@ -44,6 +48,7 @@ export class Relay {
onClose = new Subject<Relay>(undefined, false); onClose = new Subject<Relay>(undefined, false);
onEvent = new Subject<IncomingEvent>(undefined, false); onEvent = new Subject<IncomingEvent>(undefined, false);
onNotice = new Subject<IncomingNotice>(undefined, false); onNotice = new Subject<IncomingNotice>(undefined, false);
onCount = new Subject<IncomingCount>(undefined, false);
onEOSE = new Subject<IncomingEOSE>(undefined, false); onEOSE = new Subject<IncomingEOSE>(undefined, false);
onCommandResult = new Subject<IncomingCommandResult>(undefined, false); onCommandResult = new Subject<IncomingCommandResult>(undefined, false);
ws?: WebSocket; ws?: WebSocket;
@ -180,6 +185,9 @@ export class Relay {
case "NOTICE": case "NOTICE":
this.onNotice.next({ relay: this, type, message: data[1] }); this.onNotice.next({ relay: this, type, message: data[1] });
break; break;
case "COUNT":
this.onCount.next({ relay: this, type, ...data[2] });
break;
case "EOSE": case "EOSE":
this.onEOSE.next({ relay: this, type, subId: data[1] }); this.onEOSE.next({ relay: this, type, subId: data[1] });
this.endSubResTimer(data[1]); this.endSubResTimer(data[1]);

View File

@ -1,4 +1,4 @@
export type NostrBandProfileStats = { export type NostrBandUserStats = {
pubkey: string; pubkey: string;
pub_note_count: number; pub_note_count: number;
pub_post_count: number; pub_post_count: number;
@ -57,13 +57,13 @@ export type NostrBandProfileStats = {
}; };
}; };
class UserTrustedStatsService { class TrustedUserStatsService {
private userStats = new Map<string, NostrBandProfileStats>(); private userStats = new Map<string, NostrBandUserStats>();
async fetchUserStats(pubkey: string) { async fetchUserStats(pubkey: string) {
try { try {
const stats = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`).then( const stats = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`).then(
(res) => res.json() as Promise<{ stats: Record<string, NostrBandProfileStats> }>, (res) => res.json() as Promise<{ stats: Record<string, NostrBandUserStats> }>,
); );
if (stats?.stats[pubkey]) { if (stats?.stats[pubkey]) {
@ -73,7 +73,7 @@ class UserTrustedStatsService {
} catch (e) {} } catch (e) {}
} }
private dedupe = new Map<string, Promise<NostrBandProfileStats | undefined>>(); private dedupe = new Map<string, Promise<NostrBandUserStats | undefined>>();
async getUserStats(pubkey: string, alwaysFetch = false) { async getUserStats(pubkey: string, alwaysFetch = false) {
if (this.userStats.has(pubkey) && !alwaysFetch) return this.userStats.get(pubkey)!; if (this.userStats.has(pubkey) && !alwaysFetch) return this.userStats.get(pubkey)!;
@ -85,6 +85,6 @@ class UserTrustedStatsService {
} }
} }
const userTrustedStatsService = new UserTrustedStatsService(); const trustedUserStatsService = new TrustedUserStatsService();
export default userTrustedStatsService; export default trustedUserStatsService;

View File

@ -15,14 +15,24 @@ export type NostrEvent = {
content: string; content: string;
sig: string; sig: string;
}; };
export type CountResponse = {
count: number;
approximate?: boolean;
};
export type DraftNostrEvent = Omit<NostrEvent, "pubkey" | "id" | "sig">; export type DraftNostrEvent = Omit<NostrEvent, "pubkey" | "id" | "sig">;
export type RawIncomingEvent = ["EVENT", string, NostrEvent]; export type RawIncomingEvent = ["EVENT", string, NostrEvent];
export type RawIncomingNotice = ["NOTICE", string]; export type RawIncomingNotice = ["NOTICE", string];
export type RawIncomingCount = ["COUNT", string, CountResponse];
export type RawIncomingEOSE = ["EOSE", string]; export type RawIncomingEOSE = ["EOSE", string];
export type RawIncomingCommandResult = ["OK", string, boolean, string]; export type RawIncomingCommandResult = ["OK", string, boolean, string];
export type RawIncomingNostrEvent = RawIncomingEvent | RawIncomingNotice | RawIncomingEOSE | RawIncomingCommandResult; export type RawIncomingNostrEvent =
| RawIncomingEvent
| RawIncomingNotice
| RawIncomingCount
| RawIncomingEOSE
| RawIncomingCommandResult;
export function isETag(tag: Tag): tag is ETag { export function isETag(tag: Tag): tag is ETag {
return tag[0] === "e" && tag[1] !== undefined; return tag[0] === "e" && tag[1] !== undefined;

View File

@ -1,19 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import { Box, Button, Flex, IconButton, Input, Link, Text, useDisclosure } from "@chakra-ui/react";
Box, import { Kind } from "nostr-tools";
Button, import { useSearchParams, useNavigate } from "react-router-dom";
Card, import { useAsync } from "react-use";
CardBody,
CardFooter,
CardHeader,
Flex,
IconButton,
Input,
Link,
SimpleGrid,
useDisclosure,
} from "@chakra-ui/react";
import { useSearchParams, Link as RouterLink, useNavigate } from "react-router-dom";
import { ClipboardIcon, QrCodeIcon } from "../../components/icons"; import { ClipboardIcon, QrCodeIcon } from "../../components/icons";
import QrScannerModal from "../../components/qr-scanner-modal"; import QrScannerModal from "../../components/qr-scanner-modal";
import { safeDecode } from "../../helpers/nip19"; import { safeDecode } from "../../helpers/nip19";
@ -21,52 +11,46 @@ import { getMatchHashtag } from "../../helpers/regexp";
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button"; import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers/relay-selection-provider"; import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
import useTimelineLoader from "../../hooks/use-timeline-loader"; import useTimelineLoader from "../../hooks/use-timeline-loader";
import { Kind, nip19 } from "nostr-tools";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer"; import IntersectionObserverProvider from "../../providers/intersection-observer";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import { getUserDisplayName, parseKind0Event } from "../../helpers/user-metadata"; import { parseKind0Event } from "../../helpers/user-metadata";
import { UserAvatar } from "../../components/user-avatar"; import { UserAvatar } from "../../components/user-avatar";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon"; import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status"; import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import { EventRelays } from "../../components/note/note-relays";
import { EmbedableContent, embedUrls } from "../../helpers/embeds"; import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types"; import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types";
import { getEventRelays } from "../../services/event-relays"; import { UserLink } from "../../components/user-link";
import relayScoreboardService from "../../services/relay-scoreboard"; import trustedUserStatsService, { NostrBandUserStats } from "../../services/trusted-user-stats";
import { readablizeSats } from "../../helpers/bolt11";
function buildDescriptionContent(description: string) { function ProfileResult({ profile }: { profile: NostrEvent }) {
let content: EmbedableContent = [description.trim()]; const metadata = parseKind0Event(profile);
content = embedNostrLinks(content); const aboutContent = useMemo(() => {
content = embedUrls(content, [renderGenericUrl]); if (!metadata.about) return null;
let content: EmbedableContent = [metadata.about.trim()];
content = embedNostrLinks(content);
content = embedUrls(content, [renderGenericUrl]);
return content;
}, [metadata.about]);
return content; const { value: stats } = useAsync(() => trustedUserStatsService.getUserStats(profile.pubkey), [profile.pubkey]);
}
function ProfileResult({ event }: { event: NostrEvent }) {
const metadata = parseKind0Event(event);
const aboutContent = metadata.about && buildDescriptionContent(metadata.about);
const nprofile = useMemo(() => {
const relays = getEventRelays(event.id).value;
const ranked = relayScoreboardService.getRankedRelays(relays).slice(2);
return nip19.nprofileEncode({ pubkey: event.pubkey, relays: ranked });
}, [event.id]);
return ( return (
<Box> <Box>
<UserAvatar pubkey={event.pubkey} noProxy mr="2" float="left" /> <UserAvatar pubkey={profile.pubkey} noProxy mr="2" float="left" />
<Link as={RouterLink} to={`/u/${nprofile}`} whiteSpace="nowrap" fontWeight="bold" fontSize="xl" isTruncated> <UserLink pubkey={profile.pubkey} fontWeight="bold" fontSize="xl" isTruncated />
{getUserDisplayName(metadata, event.pubkey)}
</Link>
<br /> <br />
<UserDnsIdentityIcon pubkey={event.pubkey} /> <UserDnsIdentityIcon pubkey={profile.pubkey} />
<br /> <br />
<Box whiteSpace="pre" overflow="hidden" maxH="xs"> <Box whiteSpace="pre" overflow="hidden" maxH="xs" isTruncated>
{aboutContent} {aboutContent}
</Box> </Box>
{stats && (
<>{stats.followers_pubkey_count && <Text>Followers: {readablizeSats(stats.followers_pubkey_count)}</Text>}</>
)}
</Box> </Box>
); );
} }
@ -81,14 +65,31 @@ function SearchResults({ search }: { search: string }) {
{ enabled: !!search }, { enabled: !!search },
); );
const events = useSubject(timeline?.timeline) ?? []; 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); const callback = useTimelineCurserIntersectionCallback(timeline);
return ( return (
<IntersectionObserverProvider callback={callback}> <IntersectionObserverProvider callback={callback}>
{events.map((event) => ( {sortedProfiles.map((event) => (
<ProfileResult key={event.id} event={event} /> <ProfileResult key={event.id} profile={event} />
))} ))}
<TimelineActionAndStatus timeline={timeline} /> <TimelineActionAndStatus timeline={timeline} />
</IntersectionObserverProvider> </IntersectionObserverProvider>
@ -166,7 +167,6 @@ export function SearchPage() {
); );
} }
// TODO: remove this when there is a good way to allow the user to select from a list of filtered relays that support NIP-50
const searchRelays = ["wss://relay.nostr.band", "wss://search.nos.today", "wss://relay.noswhere.com"]; const searchRelays = ["wss://relay.nostr.band", "wss://search.nos.today", "wss://relay.noswhere.com"];
export default function SearchView() { export default function SearchView() {
return ( return (

View File

@ -28,7 +28,7 @@ import { getUserDisplayName } from "../../helpers/user-metadata";
import { getLudEndpoint } from "../../helpers/lnurl"; import { getLudEndpoint } from "../../helpers/lnurl";
import { EmbedableContent, embedUrls } from "../../helpers/embeds"; import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { truncatedId } from "../../helpers/nostr/events"; import { truncatedId } from "../../helpers/nostr/events";
import userTrustedStatsService from "../../services/user-trusted-stats"; import trustedUserStatsService from "../../services/trusted-user-stats";
import { parseAddress } from "../../services/dns-identity"; import { parseAddress } from "../../services/dns-identity";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useUserMetadata } from "../../hooks/use-user-metadata"; import { useUserMetadata } from "../../hooks/use-user-metadata";
@ -66,7 +66,7 @@ export default function UserAboutTab() {
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const nprofile = useSharableProfileId(pubkey); const nprofile = useSharableProfileId(pubkey);
const { value: stats } = useAsync(() => userTrustedStatsService.getUserStats(pubkey), [pubkey]); const { value: stats } = useAsync(() => trustedUserStatsService.getUserStats(pubkey), [pubkey]);
const aboutContent = metadata?.about && buildDescriptionContent(metadata?.about); const aboutContent = metadata?.about && buildDescriptionContent(metadata?.about);
const parsedNip05 = metadata?.nip05 ? parseAddress(metadata.nip05) : undefined; const parsedNip05 = metadata?.nip05 ? parseAddress(metadata.nip05) : undefined;