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

View File

@ -1,4 +1,4 @@
export type NostrBandProfileStats = {
export type NostrBandUserStats = {
pubkey: string;
pub_note_count: number;
pub_post_count: number;
@ -57,13 +57,13 @@ export type NostrBandProfileStats = {
};
};
class UserTrustedStatsService {
private userStats = new Map<string, NostrBandProfileStats>();
class TrustedUserStatsService {
private userStats = new Map<string, NostrBandUserStats>();
async fetchUserStats(pubkey: string) {
try {
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]) {
@ -73,7 +73,7 @@ class UserTrustedStatsService {
} catch (e) {}
}
private dedupe = new Map<string, Promise<NostrBandProfileStats | undefined>>();
private dedupe = new Map<string, Promise<NostrBandUserStats | undefined>>();
async getUserStats(pubkey: string, alwaysFetch = false) {
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;
sig: string;
};
export type CountResponse = {
count: number;
approximate?: boolean;
};
export type DraftNostrEvent = Omit<NostrEvent, "pubkey" | "id" | "sig">;
export type RawIncomingEvent = ["EVENT", string, NostrEvent];
export type RawIncomingNotice = ["NOTICE", string];
export type RawIncomingCount = ["COUNT", string, CountResponse];
export type RawIncomingEOSE = ["EOSE", 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 {
return tag[0] === "e" && tag[1] !== undefined;

View File

@ -1,19 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Card,
CardBody,
CardFooter,
CardHeader,
Flex,
IconButton,
Input,
Link,
SimpleGrid,
useDisclosure,
} from "@chakra-ui/react";
import { useSearchParams, Link as RouterLink, useNavigate } from "react-router-dom";
import { Box, Button, Flex, IconButton, Input, Link, Text, useDisclosure } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useAsync } from "react-use";
import { ClipboardIcon, QrCodeIcon } from "../../components/icons";
import QrScannerModal from "../../components/qr-scanner-modal";
import { safeDecode } from "../../helpers/nip19";
@ -21,52 +11,46 @@ import { getMatchHashtag } from "../../helpers/regexp";
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 { Kind, nip19 } 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 { 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 { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import { EventRelays } from "../../components/note/note-relays";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types";
import { getEventRelays } from "../../services/event-relays";
import relayScoreboardService from "../../services/relay-scoreboard";
import { UserLink } from "../../components/user-link";
import trustedUserStatsService, { NostrBandUserStats } from "../../services/trusted-user-stats";
import { readablizeSats } from "../../helpers/bolt11";
function buildDescriptionContent(description: string) {
let content: EmbedableContent = [description.trim()];
function ProfileResult({ profile }: { profile: NostrEvent }) {
const metadata = parseKind0Event(profile);
content = embedNostrLinks(content);
content = embedUrls(content, [renderGenericUrl]);
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]);
return content;
}
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]);
const { value: stats } = useAsync(() => trustedUserStatsService.getUserStats(profile.pubkey), [profile.pubkey]);
return (
<Box>
<UserAvatar pubkey={event.pubkey} noProxy mr="2" float="left" />
<Link as={RouterLink} to={`/u/${nprofile}`} whiteSpace="nowrap" fontWeight="bold" fontSize="xl" isTruncated>
{getUserDisplayName(metadata, event.pubkey)}
</Link>
<UserAvatar pubkey={profile.pubkey} noProxy mr="2" float="left" />
<UserLink pubkey={profile.pubkey} fontWeight="bold" fontSize="xl" isTruncated />
<br />
<UserDnsIdentityIcon pubkey={event.pubkey} />
<UserDnsIdentityIcon pubkey={profile.pubkey} />
<br />
<Box whiteSpace="pre" overflow="hidden" maxH="xs">
<Box whiteSpace="pre" overflow="hidden" maxH="xs" isTruncated>
{aboutContent}
</Box>
{stats && (
<>{stats.followers_pubkey_count && <Text>Followers: {readablizeSats(stats.followers_pubkey_count)}</Text>}</>
)}
</Box>
);
}
@ -81,14 +65,31 @@ function SearchResults({ search }: { search: string }) {
{ 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);
return (
<IntersectionObserverProvider callback={callback}>
{events.map((event) => (
<ProfileResult key={event.id} event={event} />
{sortedProfiles.map((event) => (
<ProfileResult key={event.id} profile={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</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"];
export default function SearchView() {
return (

View File

@ -28,7 +28,7 @@ import { getUserDisplayName } from "../../helpers/user-metadata";
import { getLudEndpoint } from "../../helpers/lnurl";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
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 { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useUserMetadata } from "../../hooks/use-user-metadata";
@ -66,7 +66,7 @@ export default function UserAboutTab() {
const npub = nip19.npubEncode(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 parsedNip05 = metadata?.nip05 ? parseAddress(metadata.nip05) : undefined;