mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-11 05:09:36 +02:00
improve local user search
This commit is contained in:
parent
0804ee33b4
commit
0e1cbfcb30
@ -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 }) {
|
||||
<Spacer display={["none", null, "block"]} />
|
||||
</Flex>
|
||||
{isGhost && <GhostToolbar />}
|
||||
{searchModal.isOpen && <SearchModal isOpen onClose={searchModal.onClose} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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<HTMLTextAreaElement>
|
||||
@ -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,
|
||||
|
57
src/components/search-modal/index.tsx
Normal file
57
src/components/search-modal/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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";
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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 }) {
|
||||
<NotificationTimelineProvider>
|
||||
<DefaultEmojiProvider>
|
||||
<UserEmojiProvider>
|
||||
<UserContactsUserDirectoryProvider>
|
||||
<AllUserDirectoryProvider>
|
||||
<PostModalProvider>{children}</PostModalProvider>
|
||||
</UserContactsUserDirectoryProvider>
|
||||
</AllUserDirectoryProvider>
|
||||
</UserEmojiProvider>
|
||||
</DefaultEmojiProvider>
|
||||
</NotificationTimelineProvider>
|
||||
|
@ -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> | UserDirectory;
|
||||
const UserDirectoryContext = createContext<GetDirectoryFn>(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;
|
||||
// }
|
||||
|
||||
// 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 people = contacts ? getPubkeysFromList(contacts).map((p) => p.pubkey) : [];
|
||||
const directory: UserDirectory = [];
|
||||
|
||||
for (const pubkey of people) {
|
||||
directory.push(pubkey);
|
||||
}
|
||||
return directory;
|
||||
}, [contacts]);
|
||||
return await db.getAll("userSearch");
|
||||
}, []);
|
||||
|
||||
return <UserDirectoryProvider getDirectory={getDirectory}>{children}</UserDirectoryProvider>;
|
||||
}
|
||||
|
@ -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>;
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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"];
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
|
Loading…
x
Reference in New Issue
Block a user