improve local user search

This commit is contained in:
hzrd149
2023-10-17 16:41:55 -05:00
parent 0804ee33b4
commit 0e1cbfcb30
10 changed files with 196 additions and 56 deletions

View File

@@ -1,5 +1,6 @@
import React from "react"; import React, { useEffect } from "react";
import { Container, Flex, Spacer } from "@chakra-ui/react"; import { Container, Flex, Spacer, useDisclosure } from "@chakra-ui/react";
import { useKeyPressEvent } from "react-use";
import { ErrorBoundary } from "../error-boundary"; import { ErrorBoundary } from "../error-boundary";
import { ReloadPrompt } from "../reload-prompt"; import { ReloadPrompt } from "../reload-prompt";
@@ -9,10 +10,25 @@ import useSubject from "../../hooks/use-subject";
import accountService from "../../services/account"; import accountService from "../../services/account";
import GhostToolbar from "./ghost-toolbar"; import GhostToolbar from "./ghost-toolbar";
import { useBreakpointValue } from "../../providers/breakpoint-provider"; import { useBreakpointValue } from "../../providers/breakpoint-provider";
import SearchModal from "../search-modal";
import { useLocation } from "react-router-dom";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const isMobile = useBreakpointValue({ base: true, md: false }); const isMobile = useBreakpointValue({ base: true, md: false });
const isGhost = useSubject(accountService.isGhost); const isGhost = useSubject(accountService.isGhost);
const searchModal = useDisclosure();
useKeyPressEvent("k", (e) => {
if (e.ctrlKey) {
e.preventDefault();
searchModal.onOpen();
}
});
const location = useLocation();
useEffect(() => {
searchModal.onClose();
}, [location.pathname]);
return ( return (
<> <>
@@ -47,6 +63,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<Spacer display={["none", null, "block"]} /> <Spacer display={["none", null, "block"]} />
</Flex> </Flex>
{isGhost && <GhostToolbar />} {isGhost && <GhostToolbar />}
{searchModal.isOpen && <SearchModal isOpen onClose={searchModal.onClose} />}
</> </>
); );
} }

View File

