diff --git a/src/Db/User.ts b/src/Db/User.ts deleted file mode 100644 index d9898fbb..00000000 --- a/src/Db/User.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { HexKey, TaggedRawEvent, UserMetadata } from "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, - - /** - * bech32 encoded pub key - */ - npub: string -}; - -export function mapEventToProfile(ev: TaggedRawEvent) { - try { - let 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); - } -} \ No newline at end of file diff --git a/src/Db/index.ts b/src/Db/index.ts index b30881a7..8834c96d 100644 --- a/src/Db/index.ts +++ b/src/Db/index.ts @@ -1,16 +1,20 @@ import Dexie, { Table } from "dexie"; -import { MetadataCache } from "Db/User"; +import { MetadataCache } from "State/Users"; import { hexToBech32 } from "Util"; +export const NAME = 'snortDB' +export const VERSION = 2 + +const STORES = { + users: '++pubkey, name, display_name, picture, nip05, npub' +} export class SnortDB extends Dexie { users!: Table; constructor() { - super('snortDB'); - this.version(2).stores({ - users: '++pubkey, name, display_name, picture, nip05, npub' - }).upgrade(tx => { + super(NAME); + this.version(VERSION).stores(STORES).upgrade(tx => { return tx.table("users").toCollection().modify(user => { user.npub = hexToBech32("npub", user.pubkey) }) diff --git a/src/Element/Mention.tsx b/src/Element/Mention.tsx index 20898cc0..6e53ba2f 100644 --- a/src/Element/Mention.tsx +++ b/src/Element/Mention.tsx @@ -1,11 +1,11 @@ import { useMemo } from "react"; import { Link } from "react-router-dom"; -import useProfile from "Feed/ProfileFeed"; +import { useUserProfile } from "Feed/ProfileFeed"; import { HexKey } from "Nostr"; import { hexToBech32, profileLink } from "Util"; export default function Mention({ pubkey }: { pubkey: HexKey }) { - const user = useProfile(pubkey)?.get(pubkey); + const user = useUserProfile(pubkey) const name = useMemo(() => { let name = hexToBech32("npub", pubkey).substring(0, 12); @@ -18,4 +18,4 @@ export default function Mention({ pubkey }: { pubkey: HexKey }) { }, [user, pubkey]); return e.stopPropagation()}>@{name} -} \ No newline at end of file +} diff --git a/src/Element/Nip5Service.tsx b/src/Element/Nip5Service.tsx index d1b32a1f..e3d06abf 100644 --- a/src/Element/Nip5Service.tsx +++ b/src/Element/Nip5Service.tsx @@ -13,7 +13,7 @@ import { import AsyncButton from "Element/AsyncButton"; import LNURLTip from "Element/LNURLTip"; import Copy from "Element/Copy"; -import useProfile from "Feed/ProfileFeed"; +import { useUserProfile }from "Feed/ProfileFeed"; import useEventPublisher from "Feed/EventPublisher"; import { debounce, hexToBech32 } from "Util"; import { UserMetadata } from "Nostr"; @@ -31,7 +31,7 @@ type ReduxStore = any; export default function Nip5Service(props: Nip05ServiceProps) { const navigate = useNavigate(); const pubkey = useSelector(s => s.login.publicKey); - const user = useProfile(pubkey); + const user = useUserProfile(pubkey); const publisher = useEventPublisher(); const svc = useMemo(() => new ServiceProvider(props.service), [props.service]); const [serviceConfig, setServiceConfig] = useState(); @@ -194,4 +194,4 @@ export default function Nip5Service(props: Nip05ServiceProps) { } ) -} \ No newline at end of file +} diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index d68d1383..38d5193f 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -9,7 +9,7 @@ import { eventLink, getReactions, hexToBech32 } from "Util"; import NoteFooter from "Element/NoteFooter"; import NoteTime from "Element/NoteTime"; import EventKind from "Nostr/EventKind"; -import useProfile from "Feed/ProfileFeed"; +import { useUserProfiles } from "Feed/ProfileFeed"; import { TaggedRawEvent, u256 } from "Nostr"; import { useInView } from "react-intersection-observer"; @@ -31,7 +31,7 @@ export default function Note(props: NoteProps) { const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent } = props const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]); const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); - const users = useProfile(pubKeys); + const users = useUserProfiles(pubKeys); const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); const { ref, inView } = useInView({ triggerOnce: true }); diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index 37a1e89d..fb08adb7 100644 --- a/src/Element/NoteFooter.tsx +++ b/src/Element/NoteFooter.tsx @@ -9,7 +9,7 @@ import useEventPublisher from "Feed/EventPublisher"; import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util"; import { NoteCreator } from "Element/NoteCreator"; import LNURLTip from "Element/LNURLTip"; -import useProfile from "Feed/ProfileFeed"; +import { useUserProfile } from "Feed/ProfileFeed"; import { default as NEvent } from "Nostr/Event"; import { RootState } from "State/Store"; import { HexKey, TaggedRawEvent } from "Nostr"; @@ -26,7 +26,7 @@ export default function NoteFooter(props: NoteFooterProps) { const login = useSelector(s => s.login.publicKey); const prefs = useSelector(s => s.login.preferences); - const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey); + const author = useUserProfile(ev.RootPubKey); const publisher = useEventPublisher(); const [reply, setReply] = useState(false); const [tip, setTip] = useState(false); diff --git a/src/Element/NoteToSelf.tsx b/src/Element/NoteToSelf.tsx index 74b893ca..9ad1feff 100644 --- a/src/Element/NoteToSelf.tsx +++ b/src/Element/NoteToSelf.tsx @@ -3,7 +3,7 @@ import "./NoteToSelf.css"; import { Link, useNavigate } from "react-router-dom"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons" -import useProfile from "Feed/ProfileFeed"; +import { useUserProfile } from "Feed/ProfileFeed"; import Nip05 from "Element/Nip05"; import { profileLink } from "Util"; @@ -15,7 +15,7 @@ export interface NoteToSelfProps { }; function NoteLabel({pubkey, link}:NoteToSelfProps) { - const user = useProfile(pubkey)?.get(pubkey); + const user = useUserProfile(pubkey); return (
Note to Self diff --git a/src/Element/ProfileImage.tsx b/src/Element/ProfileImage.tsx index 6937333e..6889d905 100644 --- a/src/Element/ProfileImage.tsx +++ b/src/Element/ProfileImage.tsx @@ -1,13 +1,13 @@ import "./ProfileImage.css"; import { useMemo } from "react"; -import { useNavigate } from "react-router-dom"; -import useProfile from "Feed/ProfileFeed"; +import { Link, useNavigate } from "react-router-dom"; +import { useUserProfile } from "Feed/ProfileFeed"; import { hexToBech32, profileLink } from "Util"; import Avatar from "Element/Avatar" import Nip05 from "Element/Nip05"; import { HexKey } from "Nostr"; -import { MetadataCache } from "Db/User"; +import { MetadataCache } from "State/Users"; export interface ProfileImageProps { pubkey: HexKey, @@ -19,7 +19,7 @@ export interface ProfileImageProps { export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }: ProfileImageProps) { const navigate = useNavigate(); - const user = useProfile(pubkey)?.get(pubkey); + const user = useUserProfile(pubkey); const name = useMemo(() => { return getDisplayName(user, pubkey); diff --git a/src/Element/ProfilePreview.tsx b/src/Element/ProfilePreview.tsx index d00aacd5..a27f2ab0 100644 --- a/src/Element/ProfilePreview.tsx +++ b/src/Element/ProfilePreview.tsx @@ -3,7 +3,7 @@ import { ReactNode } from "react"; import ProfileImage from "Element/ProfileImage"; import FollowButton from "Element/FollowButton"; -import useProfile from "Feed/ProfileFeed"; +import { useUserProfile } from "Feed/ProfileFeed"; import { HexKey } from "Nostr"; import { useInView } from "react-intersection-observer"; @@ -16,7 +16,7 @@ export interface ProfilePreviewProps { } export default function ProfilePreview(props: ProfilePreviewProps) { const pubkey = props.pubkey; - const user = useProfile(pubkey)?.get(pubkey); + const user = useUserProfile(pubkey); const { ref, inView } = useInView({ triggerOnce: true }); const options = { about: true, @@ -34,4 +34,4 @@ export default function ProfilePreview(props: ProfilePreviewProps) { }
) -} \ No newline at end of file +} diff --git a/src/Element/Text.tsx b/src/Element/Text.tsx index dd8cf15b..fadffbdc 100644 --- a/src/Element/Text.tsx +++ b/src/Element/Text.tsx @@ -10,7 +10,7 @@ import Invoice from "Element/Invoice"; import Hashtag from "Element/Hashtag"; import Tag from "Nostr/Tag"; -import { MetadataCache } from "Db/User"; +import { MetadataCache } from "State/Users"; import Mention from "Element/Mention"; import TidalEmbed from "Element/TidalEmbed"; import { useSelector } from 'react-redux'; diff --git a/src/Element/Textarea.tsx b/src/Element/Textarea.tsx index 79397df2..c71a92fc 100644 --- a/src/Element/Textarea.tsx +++ b/src/Element/Textarea.tsx @@ -2,7 +2,6 @@ import "@webscopeio/react-textarea-autocomplete/style.css"; import "./Textarea.css"; import { useState } from "react"; -import { useLiveQuery } from "dexie-react-hooks"; import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete"; import emoji from "@jukben/emoji-search"; import TextareaAutosize from "react-textarea-autosize"; @@ -10,8 +9,8 @@ import TextareaAutosize from "react-textarea-autosize"; import Avatar from "Element/Avatar"; import Nip05 from "Element/Nip05"; import { hexToBech32 } from "Util"; -import { db } from "Db"; -import { MetadataCache } from "Db/User"; +import { MetadataCache } from "State/Users"; +import { useQuery } from "State/Users/Hooks"; interface EmojiItemProps { name: string @@ -45,16 +44,7 @@ const UserItem = (metadata: MetadataCache) => { const Textarea = ({ users, onChange, ...rest }: any) => { const [query, setQuery] = useState('') - const allUsers = useLiveQuery( - () => db.users - .where("npub").startsWithIgnoreCase(query) - .or("name").startsWithIgnoreCase(query) - .or("display_name").startsWithIgnoreCase(query) - .or("nip05").startsWithIgnoreCase(query) - .limit(5) - .toArray(), - [query], - ); + const allUsers = useQuery(query) const userDataProvider = (token: string) => { setQuery(token) diff --git a/src/Element/ZapButton.tsx b/src/Element/ZapButton.tsx index 147b1871..8ab5b1f6 100644 --- a/src/Element/ZapButton.tsx +++ b/src/Element/ZapButton.tsx @@ -2,12 +2,13 @@ import "./ZapButton.css"; import { faBolt } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useState } from "react"; -import useProfile from "Feed/ProfileFeed"; +import { useUserProfile } from "Feed/ProfileFeed"; import { HexKey } from "Nostr"; import LNURLTip from "Element/LNURLTip"; + const ZapButton = ({ pubkey, svc }: { pubkey?: HexKey, svc?: string }) => { - const profile = useProfile(pubkey)?.get(pubkey ?? ""); + const profile = useUserProfile(pubkey!) const [zap, setZap] = useState(false); const service = svc ?? (profile?.lud16 || profile?.lud06); diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 7c4521f2..30e62fc1 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -7,9 +7,9 @@ import EventKind from "Nostr/EventKind"; import { Subscriptions } from "Nostr/Subscriptions"; import { addDirectMessage, addNotifications, setFollows, setRelays } from "State/Login"; import { RootState } from "State/Store"; -import { db } from "Db"; +import { mapEventToProfile, MetadataCache } from "State/Users"; +import { getDb } from "State/Users/Db"; import useSubscription from "Feed/Subscription"; -import { mapEventToProfile, MetadataCache } from "Db/User"; import { getDisplayName } from "Element/ProfileImage"; import { MentionRegex } from "Const"; @@ -87,9 +87,10 @@ export default function useLoginFeed() { return acc; }, { created: 0, profile: null }); if (maxProfile.profile) { - let existing = await db.users.get(maxProfile.profile.pubkey); + const db = getDb() + let existing = await db.find(maxProfile.profile.pubkey); if ((existing?.created ?? 0) < maxProfile.created) { - await db.users.put(maxProfile.profile); + await db.put(maxProfile.profile); } } })().catch(console.warn); @@ -115,10 +116,12 @@ export default function useLoginFeed() { } async function makeNotification(ev: TaggedRawEvent) { + const db = getDb() switch (ev.kind) { case EventKind.TextNote: { const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]); - const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!); + const users = await db.bulkGet(Array.from(pubkeys)) + // @ts-ignore const fromUser = users.find(a => a?.pubkey === ev.pubkey); const name = getDisplayName(fromUser, ev.pubkey); const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture; @@ -159,4 +162,4 @@ async function sendNotification(ev: TaggedRawEvent) { vibrate: [500] }); } -} \ No newline at end of file +} diff --git a/src/Feed/ProfileFeed.ts b/src/Feed/ProfileFeed.ts index b439f814..c8669410 100644 --- a/src/Feed/ProfileFeed.ts +++ b/src/Feed/ProfileFeed.ts @@ -1,27 +1,13 @@ import { useLiveQuery } from "dexie-react-hooks"; -import { useEffect } from "react"; -import { db } from "Db"; -import { MetadataCache } from "Db/User"; +import { useEffect, useMemo } from "react"; +import { RootState } from "State/Store"; +import { MetadataCache } from "State/Users"; +import { useKey, useKeys } from "State/Users/Hooks"; import { HexKey } from "Nostr"; import { System } from "Nostr/System"; -export default function useProfile(pubKey?: HexKey | Array | undefined): Map | undefined { - const user = useLiveQuery(async () => { - let userList = new Map(); - if (pubKey) { - if (Array.isArray(pubKey)) { - let ret = await db.users.bulkGet(pubKey); - let filtered = ret.filter(a => a !== undefined).map(a => a!); - return new Map(filtered.map(a => [a.pubkey, a])) - } else { - let ret = await db.users.get(pubKey); - if (ret) { - userList.set(ret.pubkey, ret); - } - } - } - return userList; - }, [pubKey]); +export function useUserProfile(pubKey: HexKey): MetadataCache | undefined { + const users = useKey(pubKey); useEffect(() => { if (pubKey) { @@ -30,5 +16,19 @@ export default function useProfile(pubKey?: HexKey | Array | undefined): } }, [pubKey]); - return user; -} \ No newline at end of file + 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/src/Nostr/System.ts b/src/Nostr/System.ts index bdc81e73..b388ed0d 100644 --- a/src/Nostr/System.ts +++ b/src/Nostr/System.ts @@ -1,7 +1,7 @@ import { HexKey, TaggedRawEvent } from "Nostr"; import { ProfileCacheExpire } from "Const"; -import { db } from "Db"; -import { mapEventToProfile, MetadataCache } from "Db/User"; +import { mapEventToProfile, MetadataCache, } from "State/Users"; +import { getDb } from "State/Users/Db"; import Connection, { RelaySettings } from "Nostr/Connection"; import Event from "Nostr/Event"; import EventKind from "Nostr/EventKind"; @@ -167,7 +167,8 @@ export class NostrSystem { async _FetchMetadata() { let missing = new Set(); - let meta = await db.users.bulkGet(Array.from(this.WantsMetadata)); + const db = getDb() + let meta = await db.bulkGet(Array.from(this.WantsMetadata)); let expire = new Date().getTime() - ProfileCacheExpire; for (let pk of this.WantsMetadata) { let m = meta.find(a => a?.pubkey === pk); @@ -190,18 +191,18 @@ export class NostrSystem { sub.OnEvent = async (e) => { let profile = mapEventToProfile(e); if (profile) { - let existing = await db.users.get(profile.pubkey); - if ((existing?.created ?? 0) < profile.created) { - await db.users.put(profile); - } else if (existing) { - await db.users.update(profile.pubkey, { loaded: new Date().getTime() }); + let existing = await db.find(profile.pubkey); + if((existing?.created ?? 0) < profile.created) { + await db.put(profile); + } else if(existing) { + await db.update(profile.pubkey, { loaded: new Date().getTime() }); } } } let results = await this.RequestSubscription(sub); let couldNotFetch = Array.from(missing).filter(a => !results.some(b => b.pubkey === a)); console.debug("No profiles: ", couldNotFetch); - await db.users.bulkPut(couldNotFetch.map(a => { + await db.bulkPut(couldNotFetch.map(a => { return { pubkey: a, loaded: new Date().getTime() @@ -213,4 +214,4 @@ export class NostrSystem { } } -export const System = new NostrSystem(); \ No newline at end of file +export const System = new NostrSystem(); diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 8ad0e744..6871f065 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -6,7 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faGear, faEnvelope } from "@fortawesome/free-solid-svg-icons"; import { useNavigate, useParams } from "react-router-dom"; -import useProfile from "Feed/ProfileFeed"; +import { useUserProfile } from "Feed/ProfileFeed"; import FollowButton from "Element/FollowButton"; import { extractLnAddress, parseId, hexToBech32 } from "Util"; import Avatar from "Element/Avatar"; @@ -33,7 +33,7 @@ export default function ProfilePage() { const params = useParams(); const navigate = useNavigate(); const id = useMemo(() => parseId(params.id!), [params]); - const user = useProfile(id)?.get(id); + const user = useUserProfile(id); const loginPubKey = useSelector(s => s.login.publicKey); const follows = useSelector(s => s.login.follows); const isMe = loginPubKey === id; diff --git a/src/Pages/settings/Profile.tsx b/src/Pages/settings/Profile.tsx index d1031f12..76f42acc 100644 --- a/src/Pages/settings/Profile.tsx +++ b/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 useProfile from "Feed/ProfileFeed"; +import { useUserProfile } from "Feed/ProfileFeed"; import VoidUpload from "Feed/VoidUpload"; import { logout } from "State/Login"; import { hexToBech32, openFile } from "Util"; @@ -22,7 +22,7 @@ export default function ProfileSettings() { const id = useSelector(s => s.login.publicKey); const privKey = useSelector(s => s.login.privateKey); const dispatch = useDispatch(); - const user = useProfile(id)?.get(id || ""); + const user = useUserProfile(id!); const publisher = useEventPublisher(); const [name, setName] = useState(); diff --git a/src/State/Store.ts b/src/State/Store.ts index 1c997610..74a82d6b 100644 --- a/src/State/Store.ts +++ b/src/State/Store.ts @@ -1,13 +1,15 @@ import { configureStore } from "@reduxjs/toolkit"; import { reducer as LoginReducer } from "State/Login"; +import { reducer as UsersReducer } from "State/Users"; const store = configureStore({ reducer: { - login: LoginReducer + login: LoginReducer, + users: UsersReducer, } }); export type RootState = ReturnType export type AppDispatch = typeof store.dispatch -export default store; \ No newline at end of file +export default store; diff --git a/src/State/Users.ts b/src/State/Users.ts new file mode 100644 index 00000000..9f92183a --- /dev/null +++ b/src/State/Users.ts @@ -0,0 +1,75 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { HexKey, TaggedRawEvent, UserMetadata } from "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 { + let 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/src/State/Users/Db.ts b/src/State/Users/Db.ts new file mode 100644 index 00000000..a2e5bf57 --- /dev/null +++ b/src/State/Users/Db.ts @@ -0,0 +1,149 @@ +import { HexKey } from "Nostr"; +import { db as idb } from "Db"; + +import { UsersDb, MetadataCache, setUsers } from "State/Users"; +import store from "State/Store"; + +class IndexedDb implements UsersDb { + isAvailable() { + if ("indexedDB" in window) { + return new Promise((resolve) => { + const req = window.indexedDB.open("dummy", 1) + req.onsuccess = (ev) => { + resolve(true) + } + req.onerror = (ev) => { + 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() + } + + bulkGet(keys: HexKey[]) { + return idb.users.bulkGet(keys).then(ret => ret.filter(a => a !== undefined).map(a => a!)); + } + + add(user: MetadataCache) { + return idb.users.add(user) + } + + put(user: MetadataCache) { + return idb.users.put(user) + } + + bulkAdd(users: MetadataCache[]) { + return idb.users.bulkAdd(users) + } + + bulkPut(users: MetadataCache[]) { + return idb.users.bulkPut(users) + } + + update(key: HexKey, fields: Record) { + return idb.users.update(key, fields) + } +} + +function groupByPubkey(acc: Record, user: MetadataCache) { + return { ...acc, [user.pubkey]: user } +} + +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) + }) + } + + async find(key: HexKey) { + const state = store.getState() + const { users } = state.users + return users[key] + } + + + async add(user: MetadataCache) { + const state = store.getState() + const { users } = state.users + store.dispatch(setUsers({...users, [user.pubkey]: user })) + } + + + async put(user: MetadataCache) { + const state = store.getState() + const { users } = state.users + store.dispatch(setUsers({...users, [user.pubkey]: user })) + } + + async bulkAdd(newUserProfiles: MetadataCache[]) { + const state = store.getState() + const { users } = state.users + const newUsers = newUserProfiles.reduce(groupByPubkey, {}) + store.dispatch(setUsers({...users, ...newUsers })) + } + + async bulkGet(keys: HexKey[]) { + const state = store.getState() + const { users } = state.users + const ids = new Set([...keys]) + return Object.values(users).filter(user => { + return ids.has(user.pubkey) + }) + } + + 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 })) + } + + async bulkPut(newUsers: MetadataCache[]) { + const state = store.getState() + const { users } = state.users + const newProfiles = newUsers.reduce(groupByPubkey, {}) + store.dispatch(setUsers({ ...users, ...newProfiles })) + } +} + + +const indexedDb = new IndexedDb() +export const inMemoryDb = new ReduxUsersDb() + +let db: UsersDb = inMemoryDb +indexedDb.isAvailable().then((available) => { + if (available) { + console.debug('Using Indexed DB') + db = indexedDb; + } else { + console.debug('Using in-memory DB') + } +}) + +export function getDb() { + return db +} diff --git a/src/State/Users/Hooks.ts b/src/State/Users/Hooks.ts new file mode 100644 index 00000000..e43c3bed --- /dev/null +++ b/src/State/Users/Hooks.ts @@ -0,0 +1,60 @@ +import { useSelector } from "react-redux" +import { useLiveQuery } from "dexie-react-hooks"; +import { MetadataCache } from "State/Users"; +import { getDb, inMemoryDb } from "State/Users/Db"; +import type { RootState } from "State/Store" +import { HexKey } from "Nostr"; + +export function useQuery(query: string, limit: number = 5) { + const db = getDb() + + const allUsers = useLiveQuery( + () => db.query(query) + .catch((err) => { + console.error(err) + }).then(() => { + return inMemoryDb.query(query) + }), + [query], + ) + + return allUsers +} + +export function useKey(pubKey: HexKey) { + const db = getDb() + const { users } = useSelector((state: RootState) => state.users) + const defaultUser = users[pubKey] + + const user = useLiveQuery(async () => { + if (pubKey) { + try { + return await db.find(pubKey); + } catch (error) { + console.error(error) + return defaultUser + } + } + }, [pubKey, defaultUser]); + + return user +} + +export function useKeys(pubKeys: HexKey[]): Map { + const db = getDb() + const dbUsers = useLiveQuery(async () => { + if (pubKeys) { + try { + const ret = await db.bulkGet(pubKeys); + return new Map(ret.map(a => [a.pubkey, a])) + } catch (error) { + console.error(error) + const ret = await inMemoryDb.bulkGet(pubKeys); + return new Map(ret.map(a => [a.pubkey, a])) + } + } + return new Map() + }, [pubKeys]); + + return dbUsers! +}