This commit is contained in:
Kieran 2023-03-29 13:10:22 +01:00
parent 8c44d123bd
commit c731c65661
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
27 changed files with 384 additions and 239 deletions

11
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
"**/node_modules": true
}
}

View File

@ -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<RawEvent> {
constructor() {
super("DMCache", db.dms);
}
key(of: RawEvent): string {
return of.id;
}
override async preload(): Promise<void> {
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<RawEvent> {
return [...this.cache.values()];
}
takeSnapshot(): Array<RawEvent> {
return this.allDms();
}
}
export const DmCache = new DMCache();

View File

@ -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<TCached> {
#name: string;
#table: Table<TCached>;
#hooks: Array<HookFilter> = [];
#snapshot: Readonly<Array<TCached>> = [];
#changed = true;
protected onTable: Set<string> = new Set();
protected cache: Map<string, TCached> = new Map();
constructor(name: string, table: Table<TCached>) {
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<string>(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<string>) {
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<TCached>) {
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<string>): Promise<Array<string>> {
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<string>) {
this.#changed = true;
this.#hooks.filter(a => keys.includes(a.key)).forEach(h => h.fn());
}
abstract key(of: TCached): string;
abstract takeSnapshot(): Array<TCached>;
}

View File

@ -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<MetadataCache> {
constructor() {
super("UserCache", db.users);
}
key(of: MetadataCache): string {
return of.pubkey;
}
async search(q: string): Promise<Array<MetadataCache>> {
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();

View File

@ -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 };

View File

@ -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<MetadataCache>;
relayMetrics!: Table<RelayMetrics>;
userRelays!: Table<UsersRelays>;
events!: Table<RawEvent>;
dms!: Table<RawEvent>;
constructor() {
super(NAME);

View File

@ -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 ? <option value={p}>{profile?.display_name || profile?.name}</option> : null;
}

View File

@ -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 (
<button
className="secondary"
type="button"
onClick={() => {
dispatch(logout());
window.location.href = "/";
dispatch(
logout(() => {
navigate("/");
})
);
}}>
<FormattedMessage {...messages.Logout} />
</button>

View File

@ -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({

View File

@ -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 {

View File

@ -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";

View File

@ -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");
}

View File

@ -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]);

View File

@ -0,0 +1,9 @@
import { DmCache } from "Cache";
import { useSyncExternalStore } from "react";
export function useDmCache() {
return useSyncExternalStore(
c => DmCache.hook(c, undefined),
() => DmCache.snapshot()
);
}

View File

@ -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<MetadataCache | undefined>(
h => UserCache.hook(h, pubKey),
() => UserCache.get(pubKey)
() => UserCache.getFromCache(pubKey)
);
useEffect(() => {

View File

@ -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<NotificationRequest | null> {
switch (ev.kind) {
@ -18,10 +18,10 @@ export async function makeNotification(ev: TaggedRawEvent): Promise<Notification
const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => 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 {

View File

@ -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<string>();
const dmListRef = useRef<HTMLDivElement>(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);
}

View File

@ -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,

View File

@ -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<RootState, HexKey | undefined>(s => s.login.publicKey);
const dms = useSelector<RootState, RawEvent[]>(s => s.login.dms);
const dmInteraction = useSelector<RootState, number>(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<RawEvent>, 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);
}

View File

@ -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";

View File

@ -12,8 +12,11 @@ const SettingsIndex = () => {
const navigate = useNavigate();
function handleLogout() {
dispatch(logout());
window.location.href = "/";
dispatch(
logout(() => {
navigate("/");
})
);
}
return (

View File

@ -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<HexKey>) => {
state.loggedOut = false;
@ -445,34 +437,20 @@ const LoginSlice = createSlice({
state.latestMuted = createdAt;
}
},
addDirectMessage: (state, action: PayloadAction<TaggedRawEvent | Array<TaggedRawEvent>>) => {
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,

View File

@ -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<HexKey, MetadataCache>;
#hooks: Array<HookFilter>;
#diskCache: Set<HexKey>;
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<HexKey>(keys.map(a => a as string));
}
}
async search(q: string): Promise<Array<MetadataCache>> {
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<HexKey>): Promise<Array<HexKey>> {
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<HexKey>) {
this.#hooks.filter(a => keys.includes(a.key)).forEach(h => h.fn());
}
}
export const UserCache = new UserProfileCache();

View File

@ -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();

View File

@ -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 {

View File

@ -1,4 +1,4 @@
import { MetadataCache } from "State/Users";
import { MetadataCache } from "Cache";
export interface UITask {
id: string;

View File

@ -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));