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();