@@ -10,9 +10,8 @@ import { nip19 } from "nostr-tools";
import { matchSorter } from "match-sorter/dist/match-sorter.esm.js"; import { matchSorter } from "match-sorter/dist/match-sorter.esm.js";
import { Emoji, useContextEmojis } from "../providers/emoji-provider"; import { Emoji, useContextEmojis } from "../providers/emoji-provider";
import { UserDirectory, useUserDirectoryContext } from "../providers/user-directory-provider"; import { useUserDirectoryContext } from "../providers/user-directory-provider";
import { UserAvatar } from "./user-avatar"; import { UserAvatar } from "./user-avatar";
import userMetadataService from "../services/user-metadata";
export type PeopleToken = { pubkey: string; names: string[] }; export type PeopleToken = { pubkey: string; names: string[] };
type Token = Emoji | PeopleToken; type Token = Emoji | PeopleToken;
@@ -51,21 +50,6 @@ function output(token: Token) {
} else return ""; } else return "";
} }
function getUsersFromDirectory(directory: UserDirectory) {
const people: PeopleToken[] = [];
for (const pubkey of directory) {
const metadata = userMetadataService.getSubject(pubkey).value;
if (!metadata) continue;
const names: string[] = [];
if (metadata.display_name) names.push(metadata.display_name);
if (metadata.name) names.push(metadata.name);
if (names.length > 0) {
people.push({ pubkey, names });
}
}
return people;
}
const Loading: ReactTextareaAutocompleteProps< const Loading: ReactTextareaAutocompleteProps<
Token, Token,
React.TextareaHTMLAttributes<HTMLTextAreaElement> React.TextareaHTMLAttributes<HTMLTextAreaElement>
@@ -85,7 +69,7 @@ function useAutocompleteTriggers() {
}, },
"@": { "@": {
dataProvider: async (token: string) => { dataProvider: async (token: string) => {
const dir = getUsersFromDirectory(await getDirectory()); const dir = await getDirectory();
return matchSorter(dir, token.trim(), { keys: ["names"] }).slice(0, 10); return matchSorter(dir, token.trim(), { keys: ["names"] }).slice(0, 10);
}, },
component: Item, component: Item,

View File

@@ -0,0 +1,57 @@
import { Flex, Input, Modal, ModalContent, ModalOverlay, ModalProps, Text } from "@chakra-ui/react";
import { useRef, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { useAsync, useThrottle } from "react-use";
import { matchSorter } from "match-sorter";
import { nip19 } from "nostr-tools";
import { useUserDirectoryContext } from "../../providers/user-directory-provider";
import { UserAvatar } from "../user-avatar";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { getUserDisplayName } from "../../helpers/user-metadata";
function UserOption({ pubkey }: { pubkey: string }) {
const metadata = useUserMetadata(pubkey);
return (
<Flex as={RouterLink} to={`/u/${nip19.npubEncode(pubkey)}`} p="2" gap="2" alignItems="center">
<UserAvatar pubkey={pubkey} size="sm" />
<Text fontWeight="bold">{getUserDisplayName(metadata, pubkey)}</Text>
</Flex>
);
}
export default function SearchModal({ isOpen, onClose }: Omit<ModalProps, "children">) {
const searchRef = useRef<HTMLInputElement | null>(null);
const getDirectory = useUserDirectoryContext();
const [inputValue, setInputValue] = useState("");
const search = useThrottle(inputValue);
const { value: localUsers = [] } = useAsync(async () => {
if (search.trim().length < 2) return [];
const dir = await getDirectory();
return matchSorter(dir, search.trim(), { keys: ["names"] }).slice(0, 5);
}, [search]);
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl" initialFocusRef={searchRef}>
<ModalOverlay />
<ModalContent>
<Input
placeholder="Search"
type="search"
w="full"
size="lg"
ref={searchRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
{localUsers.map(({ pubkey }) => (
<UserOption key={pubkey} pubkey={pubkey} />
))}
</ModalContent>
</Modal>
);
}

View File

@@ -1,2 +1,8 @@
export const SEARCH_RELAYS = ["wss://relay.nostr.band", "wss://search.nos.today", "wss://relay.noswhere.com"]; export const SEARCH_RELAYS = [
"wss://relay.nostr.band",
"wss://search.nos.today",
"wss://relay.noswhere.com",
// TODO: requires NIP-42 auth
// "wss://filter.nostr.wine",
];
export const COMMON_CONTACT_RELAY = "wss://purplepag.es"; export const COMMON_CONTACT_RELAY = "wss://purplepag.es";

View File

@@ -32,6 +32,12 @@ export function parseKind0Event(event: NostrEvent): Kind0ParsedContent {
return {}; return {};
} }
export function getSearchNames(metadata: Kind0ParsedContent) {
if (!metadata) return [];
return [metadata.display_name, metadata.name].filter(Boolean) as string[];
}
export function getUserDisplayName(metadata: Kind0ParsedContent | undefined, pubkey: string) { export function getUserDisplayName(metadata: Kind0ParsedContent | undefined, pubkey: string) {
return metadata?.display_name || metadata?.name || truncatedId(nip19.npubEncode(pubkey)); return metadata?.display_name || metadata?.name || truncatedId(nip19.npubEncode(pubkey));
} }

View File

@@ -9,7 +9,7 @@ import { InvoiceModalProvider } from "./invoice-modal";
import NotificationTimelineProvider from "./notification-timeline"; import NotificationTimelineProvider from "./notification-timeline";
import PostModalProvider from "./post-modal-provider"; import PostModalProvider from "./post-modal-provider";
import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider"; import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider";
import { UserContactsUserDirectoryProvider } from "./user-directory-provider"; import { AllUserDirectoryProvider } from "./user-directory-provider";
import MuteModalProvider from "./mute-modal-provider"; import MuteModalProvider from "./mute-modal-provider";
import BreakpointProvider from "./breakpoint-provider"; import BreakpointProvider from "./breakpoint-provider";
@@ -39,9 +39,9 @@ export function PageProviders({ children }: { children: React.ReactNode }) {
<NotificationTimelineProvider> <NotificationTimelineProvider>
<DefaultEmojiProvider> <DefaultEmojiProvider>
<UserEmojiProvider> <UserEmojiProvider>
<UserContactsUserDirectoryProvider> <AllUserDirectoryProvider>
<PostModalProvider>{children}</PostModalProvider> <PostModalProvider>{children}</PostModalProvider>
</UserContactsUserDirectoryProvider> </AllUserDirectoryProvider>
</UserEmojiProvider> </UserEmojiProvider>
</DefaultEmojiProvider> </DefaultEmojiProvider>
</NotificationTimelineProvider> </NotificationTimelineProvider>

View File

@@ -3,8 +3,10 @@ import { PropsWithChildren, createContext, useCallback, useContext } from "react
import { useCurrentAccount } from "../hooks/use-current-account"; import { useCurrentAccount } from "../hooks/use-current-account";
import useUserContactList from "../hooks/use-user-contact-list"; import useUserContactList from "../hooks/use-user-contact-list";
import { getPubkeysFromList } from "../helpers/nostr/lists"; import { getPubkeysFromList } from "../helpers/nostr/lists";
import userMetadataService from "../services/user-metadata";
import db from "../services/db";
export type UserDirectory = string[]; export type UserDirectory = { pubkey: string; names: [] }[];
export type GetDirectoryFn = () => Promise<UserDirectory> | UserDirectory; export type GetDirectoryFn = () => Promise<UserDirectory> | UserDirectory;
const UserDirectoryContext = createContext<GetDirectoryFn>(async () => []); const UserDirectoryContext = createContext<GetDirectoryFn>(async () => []);
@@ -12,19 +14,42 @@ export function useUserDirectoryContext() {
return useContext(UserDirectoryContext); return useContext(UserDirectoryContext);
} }
export function UserContactsUserDirectoryProvider({ children, pubkey }: PropsWithChildren & { pubkey?: string }) { // export function getNameDirectory(directory: UserDirectory) {
const account = useCurrentAccount(); // const people: { pubkey: string; names: string[] }[] = [];
const contacts = useUserContactList(pubkey || account?.pubkey); // for (const pubkey of directory) {
// const metadata = userMetadataService.getSubject(pubkey).value;
// if (!metadata) continue;
// const names: string[] = [];
// if (metadata.display_name) names.push(metadata.display_name);
// if (metadata.name) names.push(metadata.name);
// if (names.length > 0) {
// people.push({ pubkey, names });
// }
// }
// return people;
// }
// export function UserContactsUserDirectoryProvider({ children, pubkey }: PropsWithChildren & { pubkey?: string }) {
// const account = useCurrentAccount();
// const contacts = useUserContactList(pubkey || account?.pubkey);
// const getDirectory = useCallback(async () => {
// const people = contacts ? getPubkeysFromList(contacts).map((p) => p.pubkey) : [];
// const directory: UserDirectory = [];
// for (const pubkey of people) {
// directory.push(pubkey);
// }
// return directory;
// }, [contacts]);
// return <UserDirectoryProvider getDirectory={getDirectory}>{children}</UserDirectoryProvider>;
// }
export function AllUserDirectoryProvider({ children }: PropsWithChildren) {
const getDirectory = useCallback(async () => { const getDirectory = useCallback(async () => {
const people = contacts ? getPubkeysFromList(contacts).map((p) => p.pubkey) : []; return await db.getAll("userSearch");
const directory: UserDirectory = []; }, []);
for (const pubkey of people) {
directory.push(pubkey);
}
return directory;
}, [contacts]);
return <UserDirectoryProvider getDirectory={getDirectory}>{children}</UserDirectoryProvider>; return <UserDirectoryProvider getDirectory={getDirectory}>{children}</UserDirectoryProvider>;
} }

View File

@@ -1,9 +1,9 @@
import { openDB, deleteDB, IDBPDatabase } from "idb"; import { openDB, deleteDB, IDBPDatabase } from "idb";
import { SchemaV1, SchemaV2, SchemaV3 } from "./schema"; import { SchemaV1, SchemaV2, SchemaV3, SchemaV4 } from "./schema";
const dbName = "storage"; const dbName = "storage";
const version = 3; const version = 4;
const db = await openDB<SchemaV3>(dbName, version, { const db = await openDB<SchemaV4>(dbName, version, {
upgrade(db, oldVersion, newVersion, transaction, event) { upgrade(db, oldVersion, newVersion, transaction, event) {
if (oldVersion < 1) { if (oldVersion < 1) {
const v0 = db as unknown as IDBPDatabase<SchemaV1>; const v0 = db as unknown as IDBPDatabase<SchemaV1>;
@@ -71,6 +71,19 @@ const db = await openDB<SchemaV3>(dbName, version, {
}); });
settings.createIndex("created", "created"); settings.createIndex("created", "created");
} }
if (oldVersion < 4) {
const v3 = db as unknown as IDBPDatabase<SchemaV3>;
const v4 = db as unknown as IDBPDatabase<SchemaV4>;
// rename the tables
v3.deleteObjectStore("userFollows");
// create new search table
v4.createObjectStore("userSearch", {
keyPath: "pubkey",
});
}
}, },
}); });

