Skip to content

Commit

Permalink
improve local user search
Browse files Browse the repository at this point in the history
  • Loading branch information
hzrd149 committed Oct 17, 2023
1 parent 0804ee3 commit 0e1cbfc
Show file tree
Hide file tree
Showing 10 changed files with 196 additions and 56 deletions.
21 changes: 19 additions & 2 deletions src/components/layout/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<>
Expand Down Expand Up @@ -47,6 +63,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<Spacer display={["none", null, "block"]} />
</Flex>
{isGhost && <GhostToolbar />}
{searchModal.isOpen && <SearchModal isOpen onClose={searchModal.onClose} />}
</>
);
}
20 changes: 2 additions & 18 deletions src/components/magic-textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<HTMLTextAreaElement>
Expand All @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions src/components/search-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
8 changes: 7 additions & 1 deletion src/const.ts
Original file line number Diff line number Diff line change
@@ -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";
6 changes: 6 additions & 0 deletions src/helpers/user-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
6 changes: 3 additions & 3 deletions src/providers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -39,9 +39,9 @@ export function PageProviders({ children }: { children: React.ReactNode }) {
<NotificationTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<UserContactsUserDirectoryProvider>
<AllUserDirectoryProvider>
<PostModalProvider>{children}</PostModalProvider>
</UserContactsUserDirectoryProvider>
</AllUserDirectoryProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</NotificationTimelineProvider>
Expand Down
49 changes: 37 additions & 12 deletions src/providers/user-directory-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,53 @@ 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> | UserDirectory;
const UserDirectoryContext = createContext<GetDirectoryFn>(async () => []);

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 <UserDirectoryProvider getDirectory={getDirectory}>{children}</UserDirectoryProvider>;
// }

export function AllUserDirectoryProvider({ children }: PropsWithChildren) {
const getDirectory = useCallback(async () => {
return await db.getAll("userSearch");
}, []);

return <UserDirectoryProvider getDirectory={getDirectory}>{children}</UserDirectoryProvider>;
}
Expand Down
19 changes: 16 additions & 3 deletions src/services/db/index.ts
Original file line number Diff line number Diff line change
@@ -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<SchemaV3>(dbName, version, {
const version = 4;
const db = await openDB<SchemaV4>(dbName, version, {
upgrade(db, oldVersion, newVersion, transaction, event) {
if (oldVersion < 1) {
const v0 = db as unknown as IDBPDatabase<SchemaV1>;
Expand Down Expand Up @@ -71,6 +71,19 @@ const db = await openDB<SchemaV3>(dbName, version, {
});
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",
});
}
},
});

Expand Down
15 changes: 15 additions & 0 deletions src/services/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
}
51 changes: 34 additions & 17 deletions src/services/user-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<string, Subject<Kind0ParsedContent>>(() => new Subject<Kind0ParsedContent>());
private parsedSubjects = new SuperMap<string, Subject<Kind0ParsedContent>>((pubkey) => {
const sub = new Subject<Kind0ParsedContent>();
sub.subscribe((metadata) => {
if (metadata) {
this.writeSearchQueue.add(pubkey);
this.writeSearchDataThrottle();
}
});
return sub;
});
getSubject(pubkey: string) {
return this.parsedSubjects.get(pubkey);
}
Expand All @@ -35,6 +32,26 @@ class UserMetadataService {
receiveEvent(event: NostrEvent) {
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();
Expand Down

0 comments on commit 0e1cbfc

Please sign in to comment.