From 32549522d48daf082bebe697619494ece7c29fd0 Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 3 Mar 2023 14:30:31 +0000 Subject: [PATCH] feat: improve profile cache (again) --- packages/app/src/Db/index.ts | 16 ++ packages/app/src/Element/Avatar.tsx | 2 + packages/app/src/Element/Bookmarks.tsx | 5 +- packages/app/src/Element/DM.tsx | 2 +- packages/app/src/Element/Mention.tsx | 2 +- packages/app/src/Element/Nip5Service.tsx | 2 +- packages/app/src/Element/Note.tsx | 27 +-- packages/app/src/Element/NoteFooter.tsx | 2 +- packages/app/src/Element/ProfileImage.tsx | 2 +- packages/app/src/Element/ProfilePreview.tsx | 2 +- packages/app/src/Element/Text.tsx | 9 +- packages/app/src/Element/Textarea.tsx | 11 +- packages/app/src/Element/Zap.tsx | 2 +- packages/app/src/Element/ZapButton.tsx | 2 +- packages/app/src/Feed/LoginFeed.ts | 36 +--- packages/app/src/Feed/ProfileFeed.ts | 31 ---- packages/app/src/Hooks/useUserProfile.ts | 21 +++ packages/app/src/Notifications.ts | 17 +- packages/app/src/Pages/Layout.tsx | 18 +- packages/app/src/Pages/ProfilePage.tsx | 3 +- packages/app/src/Pages/SearchPage.tsx | 6 +- packages/app/src/Pages/new/GetVerified.tsx | 2 +- packages/app/src/Pages/settings/Profile.tsx | 2 +- packages/app/src/State/Login.ts | 11 +- packages/app/src/State/Store.ts | 2 - packages/app/src/State/Users.ts | 75 -------- packages/app/src/State/Users/Db.ts | 191 -------------------- packages/app/src/State/Users/Hooks.ts | 20 -- packages/app/src/State/Users/UserCache.ts | 145 +++++++++++++++ packages/app/src/State/Users/index.ts | 39 ++++ packages/app/src/System.ts | 81 ++++----- packages/app/src/Tasks/TaskList.tsx | 2 +- 32 files changed, 316 insertions(+), 472 deletions(-) delete mode 100644 packages/app/src/Feed/ProfileFeed.ts create mode 100644 packages/app/src/Hooks/useUserProfile.ts delete mode 100644 packages/app/src/State/Users.ts delete mode 100644 packages/app/src/State/Users/Db.ts delete mode 100644 packages/app/src/State/Users/Hooks.ts create mode 100644 packages/app/src/State/Users/UserCache.ts create mode 100644 packages/app/src/State/Users/index.ts diff --git a/packages/app/src/Db/index.ts b/packages/app/src/Db/index.ts index eb870f26..7a3b04eb 100644 --- a/packages/app/src/Db/index.ts +++ b/packages/app/src/Db/index.ts @@ -17,12 +17,28 @@ const STORES = { }; export class SnortDB extends Dexie { + ready = false; users!: Table; constructor() { super(NAME); this.version(VERSION).stores(STORES); } + + isAvailable() { + if ("indexedDB" in window) { + return new Promise(resolve => { + const req = window.indexedDB.open("dummy", 1); + req.onsuccess = () => { + resolve(true); + }; + req.onerror = () => { + resolve(false); + }; + }); + } + return Promise.resolve(false); + } } export const db = new SnortDB(); diff --git a/packages/app/src/Element/Avatar.tsx b/packages/app/src/Element/Avatar.tsx index c7de323c..b656fcf3 100644 --- a/packages/app/src/Element/Avatar.tsx +++ b/packages/app/src/Element/Avatar.tsx @@ -1,7 +1,9 @@ import "./Avatar.css"; import Nostrich from "nostrich.webp"; + import { CSSProperties, useEffect, useState } from "react"; import type { UserMetadata } from "@snort/nostr"; + import useImgProxy from "Hooks/useImgProxy"; const Avatar = ({ user, ...rest }: { user?: UserMetadata; onClick?: () => void }) => { diff --git a/packages/app/src/Element/Bookmarks.tsx b/packages/app/src/Element/Bookmarks.tsx index fff74b6e..e9627ba6 100644 --- a/packages/app/src/Element/Bookmarks.tsx +++ b/packages/app/src/Element/Bookmarks.tsx @@ -5,8 +5,8 @@ import { FormattedMessage } from "react-intl"; import { dedupeByPubkey } from "Util"; import Note from "Element/Note"; import { HexKey, TaggedRawEvent } from "@snort/nostr"; -import { useUserProfiles } from "Feed/ProfileFeed"; import { RootState } from "State/Store"; +import { UserCache } from "State/Users/UserCache"; import messages from "./messages"; @@ -22,10 +22,9 @@ const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => { const ps = useMemo(() => { return dedupeByPubkey(bookmarks).map(ev => ev.pubkey); }, [bookmarks]); - const profiles = useUserProfiles(ps); function renderOption(p: HexKey) { - const profile = profiles?.get(p); + const profile = UserCache.get(p); return profile ? : null; } diff --git a/packages/app/src/Element/DM.tsx b/packages/app/src/Element/DM.tsx index 3b802253..ddadb92d 100644 --- a/packages/app/src/Element/DM.tsx +++ b/packages/app/src/Element/DM.tsx @@ -54,7 +54,7 @@ export default function DM(props: DMProps) {
- +
); diff --git a/packages/app/src/Element/Mention.tsx b/packages/app/src/Element/Mention.tsx index 088e0baf..be3d153e 100644 --- a/packages/app/src/Element/Mention.tsx +++ b/packages/app/src/Element/Mention.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { Link } from "react-router-dom"; -import { useUserProfile } from "Feed/ProfileFeed"; +import { useUserProfile } from "Hooks/useUserProfile"; import { HexKey } from "@snort/nostr"; import { hexToBech32, profileLink } from "Util"; diff --git a/packages/app/src/Element/Nip5Service.tsx b/packages/app/src/Element/Nip5Service.tsx index d377c0e5..8f10835d 100644 --- a/packages/app/src/Element/Nip5Service.tsx +++ b/packages/app/src/Element/Nip5Service.tsx @@ -17,7 +17,7 @@ import { import AsyncButton from "Element/AsyncButton"; import SendSats from "Element/SendSats"; import Copy from "Element/Copy"; -import { useUserProfile } from "Feed/ProfileFeed"; +import { useUserProfile } from "Hooks/useUserProfile"; import useEventPublisher from "Feed/EventPublisher"; import { debounce } from "Util"; import { UserMetadata } from "@snort/nostr"; diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index fc907c19..fd71a555 100644 --- a/packages/app/src/Element/Note.tsx +++ b/packages/app/src/Element/Note.tsx @@ -18,14 +18,15 @@ import { hexToBech32, normalizeReaction, Reaction, + profileLink, } from "Util"; import NoteFooter, { Translation } from "Element/NoteFooter"; import NoteTime from "Element/NoteTime"; -import { useUserProfiles } from "Feed/ProfileFeed"; import { TaggedRawEvent, u256, HexKey, Event as NEvent, EventKind } from "@snort/nostr"; import useModeration from "Hooks/useModeration"; import { setPinned, setBookmarked } from "State/Login"; import type { RootState } from "State/Store"; +import { UserCache } from "State/Users/UserCache"; import messages from "./messages"; @@ -72,8 +73,6 @@ export default function Note(props: NoteProps) { const { data, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false } = props; const [showReactions, setShowReactions] = useState(false); const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]); - const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); - const users = useUserProfiles(pubKeys); const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); const { isMuted } = useModeration(); const isOpMuted = isMuted(ev.PubKey); @@ -162,7 +161,7 @@ export default function Note(props: NoteProps) { ); } - return ; + return ; }, [ev]); useLayoutEffect(() => { @@ -188,22 +187,14 @@ export default function Note(props: NoteProps) { const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; const mentions: { pk: string; name: string; link: ReactNode }[] = []; for (const pk of ev.Thread?.PubKeys ?? []) { - const u = users?.get(pk); + const u = UserCache.get(pk); const npub = hexToBech32("npub", pk); const shortNpub = npub.substring(0, 12); - if (u) { - mentions.push({ - pk, - name: u.name ?? shortNpub, - link: {u.name ? `@${u.name}` : shortNpub}, - }); - } else { - mentions.push({ - pk, - name: shortNpub, - link: {shortNpub}, - }); - } + mentions.push({ + pk, + name: u?.name ?? shortNpub, + link: {u?.name ? `@${u.name}` : shortNpub}, + }); } mentions.sort(a => (a.name.startsWith("npub") ? 1 : -1)); const othersLength = mentions.length - maxMentions; diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx index 4d4167c3..c55790a0 100644 --- a/packages/app/src/Element/NoteFooter.tsx +++ b/packages/app/src/Element/NoteFooter.tsx @@ -15,7 +15,7 @@ import { NoteCreator } from "Element/NoteCreator"; import Reactions from "Element/Reactions"; import SendSats from "Element/SendSats"; import { ParsedZap, ZapsSummary } from "Element/Zap"; -import { useUserProfile } from "Feed/ProfileFeed"; +import { useUserProfile } from "Hooks/useUserProfile"; import { RootState } from "State/Store"; import { UserPreferences, setPinned, setBookmarked } from "State/Login"; import useModeration from "Hooks/useModeration"; diff --git a/packages/app/src/Element/ProfileImage.tsx b/packages/app/src/Element/ProfileImage.tsx index 155e3181..b382db19 100644 --- a/packages/app/src/Element/ProfileImage.tsx +++ b/packages/app/src/Element/ProfileImage.tsx @@ -2,7 +2,7 @@ import "./ProfileImage.css"; import { useMemo } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { useUserProfile } from "Feed/ProfileFeed"; +import { useUserProfile } from "Hooks/useUserProfile"; import { hexToBech32, profileLink } from "Util"; import Avatar from "Element/Avatar"; import Nip05 from "Element/Nip05"; diff --git a/packages/app/src/Element/ProfilePreview.tsx b/packages/app/src/Element/ProfilePreview.tsx index ff94f6a5..b7db72f3 100644 --- a/packages/app/src/Element/ProfilePreview.tsx +++ b/packages/app/src/Element/ProfilePreview.tsx @@ -3,7 +3,7 @@ import { ReactNode } from "react"; import ProfileImage from "Element/ProfileImage"; import FollowButton from "Element/FollowButton"; -import { useUserProfile } from "Feed/ProfileFeed"; +import { useUserProfile } from "Hooks/useUserProfile"; import { HexKey } from "@snort/nostr"; import { useInView } from "react-intersection-observer"; diff --git a/packages/app/src/Element/Text.tsx b/packages/app/src/Element/Text.tsx index ae9c87ae..cd6aacc6 100644 --- a/packages/app/src/Element/Text.tsx +++ b/packages/app/src/Element/Text.tsx @@ -10,7 +10,6 @@ import Invoice from "Element/Invoice"; import Hashtag from "Element/Hashtag"; import { Tag } from "@snort/nostr"; -import { MetadataCache } from "State/Users"; import Mention from "Element/Mention"; import HyperText from "Element/HyperText"; import { HexKey } from "@snort/nostr"; @@ -21,17 +20,15 @@ export type Fragment = string | React.ReactNode; export interface TextFragment { body: React.ReactNode[]; tags: Tag[]; - users: Map; } export interface TextProps { content: string; creator: HexKey; tags: Tag[]; - users: Map; } -export default function Text({ content, tags, creator, users }: TextProps) { +export default function Text({ content, tags, creator }: TextProps) { function extractLinks(fragments: Fragment[]) { return fragments .map(f => { @@ -143,9 +140,9 @@ export default function Text({ content, tags, creator, users }: TextProps) { const components = useMemo(() => { return { - p: (x: { children?: React.ReactNode[] }) => transformParagraph({ body: x.children ?? [], tags, users }), + p: (x: { children?: React.ReactNode[] }) => transformParagraph({ body: x.children ?? [], tags }), a: (x: { href?: string }) => , - li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags, users }), + li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags }), }; }, [content]); diff --git a/packages/app/src/Element/Textarea.tsx b/packages/app/src/Element/Textarea.tsx index 0c012758..60ba2a3b 100644 --- a/packages/app/src/Element/Textarea.tsx +++ b/packages/app/src/Element/Textarea.tsx @@ -1,7 +1,6 @@ import "@webscopeio/react-textarea-autocomplete/style.css"; import "./Textarea.css"; -import { useState } from "react"; import { useIntl } from "react-intl"; import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete"; import emoji from "@jukben/emoji-search"; @@ -11,7 +10,7 @@ import Avatar from "Element/Avatar"; import Nip05 from "Element/Nip05"; import { hexToBech32 } from "Util"; import { MetadataCache } from "State/Users"; -import { useQuery } from "State/Users/Hooks"; +import { UserCache } from "State/Users/UserCache"; import messages from "./messages"; @@ -53,14 +52,10 @@ interface TextareaProps { } const Textarea = (props: TextareaProps) => { - const [query, setQuery] = useState(""); const { formatMessage } = useIntl(); - const allUsers = useQuery(query); - - const userDataProvider = (token: string) => { - setQuery(token); - return allUsers ?? []; + const userDataProvider = async (token: string) => { + return await UserCache.search(token); }; const emojiDataProvider = (token: string) => { diff --git a/packages/app/src/Element/Zap.tsx b/packages/app/src/Element/Zap.tsx index 823c20db..5e403d2a 100644 --- a/packages/app/src/Element/Zap.tsx +++ b/packages/app/src/Element/Zap.tsx @@ -109,7 +109,7 @@ const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean {content.length > 0 && zapper && (
- +
)} diff --git a/packages/app/src/Element/ZapButton.tsx b/packages/app/src/Element/ZapButton.tsx index b910d871..48eb8cc8 100644 --- a/packages/app/src/Element/ZapButton.tsx +++ b/packages/app/src/Element/ZapButton.tsx @@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useState } from "react"; import { HexKey } from "@snort/nostr"; -import { useUserProfile } from "Feed/ProfileFeed"; +import { useUserProfile } from "Hooks/useUserProfile"; import SendSats from "Element/SendSats"; const ZapButton = ({ pubkey, lnurl }: { pubkey: HexKey; lnurl?: string }) => { diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index 752791d2..537d8d45 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -18,14 +18,11 @@ import { setLatestNotifications, } from "State/Login"; import { RootState } from "State/Store"; -import { mapEventToProfile, MetadataCache } from "State/Users"; import useSubscription from "Feed/Subscription"; import { barrierNip07 } from "Feed/EventPublisher"; import { getMutedKeys } from "Feed/MuteList"; import useModeration from "Hooks/useModeration"; -import { unwrap } from "Util"; import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit"; -import { ReduxUDB } from "State/Users/Db"; /** * Managed loading data for the current logged in user @@ -46,7 +43,7 @@ export default function useLoginFeed() { const sub = new Subscriptions(); sub.Id = `login:meta`; sub.Authors = new Set([pubKey]); - sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]); + sub.Kinds = new Set([EventKind.ContactList]); sub.Limit = 2; return sub; @@ -148,12 +145,6 @@ export default function useLoginFeed() { useEffect(() => { const contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList); - const metadata = metadataFeed.store.notes.filter(a => a.kind === EventKind.SetMetadata); - const profiles = metadata - .map(a => mapEventToProfile(a)) - .filter(a => a !== undefined) - .map(a => unwrap(a)); - for (const cl of contactList) { if (cl.content !== "" && cl.content !== "{}") { const relays = JSON.parse(cl.content); @@ -162,26 +153,7 @@ export default function useLoginFeed() { const pTags = cl.tags.filter(a => a[0] === "p").map(a => a[1]); dispatch(setFollows({ keys: pTags, createdAt: cl.created_at })); } - - (async () => { - const maxProfile = profiles.reduce( - (acc, v) => { - if (v.created > acc.created) { - acc.profile = v; - acc.created = v.created; - } - return acc; - }, - { created: 0, profile: null as MetadataCache | null } - ); - if (maxProfile.profile) { - const existing = await ReduxUDB.find(maxProfile.profile.pubkey); - if ((existing?.created ?? 0) < maxProfile.created) { - await ReduxUDB.put(maxProfile.profile); - } - } - })().catch(console.warn); - }, [dispatch, metadataFeed.store, ReduxUDB]); + }, [dispatch, metadataFeed.store]); useEffect(() => { const replies = notificationFeed.store.notes.filter( @@ -189,13 +161,13 @@ export default function useLoginFeed() { ); replies.forEach(nx => { dispatch(setLatestNotifications(nx.created_at)); - makeNotification(ReduxUDB, nx).then(notification => { + makeNotification(nx).then(notification => { if (notification) { (dispatch as ThunkDispatch)(sendNotification(notification)); } }); }); - }, [dispatch, notificationFeed.store, ReduxUDB, readNotifications]); + }, [dispatch, notificationFeed.store, readNotifications]); useEffect(() => { const muted = getMutedKeys(mutedFeed.store.notes); diff --git a/packages/app/src/Feed/ProfileFeed.ts b/packages/app/src/Feed/ProfileFeed.ts deleted file mode 100644 index a0b7d12f..00000000 --- a/packages/app/src/Feed/ProfileFeed.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffect } from "react"; -import { MetadataCache } from "State/Users"; -import { useKey, useKeys } from "State/Users/Hooks"; -import { HexKey } from "@snort/nostr"; -import { System } from "System"; - -export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined { - const users = useKey(pubKey); - - useEffect(() => { - if (pubKey) { - System.TrackMetadata(pubKey); - return () => System.UntrackMetadata(pubKey); - } - }, [pubKey]); - - return users; -} - -export function useUserProfiles(pubKeys?: Array): Map | undefined { - const users = useKeys(pubKeys); - - useEffect(() => { - if (pubKeys) { - System.TrackMetadata(pubKeys); - return () => System.UntrackMetadata(pubKeys); - } - }, [pubKeys]); - - return users; -} diff --git a/packages/app/src/Hooks/useUserProfile.ts b/packages/app/src/Hooks/useUserProfile.ts new file mode 100644 index 00000000..da5f80f8 --- /dev/null +++ b/packages/app/src/Hooks/useUserProfile.ts @@ -0,0 +1,21 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { MetadataCache } from "State/Users"; +import { HexKey } from "@snort/nostr"; +import { System } from "System"; +import { UserCache } from "State/Users/UserCache"; + +export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined { + const user = useSyncExternalStore( + h => UserCache.hook(h, pubKey), + () => UserCache.get(pubKey) + ); + + useEffect(() => { + if (pubKey) { + System.TrackMetadata(pubKey); + return () => System.UntrackMetadata(pubKey); + } + }, [pubKey]); + + return user; +} diff --git a/packages/app/src/Notifications.ts b/packages/app/src/Notifications.ts index 09c5a8f0..d070abab 100644 --- a/packages/app/src/Notifications.ts +++ b/packages/app/src/Notifications.ts @@ -3,25 +3,30 @@ import Nostrich from "nostrich.webp"; import { TaggedRawEvent } from "@snort/nostr"; import { EventKind } from "@snort/nostr"; import type { NotificationRequest } from "State/Login"; -import { MetadataCache, UsersDb } from "State/Users"; +import { MetadataCache } from "State/Users"; import { getDisplayName } from "Element/ProfileImage"; import { MentionRegex } from "Const"; -import { tagFilterOfTextRepost } from "Util"; +import { tagFilterOfTextRepost, unwrap } from "Util"; +import { UserCache } from "State/Users/UserCache"; -export async function makeNotification(db: UsersDb, ev: TaggedRawEvent): Promise { +export async function makeNotification(ev: TaggedRawEvent): Promise { switch (ev.kind) { case EventKind.TextNote: { if (ev.tags.some(tagFilterOfTextRepost(ev))) { return null; } const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1])]); - const users = await db.bulkGet(Array.from(pubkeys)); - const fromUser = users.find(a => a?.pubkey === ev.pubkey); + await UserCache.buffer([...pubkeys]); + const allUsers = [...pubkeys] + .map(a => UserCache.get(a)) + .filter(a => a) + .map(a => unwrap(a)); + const fromUser = UserCache.get(ev.pubkey); const name = getDisplayName(fromUser, ev.pubkey); const avatarUrl = fromUser?.picture || Nostrich; return { title: `Reply from ${name}`, - body: replaceTagsWithUser(ev, users).substring(0, 50), + body: replaceTagsWithUser(ev, allUsers).substring(0, 50), icon: avatarUrl, timestamp: ev.created_at * 1000, }; diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index 56914c05..44a9db32 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -14,12 +14,13 @@ import { totalUnread } from "Pages/MessagesPage"; import { SearchRelays, SnortPubKey } from "Const"; import useEventPublisher from "Feed/EventPublisher"; import useModeration from "Hooks/useModeration"; -import { IndexedUDB } from "State/Users/Db"; import { bech32ToHex } from "Util"; import { NoteCreator } from "Element/NoteCreator"; import { RelaySettings } from "@snort/nostr"; import { FormattedMessage } from "react-intl"; import messages from "./messages"; +import { db } from "Db"; +import { UserCache } from "State/Users/UserCache"; export default function Layout() { const location = useLocation(); @@ -119,16 +120,13 @@ export default function Layout() { useEffect(() => { // check DB support then init - IndexedUDB.isAvailable().then(async a => { - const dbType = a ? "indexdDb" : "redux"; - - // cleanup on load - if (dbType === "indexdDb") { - IndexedUDB.ready = true; + db.isAvailable().then(async a => { + db.ready = a; + if (a) { + await UserCache.preload(); } - - console.debug(`Using db: ${dbType}`); - dispatch(init(dbType)); + console.debug(`Using db: ${a ? "IndexedDB" : "In-Memory"}`); + dispatch(init()); try { if ("registerProtocolHandler" in window.navigator) { diff --git a/packages/app/src/Pages/ProfilePage.tsx b/packages/app/src/Pages/ProfilePage.tsx index 86d40294..98155a6e 100644 --- a/packages/app/src/Pages/ProfilePage.tsx +++ b/packages/app/src/Pages/ProfilePage.tsx @@ -18,7 +18,7 @@ import usePinnedFeed from "Feed/PinnedFeed"; import useBookmarkFeed from "Feed/BookmarkFeed"; import useFollowersFeed from "Feed/FollowersFeed"; import useFollowsFeed from "Feed/FollowsFeed"; -import { useUserProfile } from "Feed/ProfileFeed"; +import { useUserProfile } from "Hooks/useUserProfile"; import useModeration from "Hooks/useModeration"; import useZapsFeed from "Feed/ZapsFeed"; import { default as ZapElement } from "Element/Zap"; @@ -69,7 +69,6 @@ export default function ProfilePage() { const about = Text({ content: aboutText, tags: [], - users: new Map(), creator: "", }); const npub = !id?.startsWith("npub") ? hexToBech32("npub", id || undefined) : id; diff --git a/packages/app/src/Pages/SearchPage.tsx b/packages/app/src/Pages/SearchPage.tsx index 999f92df..1b86bfd0 100644 --- a/packages/app/src/Pages/SearchPage.tsx +++ b/packages/app/src/Pages/SearchPage.tsx @@ -7,7 +7,8 @@ import { debounce } from "Util"; import { router } from "index"; import { SearchRelays } from "Const"; import { System } from "System"; -import { useQuery } from "State/Users/Hooks"; +import { MetadataCache } from "State/Users"; +import { UserCache } from "State/Users/UserCache"; import messages from "./messages"; @@ -16,12 +17,13 @@ const SearchPage = () => { const { formatMessage } = useIntl(); const [search, setSearch] = useState(); const [keyword, setKeyword] = useState(params.keyword); - const allUsers = useQuery(keyword || ""); + const [allUsers, setAllUsers] = useState(); useEffect(() => { if (keyword) { // "navigate" changing only url router.navigate(`/search/${encodeURIComponent(keyword)}`); + UserCache.search(keyword).then(v => setAllUsers(v)); } }, [keyword]); diff --git a/packages/app/src/Pages/new/GetVerified.tsx b/packages/app/src/Pages/new/GetVerified.tsx index dd15cab0..79265e38 100644 --- a/packages/app/src/Pages/new/GetVerified.tsx +++ b/packages/app/src/Pages/new/GetVerified.tsx @@ -8,7 +8,7 @@ import { services } from "Pages/Verification"; import Nip5Service from "Element/Nip5Service"; import ProfileImage from "Element/ProfileImage"; import type { RootState } from "State/Store"; -import { useUserProfile } from "Feed/ProfileFeed"; +import { useUserProfile } from "Hooks/useUserProfile"; import messages from "./messages"; diff --git a/packages/app/src/Pages/settings/Profile.tsx b/packages/app/src/Pages/settings/Profile.tsx index b67f9bfb..f3d2e862 100644 --- a/packages/app/src/Pages/settings/Profile.tsx +++ b/packages/app/src/Pages/settings/Profile.tsx @@ -8,7 +8,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faShop } from "@fortawesome/free-solid-svg-icons"; import useEventPublisher from "Feed/EventPublisher"; -import { useUserProfile } from "Feed/ProfileFeed"; +import { useUserProfile } from "Hooks/useUserProfile"; import { hexToBech32, openFile } from "Util"; import Copy from "Element/Copy"; import { RootState } from "State/Store"; diff --git a/packages/app/src/State/Login.ts b/packages/app/src/State/Login.ts index cf77e293..07861231 100644 --- a/packages/app/src/State/Login.ts +++ b/packages/app/src/State/Login.ts @@ -82,14 +82,7 @@ export interface UserPreferences { defaultZapAmount: number; } -export type DbType = "indexdDb" | "redux"; - export interface LoginStore { - /** - * Which db we will use to cache data - */ - useDb: DbType; - /** * If there is no login */ @@ -208,7 +201,6 @@ export const DefaultImgProxy = { }; export const InitState = { - useDb: "redux", loggedOut: undefined, publicKey: undefined, privateKey: undefined, @@ -267,8 +259,7 @@ const LoginSlice = createSlice({ name: "Login", initialState: InitState, reducers: { - init: (state, action: PayloadAction) => { - state.useDb = action.payload; + init: state => { state.privateKey = window.localStorage.getItem(PrivateKeyItem) ?? undefined; if (state.privateKey) { window.localStorage.removeItem(PublicKeyItem); // reset nip07 if using private key diff --git a/packages/app/src/State/Store.ts b/packages/app/src/State/Store.ts index 196dad23..6d8e7bd9 100644 --- a/packages/app/src/State/Store.ts +++ b/packages/app/src/State/Store.ts @@ -1,12 +1,10 @@ import { configureStore } from "@reduxjs/toolkit"; import { reducer as LoginReducer } from "State/Login"; -import { reducer as UsersReducer } from "State/Users"; import { reducer as CacheReducer } from "State/Cache"; const store = configureStore({ reducer: { login: LoginReducer, - users: UsersReducer, cache: CacheReducer, }, }); diff --git a/packages/app/src/State/Users.ts b/packages/app/src/State/Users.ts deleted file mode 100644 index f49dab15..00000000 --- a/packages/app/src/State/Users.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { HexKey, TaggedRawEvent, UserMetadata } from "@snort/nostr"; -import { hexToBech32 } from "../Util"; - -export interface MetadataCache extends UserMetadata { - /** - * When the object was saved in cache - */ - loaded: number; - - /** - * When the source metadata event was created - */ - created: number; - - /** - * The pubkey of the owner of this metadata - */ - pubkey: HexKey; - - /** - * The bech32 encoded pubkey - */ - npub: string; -} - -export function mapEventToProfile(ev: TaggedRawEvent) { - try { - const data: UserMetadata = JSON.parse(ev.content); - return { - pubkey: ev.pubkey, - npub: hexToBech32("npub", ev.pubkey), - created: ev.created_at, - loaded: new Date().getTime(), - ...data, - } as MetadataCache; - } catch (e) { - console.error("Failed to parse JSON", ev, e); - } -} - -export interface UsersDb { - isAvailable(): Promise; - query(str: string): Promise; - find(key: HexKey): Promise; - add(user: MetadataCache): Promise; - put(user: MetadataCache): Promise; - bulkAdd(users: MetadataCache[]): Promise; - bulkGet(keys: HexKey[]): Promise; - bulkPut(users: MetadataCache[]): Promise; - update(key: HexKey, fields: Record): Promise; -} - -export interface UsersStore { - /** - * A list of seen users - */ - users: Record; -} - -const InitState = { users: {} } as UsersStore; - -const UsersSlice = createSlice({ - name: "Users", - initialState: InitState, - reducers: { - setUsers(state, action: PayloadAction>) { - state.users = action.payload; - }, - }, -}); - -export const { setUsers } = UsersSlice.actions; - -export const reducer = UsersSlice.reducer; diff --git a/packages/app/src/State/Users/Db.ts b/packages/app/src/State/Users/Db.ts deleted file mode 100644 index 9201e6f7..00000000 --- a/packages/app/src/State/Users/Db.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { HexKey } from "@snort/nostr"; -import { db as idb } from "Db"; - -import { UsersDb, MetadataCache, setUsers } from "State/Users"; -import store from "State/Store"; -import { groupByPubkey, unixNowMs, unwrap } from "Util"; - -class IndexedUsersDb implements UsersDb { - ready = false; - - isAvailable() { - if ("indexedDB" in window) { - return new Promise(resolve => { - const req = window.indexedDB.open("dummy", 1); - req.onsuccess = () => { - resolve(true); - }; - req.onerror = () => { - resolve(false); - }; - }); - } - return Promise.resolve(false); - } - - find(key: HexKey) { - return idb.users.get(key); - } - - query(q: string) { - return idb.users - .where("npub") - .startsWithIgnoreCase(q) - .or("name") - .startsWithIgnoreCase(q) - .or("display_name") - .startsWithIgnoreCase(q) - .or("nip05") - .startsWithIgnoreCase(q) - .limit(5) - .toArray(); - } - - async bulkGet(keys: HexKey[]) { - const ret = await idb.users.bulkGet(keys); - return ret.filter(a => a !== undefined).map(a_1 => unwrap(a_1)); - } - - async add(user: MetadataCache) { - await idb.users.add(user); - } - - async put(user: MetadataCache) { - await idb.users.put(user); - } - - async bulkAdd(users: MetadataCache[]) { - await idb.users.bulkAdd(users); - } - - async bulkPut(users: MetadataCache[]) { - await idb.users.bulkPut(users); - } - - async update(key: HexKey, fields: Record) { - await idb.users.update(key, fields); - } -} - -export const IndexedUDB = new IndexedUsersDb(); - -class ReduxUsersDb implements UsersDb { - async isAvailable() { - return true; - } - - async query(q: string) { - const state = store.getState(); - const { users } = state.users; - return Object.values(users).filter(user => { - const profile = user as MetadataCache; - return ( - profile.name?.includes(q) || - profile.npub?.includes(q) || - profile.display_name?.includes(q) || - profile.nip05?.includes(q) - ); - }); - } - - querySync(q: string) { - const state = store.getState(); - const { users } = state.users; - return Object.values(users).filter(user => { - const profile = user as MetadataCache; - return ( - profile.name?.includes(q) || - profile.npub?.includes(q) || - profile.display_name?.includes(q) || - profile.nip05?.includes(q) - ); - }); - } - - async find(key: HexKey) { - const state = store.getState(); - const { users } = state.users; - let ret: MetadataCache | undefined = users[key]; - if (IndexedUDB.ready && ret === undefined) { - ret = await IndexedUDB.find(key); - if (ret) { - await this.put(ret); - } - } - return ret; - } - - async add(user: MetadataCache) { - const state = store.getState(); - const { users } = state.users; - store.dispatch(setUsers({ ...users, [user.pubkey]: user })); - if (IndexedUDB.ready) { - await IndexedUDB.add(user); - } - } - - async put(user: MetadataCache) { - const state = store.getState(); - const { users } = state.users; - store.dispatch(setUsers({ ...users, [user.pubkey]: user })); - if (IndexedUDB.ready) { - await IndexedUDB.put(user); - } - } - - async bulkAdd(newUserProfiles: MetadataCache[]) { - const state = store.getState(); - const { users } = state.users; - const newUsers = newUserProfiles.reduce(groupByPubkey, {}); - store.dispatch(setUsers({ ...users, ...newUsers })); - if (IndexedUDB.ready) { - await IndexedUDB.bulkAdd(newUserProfiles); - } - } - - async bulkGet(keys: HexKey[]) { - const state = store.getState(); - const { users } = state.users; - const ids = new Set([...keys]); - let ret = Object.values(users).filter(user => { - return ids.has(user.pubkey); - }); - if (IndexedUDB.ready && ret.length !== ids.size) { - const startLoad = unixNowMs(); - const hasKeys = new Set(Object.keys(users)); - const missing = [...ids].filter(a => !hasKeys.has(a)); - const missingFromCache = await IndexedUDB.bulkGet(missing); - store.dispatch(setUsers({ ...users, ...missingFromCache.reduce(groupByPubkey, {}) })); - console.debug( - `Loaded ${missingFromCache.length}/${missing.length} profiles from cache in ${(unixNowMs() - startLoad).toFixed( - 1 - )} ms` - ); - ret = [...ret, ...missingFromCache]; - } - return ret; - } - - async update(key: HexKey, fields: Record) { - const state = store.getState(); - const { users } = state.users; - const current = users[key]; - const updated = { ...current, ...fields }; - store.dispatch(setUsers({ ...users, [key]: updated })); - if (IndexedUDB.ready) { - await IndexedUDB.update(key, fields); - } - } - - async bulkPut(newUsers: MetadataCache[]) { - const state = store.getState(); - const { users } = state.users; - const newProfiles = newUsers.reduce(groupByPubkey, {}); - store.dispatch(setUsers({ ...users, ...newProfiles })); - if (IndexedUDB.ready) { - await IndexedUDB.bulkPut(newUsers); - } - } -} - -export const ReduxUDB = new ReduxUsersDb(); diff --git a/packages/app/src/State/Users/Hooks.ts b/packages/app/src/State/Users/Hooks.ts deleted file mode 100644 index 62b16541..00000000 --- a/packages/app/src/State/Users/Hooks.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useSelector } from "react-redux"; -import { MetadataCache } from "State/Users"; -import type { RootState } from "State/Store"; -import { HexKey } from "@snort/nostr"; -import { ReduxUDB } from "./Db"; - -export function useQuery(query: string) { - // TODO: not observable - return ReduxUDB.querySync(query); -} - -export function useKey(pubKey?: HexKey) { - const { users } = useSelector((state: RootState) => state.users); - return pubKey ? users[pubKey] : undefined; -} - -export function useKeys(pubKeys?: HexKey[]): Map { - const { users } = useSelector((state: RootState) => state.users); - return new Map((pubKeys ?? []).map(a => [a, users[a]])); -} diff --git a/packages/app/src/State/Users/UserCache.ts b/packages/app/src/State/Users/UserCache.ts new file mode 100644 index 00000000..8e0702f8 --- /dev/null +++ b/packages/app/src/State/Users/UserCache.ts @@ -0,0 +1,145 @@ +import { HexKey } from "@snort/nostr"; +import { db } from "Db"; +import { unixNowMs, unwrap } from "Util"; +import { MetadataCache } from "."; + +type HookFn = () => void; + +interface HookFilter { + key: HexKey; + fn: HookFn; +} + +export class UserProfileCache { + #cache: Map; + #hooks: Array; + #diskCache: Set; + + constructor() { + this.#cache = new Map(); + this.#hooks = []; + this.#diskCache = new Set(); + setInterval(() => { + console.debug( + `[UserCache] ${this.#cache.size} loaded, ${this.#diskCache.size} on-disk, ${this.#hooks.length} hooks` + ); + }, 5_000); + } + + async preload() { + if (db.ready) { + const keys = await db.users.toCollection().primaryKeys(); + this.#diskCache = new Set(keys.map(a => a as string)); + } + } + + async search(q: string): Promise> { + if (db.ready) { + // on-disk cache will always have more data + return ( + await db.users + .where("npub") + .startsWithIgnoreCase(q) + .or("name") + .startsWithIgnoreCase(q) + .or("display_name") + .startsWithIgnoreCase(q) + .or("nip05") + .startsWithIgnoreCase(q) + .toArray() + ).slice(0, 5); + } else { + return [...this.#cache.values()] + .filter(user => { + const profile = user as MetadataCache; + return ( + profile.name?.includes(q) || + profile.npub?.includes(q) || + profile.display_name?.includes(q) || + profile.nip05?.includes(q) + ); + }) + .slice(0, 5); + } + } + + hook(fn: HookFn, key: HexKey | undefined) { + if (!key) { + return () => { + //noop + }; + } + + this.#hooks.push({ + key, + fn, + }); + return () => { + const idx = this.#hooks.findIndex(a => a.fn === fn); + if (idx >= 0) { + this.#hooks.splice(idx, 1); + } + }; + } + + get(key?: HexKey) { + if (key) { + return this.#cache.get(key); + } + } + + /** + * Try to update the profile metadata cache with a new version + * @param m Profile metadata + * @returns + */ + async update(m: MetadataCache) { + const existing = this.get(m.pubkey); + const refresh = existing && existing.created === m.created && existing.loaded < m.loaded; + if (!existing || existing.created < m.created || refresh) { + this.#cache.set(m.pubkey, m); + if (db.ready) { + await db.users.put(m); + this.#diskCache.add(m.pubkey); + } + this.#notifyChange([m.pubkey]); + return true; + } + return false; + } + + /** + * Loads a list of profiles from disk cache + * @param keys List of profiles to load + * @returns Keys that do not exist on disk cache + */ + async buffer(keys: Array): Promise> { + const needsBuffer = keys.filter(a => !this.#cache.has(a)); + if (db.ready && needsBuffer.length > 0) { + const mapped = needsBuffer.map(a => ({ + has: this.#diskCache.has(a), + key: a, + })); + const start = unixNowMs(); + const fromCache = await db.users.bulkGet(mapped.filter(a => a.has).map(a => a.key)); + const fromCacheFiltered = fromCache.filter(a => a !== undefined).map(a => unwrap(a)); + fromCacheFiltered.forEach(a => { + this.#cache.set(a.pubkey, a); + }); + this.#notifyChange(fromCacheFiltered.map(a => a.pubkey)); + console.debug( + `Loaded ${fromCacheFiltered.length}/${keys.length} in ${(unixNowMs() - start).toLocaleString()} ms` + ); + return mapped.filter(a => !a.has).map(a => a.key); + } + + // no IndexdDB always return all keys + return needsBuffer; + } + + #notifyChange(keys: Array) { + this.#hooks.filter(a => keys.includes(a.key)).forEach(h => h.fn()); + } +} + +export const UserCache = new UserProfileCache(); diff --git a/packages/app/src/State/Users/index.ts b/packages/app/src/State/Users/index.ts new file mode 100644 index 00000000..a878a355 --- /dev/null +++ b/packages/app/src/State/Users/index.ts @@ -0,0 +1,39 @@ +import { HexKey, TaggedRawEvent, UserMetadata } from "@snort/nostr"; +import { hexToBech32 } from "Util"; + +export interface MetadataCache extends UserMetadata { + /** + * When the object was saved in cache + */ + loaded: number; + + /** + * When the source metadata event was created + */ + created: number; + + /** + * The pubkey of the owner of this metadata + */ + pubkey: HexKey; + + /** + * The bech32 encoded pubkey + */ + npub: string; +} + +export function mapEventToProfile(ev: TaggedRawEvent) { + try { + const data: UserMetadata = JSON.parse(ev.content); + return { + pubkey: ev.pubkey, + npub: hexToBech32("npub", ev.pubkey), + created: ev.created_at, + loaded: new Date().getTime(), + ...data, + } as MetadataCache; + } catch (e) { + console.error("Failed to parse JSON", ev, e); + } +} diff --git a/packages/app/src/System.ts b/packages/app/src/System.ts index 22c4ae79..1208c7a3 100644 --- a/packages/app/src/System.ts +++ b/packages/app/src/System.ts @@ -1,10 +1,18 @@ -import { AuthHandler, HexKey, TaggedRawEvent } from "@snort/nostr"; +import { + AuthHandler, + HexKey, + TaggedRawEvent, + Event as NEvent, + EventKind, + RelaySettings, + Connection, + Subscriptions, +} from "@snort/nostr"; -import { Event as NEvent, EventKind, RelaySettings, Connection, Subscriptions } from "@snort/nostr"; import { ProfileCacheExpire } from "Const"; -import { mapEventToProfile } from "State/Users"; -import { ReduxUDB } from "State/Users/Db"; -import { unwrap } from "Util"; +import { mapEventToProfile, MetadataCache } from "State/Users"; +import { UserCache } from "State/Users/UserCache"; +import { unixNowMs, unwrap } from "Util"; /** * Manages nostr content retrieval system @@ -137,15 +145,15 @@ export class NostrSystem { /** * Request/Response pattern */ - RequestSubscription(sub: Subscriptions) { + RequestSubscription(sub: Subscriptions, timeout?: number) { return new Promise(resolve => { const events: TaggedRawEvent[] = []; // force timeout returning current results - const timeout = setTimeout(() => { + const t = setTimeout(() => { this.RemoveSubscription(sub.Id); resolve(events); - }, 10_000); + }, timeout ?? 10_000); const onEventPassthrough = sub.OnEvent; sub.OnEvent = ev => { @@ -166,7 +174,7 @@ export class NostrSystem { sub.OnEnd = c => { c.RemoveSubscription(sub.Id); if (sub.IsFinished()) { - clearInterval(timeout); + clearInterval(t); console.debug(`[${sub.Id}] Finished`); resolve(events); } @@ -176,24 +184,15 @@ export class NostrSystem { } async _FetchMetadata() { - const userDb = ReduxUDB; - - const missing = new Set(); - const meta = await userDb.bulkGet(Array.from(this.WantsMetadata)); - const expire = new Date().getTime() - ProfileCacheExpire; - for (const pk of this.WantsMetadata) { - const m = meta.find(a => a.pubkey === pk); - if ((m?.loaded ?? 0) < expire) { - missing.add(pk); - // cap 100 missing profiles - if (missing.size >= 100) { - break; - } - } - } + const missingFromCache = await UserCache.buffer([...this.WantsMetadata]); + const expire = unixNowMs() - ProfileCacheExpire; + const expired = [...this.WantsMetadata] + .filter(a => !missingFromCache.includes(a)) + .filter(a => (UserCache.get(a)?.loaded ?? 0) < expire); + const missing = new Set([...missingFromCache, ...expired]); if (missing.size > 0) { - console.debug("Wants profiles: ", missing); + console.debug(`Wants profiles: ${missingFromCache.length} missing, ${expired.length} expired`); const sub = new Subscriptions(); sub.Id = `profiles:${sub.Id.slice(0, 8)}`; @@ -202,29 +201,21 @@ export class NostrSystem { sub.OnEvent = async e => { const profile = mapEventToProfile(e); if (profile) { - const existing = await userDb.find(profile.pubkey); - if ((existing?.created ?? 0) < profile.created) { - await userDb.put(profile); - } else if (existing) { - await userDb.update(profile.pubkey, { - loaded: profile.loaded, - }); - } + await UserCache.update(profile); } }; - const results = await this.RequestSubscription(sub); - const couldNotFetch = Array.from(missing).filter(a => !results.some(b => b.pubkey === a)); - console.debug("No profiles: ", couldNotFetch); + const results = await this.RequestSubscription(sub, 5_000); + const couldNotFetch = [...missing].filter(a => !results.some(b => b.pubkey === a)); if (couldNotFetch.length > 0) { - const updates = couldNotFetch - .map(a => { - return { - pubkey: a, - loaded: new Date().getTime(), - }; - }) - .map(a => userDb.update(a.pubkey, a)); - await Promise.all(updates); + console.debug("No profiles: ", couldNotFetch); + const empty = couldNotFetch.map(a => + UserCache.update({ + pubkey: a, + loaded: unixNowMs(), + created: 69, + } as MetadataCache) + ); + await Promise.all(empty); } } diff --git a/packages/app/src/Tasks/TaskList.tsx b/packages/app/src/Tasks/TaskList.tsx index d4e24492..68c3c6ef 100644 --- a/packages/app/src/Tasks/TaskList.tsx +++ b/packages/app/src/Tasks/TaskList.tsx @@ -1,4 +1,4 @@ -import { useUserProfile } from "Feed/ProfileFeed"; +import { useUserProfile } from "Hooks/useUserProfile"; import Icon from "Icons/Icon"; import { useState } from "react"; import { useSelector } from "react-redux";