From c731c656613d94c6554780d27aec671114fe50d1 Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 29 Mar 2023 13:10:22 +0100 Subject: [PATCH] dm cache --- .vscode/settings.json | 11 ++ packages/app/src/Cache/DMCache.ts | 36 ++++ packages/app/src/Cache/FeedCache.ts | 162 ++++++++++++++++++ packages/app/src/Cache/UserCache.ts | 83 +++++++++ .../app/src/{State/Users => Cache}/index.ts | 9 + packages/app/src/Db/index.ts | 10 +- packages/app/src/Element/Bookmarks.tsx | 4 +- packages/app/src/Element/LogoutButton.tsx | 11 +- packages/app/src/Element/Note.tsx | 4 +- packages/app/src/Element/ProfileImage.tsx | 2 +- packages/app/src/Element/Textarea.tsx | 4 +- packages/app/src/Element/Zap.tsx | 4 +- packages/app/src/Feed/LoginFeed.ts | 11 +- packages/app/src/Hooks/useDmsCache.tsx | 9 + packages/app/src/Hooks/useUserProfile.ts | 6 +- packages/app/src/Notifications.ts | 8 +- packages/app/src/Pages/ChatPage.tsx | 7 +- packages/app/src/Pages/Layout.tsx | 10 +- packages/app/src/Pages/MessagesPage.tsx | 7 +- packages/app/src/Pages/SearchPage.tsx | 4 +- packages/app/src/Pages/settings/Index.tsx | 7 +- packages/app/src/State/Login.ts | 39 +---- packages/app/src/State/Users/UserCache.ts | 159 ----------------- packages/app/src/System/ProfileCache.ts | 10 +- packages/app/src/Tasks/Nip5Task.tsx | 2 +- packages/app/src/Tasks/index.ts | 2 +- packages/app/src/Util.ts | 2 +- 27 files changed, 384 insertions(+), 239 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 packages/app/src/Cache/DMCache.ts create mode 100644 packages/app/src/Cache/FeedCache.ts create mode 100644 packages/app/src/Cache/UserCache.ts rename packages/app/src/{State/Users => Cache}/index.ts (81%) create mode 100644 packages/app/src/Hooks/useDmsCache.tsx delete mode 100644 packages/app/src/State/Users/UserCache.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..2291b096 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**/node_modules": true + } +} \ No newline at end of file diff --git a/packages/app/src/Cache/DMCache.ts b/packages/app/src/Cache/DMCache.ts new file mode 100644 index 00000000..2d4e63a4 --- /dev/null +++ b/packages/app/src/Cache/DMCache.ts @@ -0,0 +1,36 @@ +import { RawEvent } from "@snort/nostr"; +import { db } from "Db"; +import { dedupe } from "Util"; +import FeedCache from "./FeedCache"; + +class DMCache extends FeedCache { + constructor() { + super("DMCache", db.dms); + } + + key(of: RawEvent): string { + return of.id; + } + + override async preload(): Promise { + await super.preload(); + // load all dms to memory + await this.buffer([...this.onTable]); + } + + newest(): number { + let ret = 0; + this.cache.forEach(v => (ret = v.created_at > ret ? v.created_at : ret)); + return ret; + } + + allDms(): Array { + return [...this.cache.values()]; + } + + takeSnapshot(): Array { + return this.allDms(); + } +} + +export const DmCache = new DMCache(); diff --git a/packages/app/src/Cache/FeedCache.ts b/packages/app/src/Cache/FeedCache.ts new file mode 100644 index 00000000..387093ae --- /dev/null +++ b/packages/app/src/Cache/FeedCache.ts @@ -0,0 +1,162 @@ +import { db } from "Db"; +import { Table } from "dexie"; +import { unixNowMs, unwrap } from "Util"; + +type HookFn = () => void; + +interface HookFilter { + key: string; + fn: HookFn; +} + +export default abstract class FeedCache { + #name: string; + #table: Table; + #hooks: Array = []; + #snapshot: Readonly> = []; + #changed = true; + protected onTable: Set = new Set(); + protected cache: Map = new Map(); + + constructor(name: string, table: Table) { + this.#name = name; + this.#table = table; + setInterval(() => { + console.debug( + `[${this.#name}] ${this.cache.size} loaded, ${this.onTable.size} on-disk, ${this.#hooks.length} hooks` + ); + }, 5_000); + } + + async preload() { + if (db.ready) { + const keys = await this.#table.toCollection().primaryKeys(); + this.onTable = new Set(keys.map(a => a as string)); + } + } + + hook(fn: HookFn, key: string | 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); + } + }; + } + + getFromCache(key?: string) { + if (key) { + return this.cache.get(key); + } + } + + async get(key?: string) { + if (key && !this.cache.has(key) && db.ready) { + const cached = await this.#table.get(key); + if (cached) { + this.cache.set(this.key(cached), cached); + this.notifyChange([key]); + return cached; + } + } + return key ? this.cache.get(key) : undefined; + } + + async bulkGet(keys: Array) { + const missing = keys.filter(a => !this.cache.has(a)); + if (missing.length > 0 && db.ready) { + const cached = await this.#table.bulkGet(missing); + cached.forEach(a => { + if (a) { + this.cache.set(this.key(a), a); + } + }); + } + return keys + .map(a => this.cache.get(a)) + .filter(a => a) + .map(a => unwrap(a)); + } + + async set(obj: TCached) { + const k = this.key(obj); + this.cache.set(k, obj); + if (db.ready) { + await this.#table.put(obj); + this.onTable.add(k); + } + this.notifyChange([k]); + } + + async bulkSet(obj: Array) { + if (db.ready) { + await this.#table.bulkPut(obj); + obj.forEach(a => this.onTable.add(this.key(a))); + } + obj.forEach(v => this.cache.set(this.key(v), v)); + this.notifyChange(obj.map(a => this.key(a))); + } + + /** + * Loads a list of rows from disk cache + * @param keys List of ids 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.onTable.has(a), + key: a, + })); + const start = unixNowMs(); + const fromCache = await this.#table.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(this.key(a), a); + }); + this.notifyChange(fromCacheFiltered.map(a => this.key(a))); + console.debug( + `[${this.#name}] 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; + } + + async clear() { + await this.#table.clear(); + this.cache.clear(); + this.onTable.clear(); + } + + snapshot() { + if (this.#changed) { + this.#snapshot = this.takeSnapshot(); + this.#changed = false; + } + return this.#snapshot; + } + + protected notifyChange(keys: Array) { + this.#changed = true; + this.#hooks.filter(a => keys.includes(a.key)).forEach(h => h.fn()); + } + + abstract key(of: TCached): string; + abstract takeSnapshot(): Array; +} diff --git a/packages/app/src/Cache/UserCache.ts b/packages/app/src/Cache/UserCache.ts new file mode 100644 index 00000000..a5040300 --- /dev/null +++ b/packages/app/src/Cache/UserCache.ts @@ -0,0 +1,83 @@ +import FeedCache from "Cache/FeedCache"; +import { db } from "Db"; +import { LNURL } from "LNURL"; +import { MetadataCache } from "Cache"; + +class UserProfileCache extends FeedCache { + constructor() { + super("UserCache", db.users); + } + + key(of: MetadataCache): string { + return of.pubkey; + } + + 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); + } + } + + /** + * Try to update the profile metadata cache with a new version + * @param m Profile metadata + * @returns + */ + async update(m: MetadataCache) { + const existing = this.getFromCache(m.pubkey); + const refresh = existing && existing.created === m.created && existing.loaded < m.loaded; + if (!existing || existing.created < m.created || refresh) { + // fetch zapper key + const lnurl = m.lud16 || m.lud06; + if (lnurl) { + try { + const svc = new LNURL(lnurl); + await svc.load(); + m.zapService = svc.zapperPubkey; + } catch { + console.debug("Failed to load LNURL for zapper pubkey", lnurl); + } + // ignored + } + + this.cache.set(m.pubkey, m); + if (db.ready) { + await db.users.put(m); + this.onTable.add(m.pubkey); + } + this.notifyChange([m.pubkey]); + return true; + } + return false; + } + + takeSnapshot(): MetadataCache[] { + return []; + } +} + +export const UserCache = new UserProfileCache(); diff --git a/packages/app/src/State/Users/index.ts b/packages/app/src/Cache/index.ts similarity index 81% rename from packages/app/src/State/Users/index.ts rename to packages/app/src/Cache/index.ts index 8d9fd175..ae61faaf 100644 --- a/packages/app/src/State/Users/index.ts +++ b/packages/app/src/Cache/index.ts @@ -1,5 +1,7 @@ import { HexKey, TaggedRawEvent, UserMetadata } from "@snort/nostr"; import { hexToBech32, unixNowMs } from "Util"; +import { DmCache } from "./DMCache"; +import { UserCache } from "./UserCache"; export interface MetadataCache extends UserMetadata { /** @@ -42,3 +44,10 @@ export function mapEventToProfile(ev: TaggedRawEvent) { console.error("Failed to parse JSON", ev, e); } } + +export async function preload() { + await UserCache.preload(); + await DmCache.preload(); +} + +export { UserCache, DmCache }; diff --git a/packages/app/src/Db/index.ts b/packages/app/src/Db/index.ts index f919369c..7b30d582 100644 --- a/packages/app/src/Db/index.ts +++ b/packages/app/src/Db/index.ts @@ -1,9 +1,9 @@ import Dexie, { Table } from "dexie"; -import { FullRelaySettings, HexKey, u256 } from "@snort/nostr"; -import { MetadataCache } from "State/Users"; +import { FullRelaySettings, HexKey, RawEvent, u256 } from "@snort/nostr"; +import { MetadataCache } from "Cache"; export const NAME = "snortDB"; -export const VERSION = 6; +export const VERSION = 7; export interface SubCache { id: string; @@ -28,6 +28,8 @@ const STORES = { users: "++pubkey, name, display_name, picture, nip05, npub", relays: "++addr", userRelays: "++pubkey", + events: "++id, pubkey, created_at", + dms: "++id, pubkey", }; export class SnortDB extends Dexie { @@ -35,6 +37,8 @@ export class SnortDB extends Dexie { users!: Table; relayMetrics!: Table; userRelays!: Table; + events!: Table; + dms!: Table; constructor() { super(NAME); diff --git a/packages/app/src/Element/Bookmarks.tsx b/packages/app/src/Element/Bookmarks.tsx index 23871417..cd6ddea8 100644 --- a/packages/app/src/Element/Bookmarks.tsx +++ b/packages/app/src/Element/Bookmarks.tsx @@ -5,7 +5,7 @@ import { HexKey, TaggedRawEvent } from "@snort/nostr"; import Note from "Element/Note"; import { RootState } from "State/Store"; -import { UserCache } from "State/Users/UserCache"; +import { UserCache } from "Cache/UserCache"; import messages from "./messages"; @@ -23,7 +23,7 @@ const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => { }, [bookmarks]); function renderOption(p: HexKey) { - const profile = UserCache.get(p); + const profile = UserCache.getFromCache(p); return profile ? : null; } diff --git a/packages/app/src/Element/LogoutButton.tsx b/packages/app/src/Element/LogoutButton.tsx index fc9cf7bb..99e6b5c0 100644 --- a/packages/app/src/Element/LogoutButton.tsx +++ b/packages/app/src/Element/LogoutButton.tsx @@ -1,19 +1,24 @@ import { useDispatch } from "react-redux"; import { FormattedMessage } from "react-intl"; - +import { useNavigate } from "react-router-dom"; import { logout } from "State/Login"; import messages from "./messages"; export default function LogoutButton() { const dispatch = useDispatch(); + const navigate = useNavigate(); + return ( diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index 2d1e8dff..96d1bee9 100644 --- a/packages/app/src/Element/Note.tsx +++ b/packages/app/src/Element/Note.tsx @@ -26,7 +26,7 @@ import NoteTime from "Element/NoteTime"; import useModeration from "Hooks/useModeration"; import { setPinned, setBookmarked } from "State/Login"; import type { RootState } from "State/Store"; -import { UserCache } from "State/Users/UserCache"; +import { UserCache } from "Cache/UserCache"; import messages from "./messages"; import { EventExt } from "System/EventExt"; @@ -204,7 +204,7 @@ export default function Note(props: NoteProps) { const replyRelayHints = thread?.replyTo?.Relay ?? thread.root?.Relay; const mentions: { pk: string; name: string; link: ReactNode }[] = []; for (const pk of thread?.pubKeys ?? []) { - const u = UserCache.get(pk); + const u = UserCache.getFromCache(pk); const npub = hexToBech32(NostrPrefix.PublicKey, pk); const shortNpub = npub.substring(0, 12); mentions.push({ diff --git a/packages/app/src/Element/ProfileImage.tsx b/packages/app/src/Element/ProfileImage.tsx index 55661053..46d0c2d3 100644 --- a/packages/app/src/Element/ProfileImage.tsx +++ b/packages/app/src/Element/ProfileImage.tsx @@ -7,7 +7,7 @@ import { hexToBech32, profileLink } from "Util"; import Avatar from "Element/Avatar"; import Nip05 from "Element/Nip05"; import { HexKey, NostrPrefix } from "@snort/nostr"; -import { MetadataCache } from "State/Users"; +import { MetadataCache } from "Cache"; import usePageWidth from "Hooks/usePageWidth"; export interface ProfileImageProps { diff --git a/packages/app/src/Element/Textarea.tsx b/packages/app/src/Element/Textarea.tsx index 677c73d0..e9470126 100644 --- a/packages/app/src/Element/Textarea.tsx +++ b/packages/app/src/Element/Textarea.tsx @@ -10,8 +10,8 @@ import { NostrPrefix } from "@snort/nostr"; import Avatar from "Element/Avatar"; import Nip05 from "Element/Nip05"; import { hexToBech32 } from "Util"; -import { MetadataCache } from "State/Users"; -import { UserCache } from "State/Users/UserCache"; +import { MetadataCache } from "Cache"; +import { UserCache } from "Cache/UserCache"; import messages from "./messages"; diff --git a/packages/app/src/Element/Zap.tsx b/packages/app/src/Element/Zap.tsx index 2630ee54..459aa22d 100644 --- a/packages/app/src/Element/Zap.tsx +++ b/packages/app/src/Element/Zap.tsx @@ -10,7 +10,7 @@ import Text from "Element/Text"; import ProfileImage from "Element/ProfileImage"; import { RootState } from "State/Store"; import { findTag } from "Util"; -import { UserCache } from "State/Users/UserCache"; +import { UserCache } from "Cache/UserCache"; import messages from "./messages"; @@ -65,7 +65,7 @@ export function parseZap(zapReceipt: TaggedRawEvent): ParsedZap { ret.valid = false; ret.errors.push("amount tag does not match invoice amount"); } - if (UserCache.get(ret.receiver)?.zapService !== ret.zapService) { + if (UserCache.getFromCache(ret.receiver)?.zapService !== ret.zapService) { ret.valid = false; ret.errors.push("zap service pubkey doesn't match"); } diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index 064f88ac..3c13c013 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -6,7 +6,6 @@ import { TaggedRawEvent, HexKey, Lists, EventKind } from "@snort/nostr"; import { getNewest, getNewestEventTagsByKey, unwrap } from "Util"; import { makeNotification } from "Notifications"; import { - addDirectMessage, setFollows, setRelays, setMuted, @@ -24,6 +23,7 @@ import useModeration from "Hooks/useModeration"; import { FlatNoteStore, RequestBuilder } from "System"; import useRequestBuilder from "Hooks/useRequestBuilder"; import { EventExt } from "System/EventExt"; +import { DmCache } from "Cache"; /** * Managed loading data for the current logged in user @@ -45,9 +45,12 @@ export default function useLoginFeed() { b.withOptions({ leaveOpen: true, }); - b.withFilter().authors([pubKey]).kinds([EventKind.ContactList, EventKind.DirectMessage]); + b.withFilter().authors([pubKey]).kinds([EventKind.ContactList]); b.withFilter().kinds([EventKind.TextNote]).tag("p", [pubKey]).limit(1); - b.withFilter().kinds([EventKind.DirectMessage]).tag("p", [pubKey]); + + const dmSince = DmCache.newest(); + b.withFilter().authors([pubKey]).kinds([EventKind.DirectMessage]).since(dmSince); + b.withFilter().kinds([EventKind.DirectMessage]).tag("p", [pubKey]).since(dmSince); return b; }, [pubKey]); @@ -81,7 +84,7 @@ export default function useLoginFeed() { } const dms = loginFeed.data.filter(a => a.kind === EventKind.DirectMessage); - dispatch(addDirectMessage(dms)); + DmCache.bulkSet(dms); } }, [dispatch, loginFeed]); diff --git a/packages/app/src/Hooks/useDmsCache.tsx b/packages/app/src/Hooks/useDmsCache.tsx new file mode 100644 index 00000000..ae7f9f9a --- /dev/null +++ b/packages/app/src/Hooks/useDmsCache.tsx @@ -0,0 +1,9 @@ +import { DmCache } from "Cache"; +import { useSyncExternalStore } from "react"; + +export function useDmCache() { + return useSyncExternalStore( + c => DmCache.hook(c, undefined), + () => DmCache.snapshot() + ); +} diff --git a/packages/app/src/Hooks/useUserProfile.ts b/packages/app/src/Hooks/useUserProfile.ts index 4b162ebe..f04245a6 100644 --- a/packages/app/src/Hooks/useUserProfile.ts +++ b/packages/app/src/Hooks/useUserProfile.ts @@ -1,14 +1,14 @@ import { useEffect, useSyncExternalStore } from "react"; import { HexKey } from "@snort/nostr"; -import { MetadataCache } from "State/Users"; -import { UserCache } from "State/Users/UserCache"; +import { MetadataCache } from "Cache"; +import { UserCache } from "Cache/UserCache"; import { ProfileLoader } from "System/ProfileCache"; export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined { const user = useSyncExternalStore( h => UserCache.hook(h, pubKey), - () => UserCache.get(pubKey) + () => UserCache.getFromCache(pubKey) ); useEffect(() => { diff --git a/packages/app/src/Notifications.ts b/packages/app/src/Notifications.ts index d070abab..46442051 100644 --- a/packages/app/src/Notifications.ts +++ b/packages/app/src/Notifications.ts @@ -3,11 +3,11 @@ import Nostrich from "nostrich.webp"; import { TaggedRawEvent } from "@snort/nostr"; import { EventKind } from "@snort/nostr"; import type { NotificationRequest } from "State/Login"; -import { MetadataCache } from "State/Users"; +import { MetadataCache } from "Cache"; import { getDisplayName } from "Element/ProfileImage"; import { MentionRegex } from "Const"; import { tagFilterOfTextRepost, unwrap } from "Util"; -import { UserCache } from "State/Users/UserCache"; +import { UserCache } from "Cache/UserCache"; export async function makeNotification(ev: TaggedRawEvent): Promise { switch (ev.kind) { @@ -18,10 +18,10 @@ export async function makeNotification(ev: TaggedRawEvent): Promise a[0] === "p").map(a => a[1])]); await UserCache.buffer([...pubkeys]); const allUsers = [...pubkeys] - .map(a => UserCache.get(a)) + .map(a => UserCache.getFromCache(a)) .filter(a => a) .map(a => unwrap(a)); - const fromUser = UserCache.get(ev.pubkey); + const fromUser = UserCache.getFromCache(ev.pubkey); const name = getDisplayName(fromUser, ev.pubkey); const avatarUrl = fromUser?.picture || Nostrich; return { diff --git a/packages/app/src/Pages/ChatPage.tsx b/packages/app/src/Pages/ChatPage.tsx index 05a014ed..29fc6fd9 100644 --- a/packages/app/src/Pages/ChatPage.tsx +++ b/packages/app/src/Pages/ChatPage.tsx @@ -8,11 +8,12 @@ import { bech32ToHex } from "Util"; import useEventPublisher from "Feed/EventPublisher"; import DM from "Element/DM"; -import { TaggedRawEvent } from "@snort/nostr"; +import { RawEvent, TaggedRawEvent } from "@snort/nostr"; import { dmsInChat, isToSelf } from "Pages/MessagesPage"; import NoteToSelf from "Element/NoteToSelf"; import { RootState } from "State/Store"; import { FormattedMessage } from "react-intl"; +import { useDmCache } from "Hooks/useDmsCache"; type RouterParams = { id: string; @@ -23,11 +24,11 @@ export default function ChatPage() { const publisher = useEventPublisher(); const id = bech32ToHex(params.id ?? ""); const pubKey = useSelector((s: RootState) => s.login.publicKey); - const dms = useSelector((s: RootState) => filterDms(s.login.dms)); const [content, setContent] = useState(); const dmListRef = useRef(null); + const dms = filterDms(useDmCache()); - function filterDms(dms: TaggedRawEvent[]) { + function filterDms(dms: readonly RawEvent[]) { return dmsInChat(id === pubKey ? dms.filter(d => isToSelf(d, pubKey)) : dms, id); } diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index 3f9a7496..f9850e25 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -18,11 +18,11 @@ import { totalUnread } from "Pages/MessagesPage"; import useModeration from "Hooks/useModeration"; import { NoteCreator } from "Element/NoteCreator"; import { db } from "Db"; -import { UserCache } from "State/Users/UserCache"; -import { FollowsRelays } from "State/Relays"; import useEventPublisher from "Feed/EventPublisher"; import { SnortPubKey } from "Const"; import SubDebug from "Element/SubDebug"; +import { preload } from "Cache"; +import { useDmCache } from "Hooks/useDmsCache"; export default function Layout() { const location = useLocation(); @@ -101,8 +101,7 @@ export default function Layout() { db.isAvailable().then(async a => { db.ready = a; if (a) { - await UserCache.preload(); - await FollowsRelays.preload(); + await preload(); } console.debug(`Using db: ${a ? "IndexedDB" : "In-Memory"}`); dispatch(init()); @@ -192,7 +191,8 @@ const AccountHeader = () => { const navigate = useNavigate(); const { isMuted } = useModeration(); - const { publicKey, latestNotification, readNotifications, dms } = useSelector((s: RootState) => s.login); + const { publicKey, latestNotification, readNotifications } = useSelector((s: RootState) => s.login); + const dms = useDmCache(); const hasNotifications = useMemo( () => latestNotification > readNotifications, diff --git a/packages/app/src/Pages/MessagesPage.tsx b/packages/app/src/Pages/MessagesPage.tsx index aba87945..e31d83cc 100644 --- a/packages/app/src/Pages/MessagesPage.tsx +++ b/packages/app/src/Pages/MessagesPage.tsx @@ -12,6 +12,7 @@ import NoteToSelf from "Element/NoteToSelf"; import useModeration from "Hooks/useModeration"; import messages from "./messages"; +import { useDmCache } from "Hooks/useDmsCache"; type DmChat = { pubkey: HexKey; @@ -22,9 +23,9 @@ type DmChat = { export default function MessagesPage() { const dispatch = useDispatch(); const myPubKey = useSelector(s => s.login.publicKey); - const dms = useSelector(s => s.login.dms); const dmInteraction = useSelector(s => s.login.dmInteraction); const { isMuted } = useModeration(); + const dms = useDmCache(); const chats = useMemo(() => { return extractChats( @@ -105,11 +106,11 @@ export function dmTo(e: RawEvent) { return firstP ? firstP[1] : ""; } -export function isToSelf(e: RawEvent, pk: HexKey) { +export function isToSelf(e: Readonly, pk: HexKey) { return e.pubkey === pk && dmTo(e) === pk; } -export function dmsInChat(dms: RawEvent[], pk: HexKey) { +export function dmsInChat(dms: readonly RawEvent[], pk: HexKey) { return dms.filter(a => a.pubkey === pk || dmTo(a) === pk); } diff --git a/packages/app/src/Pages/SearchPage.tsx b/packages/app/src/Pages/SearchPage.tsx index 1dd00be7..464ecab6 100644 --- a/packages/app/src/Pages/SearchPage.tsx +++ b/packages/app/src/Pages/SearchPage.tsx @@ -7,8 +7,8 @@ import { debounce } from "Util"; import { router } from "index"; import { SearchRelays } from "Const"; import { System } from "System"; -import { MetadataCache } from "State/Users"; -import { UserCache } from "State/Users/UserCache"; +import { MetadataCache } from "Cache"; +import { UserCache } from "Cache/UserCache"; import messages from "./messages"; diff --git a/packages/app/src/Pages/settings/Index.tsx b/packages/app/src/Pages/settings/Index.tsx index f723120b..1ec45fde 100644 --- a/packages/app/src/Pages/settings/Index.tsx +++ b/packages/app/src/Pages/settings/Index.tsx @@ -12,8 +12,11 @@ const SettingsIndex = () => { const navigate = useNavigate(); function handleLogout() { - dispatch(logout()); - window.location.href = "/"; + dispatch( + logout(() => { + navigate("/"); + }) + ); } return ( diff --git a/packages/app/src/State/Login.ts b/packages/app/src/State/Login.ts index ca512c2a..41577334 100644 --- a/packages/app/src/State/Login.ts +++ b/packages/app/src/State/Login.ts @@ -1,11 +1,13 @@ import { AnyAction, createSlice, PayloadAction, ThunkAction } from "@reduxjs/toolkit"; import * as secp from "@noble/secp256k1"; +import { HexKey } from "@snort/nostr"; + import { DefaultRelays } from "Const"; -import { HexKey, TaggedRawEvent } from "@snort/nostr"; import { RelaySettings } from "@snort/nostr"; import type { AppDispatch, RootState } from "State/Store"; import { ImgProxySettings } from "Hooks/useImgProxy"; import { sanitizeRelayUrl } from "Util"; +import { DmCache } from "Cache"; const PrivateKeyItem = "secret"; const PublicKeyItem = "pubkey"; @@ -194,11 +196,6 @@ export interface LoginStore { */ readNotifications: number; - /** - * Encrypted DM's - */ - dms: TaggedRawEvent[]; - /** * Counter to trigger refresh of unread dms */ @@ -320,11 +317,6 @@ const LoginSlice = createSlice({ // preferences const pref = ReadPreferences(); state.preferences = pref; - - // disable reactions for logged out - if (state.loggedOut === true) { - state.preferences.enableReactions = false; - } }, setPrivateKey: (state, action: PayloadAction) => { state.loggedOut = false; @@ -445,34 +437,20 @@ const LoginSlice = createSlice({ state.latestMuted = createdAt; } }, - addDirectMessage: (state, action: PayloadAction>) => { - let n = action.payload; - if (!Array.isArray(n)) { - n = [n]; - } - - let didChange = false; - for (const x of n) { - if (!state.dms.some(a => a.id === x.id)) { - state.dms.push(x); - didChange = true; - } - } - - if (didChange) { - state.dms = [...state.dms]; - } - }, incDmInteraction: state => { state.dmInteraction += 1; }, - logout: state => { + logout: (state, payload: PayloadAction<() => void>) => { const relays = { ...state.relays }; state = Object.assign(state, InitState); state.loggedOut = true; window.localStorage.clear(); state.relays = relays; window.localStorage.setItem(RelayListKey, JSON.stringify(relays)); + queueMicrotask(async () => { + await DmCache.clear(); + payload.payload(); + }); }, markNotificationsRead: state => { state.readNotifications = Math.ceil(new Date().getTime() / 1000); @@ -502,7 +480,6 @@ export const { setPinned, setBookmarked, setBlocked, - addDirectMessage, incDmInteraction, logout, markNotificationsRead, diff --git a/packages/app/src/State/Users/UserCache.ts b/packages/app/src/State/Users/UserCache.ts deleted file mode 100644 index ad59297f..00000000 --- a/packages/app/src/State/Users/UserCache.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { HexKey } from "@snort/nostr"; -import { db } from "Db"; -import { LNURL } from "LNURL"; -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) { - // fetch zapper key - const lnurl = m.lud16 || m.lud06; - if (lnurl) { - try { - const svc = new LNURL(lnurl); - await svc.load(); - m.zapService = svc.zapperPubkey; - } catch { - console.debug("Failed to load LNURL for zapper pubkey", lnurl); - } - // ignored - } - - 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/System/ProfileCache.ts b/packages/app/src/System/ProfileCache.ts index 30021c2e..04a3a4b1 100644 --- a/packages/app/src/System/ProfileCache.ts +++ b/packages/app/src/System/ProfileCache.ts @@ -1,11 +1,11 @@ import { EventKind, HexKey, TaggedRawEvent } from "@snort/nostr"; import { ProfileCacheExpire } from "Const"; -import { mapEventToProfile, MetadataCache } from "State/Users"; -import { UserCache } from "State/Users/UserCache"; +import { mapEventToProfile, MetadataCache } from "Cache"; +import { UserCache } from "Cache/UserCache"; import { PubkeyReplaceableNoteStore, RequestBuilder, System } from "System"; import { unixNowMs } from "Util"; -class ProfileCache { +class ProfileLoaderService { /** * List of pubkeys to fetch metadata for */ @@ -43,7 +43,7 @@ class ProfileCache { const expire = unixNowMs() - ProfileCacheExpire; const expired = [...this.WantsMetadata] .filter(a => !missingFromCache.includes(a)) - .filter(a => (UserCache.get(a)?.loaded ?? 0) < expire); + .filter(a => (UserCache.getFromCache(a)?.loaded ?? 0) < expire); const missing = new Set([...missingFromCache, ...expired]); if (missing.size > 0) { console.debug(`Wants profiles: ${missingFromCache.length} missing, ${expired.length} expired`); @@ -97,4 +97,4 @@ class ProfileCache { } } -export const ProfileLoader = new ProfileCache(); +export const ProfileLoader = new ProfileLoaderService(); diff --git a/packages/app/src/Tasks/Nip5Task.tsx b/packages/app/src/Tasks/Nip5Task.tsx index 2bf3ba98..fd711a24 100644 --- a/packages/app/src/Tasks/Nip5Task.tsx +++ b/packages/app/src/Tasks/Nip5Task.tsx @@ -1,6 +1,6 @@ import { FormattedMessage } from "react-intl"; import { Link } from "react-router-dom"; -import { MetadataCache } from "State/Users"; +import { MetadataCache } from "Cache"; import { BaseUITask } from "Tasks"; export class Nip5Task extends BaseUITask { diff --git a/packages/app/src/Tasks/index.ts b/packages/app/src/Tasks/index.ts index 86be1705..384c80af 100644 --- a/packages/app/src/Tasks/index.ts +++ b/packages/app/src/Tasks/index.ts @@ -1,4 +1,4 @@ -import { MetadataCache } from "State/Users"; +import { MetadataCache } from "Cache"; export interface UITask { id: string; diff --git a/packages/app/src/Util.ts b/packages/app/src/Util.ts index 6535f8a4..1172cfc3 100644 --- a/packages/app/src/Util.ts +++ b/packages/app/src/Util.ts @@ -6,7 +6,7 @@ import { decode as invoiceDecode } from "light-bolt11-decoder"; import { bech32 } from "bech32"; import base32Decode from "base32-decode"; import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix, decodeTLV, TLVEntryType } from "@snort/nostr"; -import { MetadataCache } from "State/Users"; +import { MetadataCache } from "Cache"; export const sha256 = (str: string | Uint8Array): u256 => { return secp.utils.bytesToHex(hash(str));