View File

@@ -77,3 +77,18 @@ export interface SchemaV3 {
relayScoreboardStats: SchemaV2["relayScoreboardStats"]; relayScoreboardStats: SchemaV2["relayScoreboardStats"];
misc: SchemaV2["misc"]; misc: SchemaV2["misc"];
} }
export interface SchemaV4 {
replaceableEvents: SchemaV3["replaceableEvents"];
dnsIdentifiers: SchemaV3["dnsIdentifiers"];
relayInfo: SchemaV3["relayInfo"];
relayScoreboardStats: SchemaV3["relayScoreboardStats"];
userSearch: {
key: string;
value: {
pubkey: string;
names: string[];
};
};
misc: SchemaV3["misc"];
}

View File

@@ -1,27 +1,24 @@
import db from "./db"; import db from "./db";
import { Kind } from "nostr-tools";
import _throttle from "lodash.throttle";
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import { Kind0ParsedContent, parseKind0Event } from "../helpers/user-metadata"; import { Kind0ParsedContent, getSearchNames, parseKind0Event } from "../helpers/user-metadata";
import SuperMap from "../classes/super-map"; import SuperMap from "../classes/super-map";
import Subject from "../classes/subject"; import Subject from "../classes/subject";
import replaceableEventLoaderService, { RequestOptions } from "./replaceable-event-requester"; import replaceableEventLoaderService, { RequestOptions } from "./replaceable-event-requester";
import { Kind } from "nostr-tools";
class UserMetadataService { class UserMetadataService {
// requester: CachedPubkeyEventRequester; private parsedSubjects = new SuperMap<string, Subject<Kind0ParsedContent>>((pubkey) => {
// constructor() { const sub = new Subject<Kind0ParsedContent>();
// this.requester = new CachedPubkeyEventRequester(0, "user-metadata"); sub.subscribe((metadata) => {
// this.requester.readCache = this.readCache; if (metadata) {
// this.requester.writeCache = this.writeCache; this.writeSearchQueue.add(pubkey);
// } this.writeSearchDataThrottle();
}
readCache(pubkey: string) { });
return db.get("userMetadata", pubkey); return sub;
} });
writeCache(pubkey: string, event: NostrEvent) {
return db.put("userMetadata", event);
}
private parsedSubjects = new SuperMap<string, Subject<Kind0ParsedContent>>(() => new Subject<Kind0ParsedContent>());
getSubject(pubkey: string) { getSubject(pubkey: string) {
return this.parsedSubjects.get(pubkey); return this.parsedSubjects.get(pubkey);
} }
@@ -35,6 +32,26 @@ class UserMetadataService {
receiveEvent(event: NostrEvent) { receiveEvent(event: NostrEvent) {
replaceableEventLoaderService.handleEvent(event); replaceableEventLoaderService.handleEvent(event);
} }
private writeSearchQueue = new Set<string>();
private writeSearchDataThrottle = _throttle(this.writeSearchData.bind(this));
private async writeSearchData() {
if (this.writeSearchQueue.size === 0) return;
const keys = Array.from(this.writeSearchQueue);
this.writeSearchQueue.clear();
const transaction = db.transaction("userSearch", "readwrite");
for (const pubkey of keys) {
const metadata = this.getSubject(pubkey).value;
if (metadata) {
const names = getSearchNames(metadata);
transaction.objectStore("userSearch").put({ pubkey, names });
}
}
transaction.commit();
await transaction.done;
}
} }
const userMetadataService = new UserMetadataService(); const userMetadataService = new UserMetadataService();