diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index 9048f0f2b..a7e559906 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -1,5 +1,6 @@ -import React from "react"; -import { Container, Flex, Spacer } from "@chakra-ui/react"; +import React, { useEffect } from "react"; +import { Container, Flex, Spacer, useDisclosure } from "@chakra-ui/react"; +import { useKeyPressEvent } from "react-use"; import { ErrorBoundary } from "../error-boundary"; import { ReloadPrompt } from "../reload-prompt"; @@ -9,10 +10,25 @@ import useSubject from "../../hooks/use-subject"; import accountService from "../../services/account"; import GhostToolbar from "./ghost-toolbar"; 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 }) { const isMobile = useBreakpointValue({ base: true, md: false }); 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 ( <> @@ -47,6 +63,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { {isGhost && } + {searchModal.isOpen && } ); } diff --git a/src/components/magic-textarea.tsx b/src/components/magic-textarea.tsx index 528298fbb..42f3b5b44 100644 --- a/src/components/magic-textarea.tsx +++ b/src/components/magic-textarea.tsx @@ -10,9 +10,8 @@ import { nip19 } from "nostr-tools"; import { matchSorter } from "match-sorter/dist/match-sorter.esm.js"; 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 userMetadataService from "../services/user-metadata"; export type PeopleToken = { pubkey: string; names: string[] }; type Token = Emoji | PeopleToken; @@ -51,21 +50,6 @@ function output(token: Token) { } 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< Token, React.TextareaHTMLAttributes @@ -85,7 +69,7 @@ function useAutocompleteTriggers() { }, "@": { dataProvider: async (token: string) => { - const dir = getUsersFromDirectory(await getDirectory()); + const dir = await getDirectory(); return matchSorter(dir, token.trim(), { keys: ["names"] }).slice(0, 10); }, component: Item, diff --git a/src/components/search-modal/index.tsx b/src/components/search-modal/index.tsx new file mode 100644 index 000000000..e590a773e --- /dev/null +++ b/src/components/search-modal/index.tsx @@ -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 ( + + + {getUserDisplayName(metadata, pubkey)} + + ); +} + +export default function SearchModal({ isOpen, onClose }: Omit) { + const searchRef = useRef(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 ( + + + + setInputValue(e.target.value)} + /> + {localUsers.map(({ pubkey }) => ( + + ))} + + + ); +} diff --git a/src/const.ts b/src/const.ts index 95cc35ec3..b9564750b 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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"; diff --git a/src/helpers/user-metadata.ts b/src/helpers/user-metadata.ts index 60ef9c320..002400533 100644 --- a/src/helpers/user-metadata.ts +++ b/src/helpers/user-metadata.ts @@ -32,6 +32,12 @@ export function parseKind0Event(event: NostrEvent): Kind0ParsedContent { 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) { return metadata?.display_name || metadata?.name || truncatedId(nip19.npubEncode(pubkey)); } diff --git a/src/providers/index.tsx b/src/providers/index.tsx index 132a4c434..cb63b040b 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -9,7 +9,7 @@ import { InvoiceModalProvider } from "./invoice-modal"; import NotificationTimelineProvider from "./notification-timeline"; import PostModalProvider from "./post-modal-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 BreakpointProvider from "./breakpoint-provider"; @@ -39,9 +39,9 @@ export function PageProviders({ children }: { children: React.ReactNode }) { - + {children} - + diff --git a/src/providers/user-directory-provider.tsx b/src/providers/user-directory-provider.tsx index c59fe6b16..a26afd926 100644 --- a/src/providers/user-directory-provider.tsx +++ b/src/providers/user-directory-provider.tsx @@ -3,8 +3,10 @@ import { PropsWithChildren, createContext, useCallback, useContext } from "react import { useCurrentAccount } from "../hooks/use-current-account"; import useUserContactList from "../hooks/use-user-contact-list"; 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; const UserDirectoryContext = createContext(async () => []); @@ -12,19 +14,42 @@ export function useUserDirectoryContext() { return useContext(UserDirectoryContext); } -export function UserContactsUserDirectoryProvider({ children, pubkey }: PropsWithChildren & { pubkey?: string }) { - const account = useCurrentAccount(); - const contacts = useUserContactList(pubkey || account?.pubkey); +// export function getNameDirectory(directory: UserDirectory) { +// const people: { pubkey: string; names: string[] }[] = []; +// 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 getDirectory = useCallback(async () => { - const people = contacts ? getPubkeysFromList(contacts).map((p) => p.pubkey) : []; - const directory: UserDirectory = []; +// export function UserContactsUserDirectoryProvider({ children, pubkey }: PropsWithChildren & { pubkey?: string }) { +// const account = useCurrentAccount(); +// const contacts = useUserContactList(pubkey || account?.pubkey); - for (const pubkey of people) { - directory.push(pubkey); - } - return directory; - }, [contacts]); +// 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 {children}; +// } + +export function AllUserDirectoryProvider({ children }: PropsWithChildren) { + const getDirectory = useCallback(async () => { + return await db.getAll("userSearch"); + }, []); return {children}; } diff --git a/src/services/db/index.ts b/src/services/db/index.ts index 0c23ebc35..2e878512e 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -1,9 +1,9 @@ import { openDB, deleteDB, IDBPDatabase } from "idb"; -import { SchemaV1, SchemaV2, SchemaV3 } from "./schema"; +import { SchemaV1, SchemaV2, SchemaV3, SchemaV4 } from "./schema"; const dbName = "storage"; -const version = 3; -const db = await openDB(dbName, version, { +const version = 4; +const db = await openDB(dbName, version, { upgrade(db, oldVersion, newVersion, transaction, event) { if (oldVersion < 1) { const v0 = db as unknown as IDBPDatabase; @@ -71,6 +71,19 @@ const db = await openDB(dbName, version, { }); settings.createIndex("created", "created"); } + + if (oldVersion < 4) { + const v3 = db as unknown as IDBPDatabase; + const v4 = db as unknown as IDBPDatabase; + + // rename the tables + v3.deleteObjectStore("userFollows"); + + // create new search table + v4.createObjectStore("userSearch", { + keyPath: "pubkey", + }); + } }, }); diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index aabc91b95..97a5b1f53 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -77,3 +77,18 @@ export interface SchemaV3 { relayScoreboardStats: SchemaV2["relayScoreboardStats"]; 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"]; +} diff --git a/src/services/user-metadata.ts b/src/services/user-metadata.ts index 051ab793d..1880c9963 100644 --- a/src/services/user-metadata.ts +++ b/src/services/user-metadata.ts @@ -1,27 +1,24 @@ import db from "./db"; +import { Kind } from "nostr-tools"; +import _throttle from "lodash.throttle"; + 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 Subject from "../classes/subject"; import replaceableEventLoaderService, { RequestOptions } from "./replaceable-event-requester"; -import { Kind } from "nostr-tools"; class UserMetadataService { - // requester: CachedPubkeyEventRequester; - // constructor() { - // this.requester = new CachedPubkeyEventRequester(0, "user-metadata"); - // this.requester.readCache = this.readCache; - // this.requester.writeCache = this.writeCache; - // } - - readCache(pubkey: string) { - return db.get("userMetadata", pubkey); - } - writeCache(pubkey: string, event: NostrEvent) { - return db.put("userMetadata", event); - } - - private parsedSubjects = new SuperMap>(() => new Subject()); + private parsedSubjects = new SuperMap>((pubkey) => { + const sub = new Subject(); + sub.subscribe((metadata) => { + if (metadata) { + this.writeSearchQueue.add(pubkey); + this.writeSearchDataThrottle(); + } + }); + return sub; + }); getSubject(pubkey: string) { return this.parsedSubjects.get(pubkey); } @@ -35,6 +32,26 @@ class UserMetadataService { receiveEvent(event: NostrEvent) { replaceableEventLoaderService.handleEvent(event); } + + private writeSearchQueue = new Set(); + 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();