feat: improve profile cache (again)

This commit is contained in:
Kieran 2023-03-03 14:30:31 +00:00
parent 27edf5f592
commit 32549522d4
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
32 changed files with 316 additions and 472 deletions

View File

@ -17,12 +17,28 @@ const STORES = {
};
export class SnortDB extends Dexie {
ready = false;
users!: Table<MetadataCache>;
constructor() {
super(NAME);
this.version(VERSION).stores(STORES);
}
isAvailable() {
if ("indexedDB" in window) {
return new Promise<boolean>(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();

View File

@ -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 }) => {

View File

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

View File

@ -54,7 +54,7 @@ export default function DM(props: DMProps) {
<NoteTime from={props.data.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
</div>
<div className="w-max">
<Text content={content} tags={[]} users={new Map()} creator={otherPubkey} />
<Text content={content} tags={[]} creator={otherPubkey} />
</div>
</div>
);

View File

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

View File

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

View File

@ -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) {
</b>
);
}
return <Text content={body} tags={ev.Tags} users={users || new Map()} creator={ev.PubKey} />;
return <Text content={body} tags={ev.Tags} creator={ev.PubKey} />;
}, [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: <Link to={`/p/${npub}`}>{u.name ? `@${u.name}` : shortNpub}</Link>,
});
} else {
mentions.push({
pk,
name: shortNpub,
link: <Link to={`/p/${npub}`}>{shortNpub}</Link>,
});
}
mentions.push({
pk,
name: u?.name ?? shortNpub,
link: <Link to={profileLink(pk)}>{u?.name ? `@${u.name}` : shortNpub}</Link>,
});
}
mentions.sort(a => (a.name.startsWith("npub") ? 1 : -1));
const othersLength = mentions.length - maxMentions;

View File

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

View File

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

View File

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

View File

@ -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<string, MetadataCache>;
}
export interface TextProps {
content: string;
creator: HexKey;
tags: Tag[];
users: Map<string, MetadataCache>;
}
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 }) => <HyperText link={x.href ?? ""} creator={creator} />,
li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags, users }),
li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags }),
};
}, [content]);

View File

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

View File

@ -109,7 +109,7 @@ const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean
</div>
{content.length > 0 && zapper && (
<div className="body">
<Text creator={zapper} content={content} tags={[]} users={new Map()} />
<Text creator={zapper} content={content} tags={[]} />
</div>
)}
</div>

View File

@ -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 }) => {

View File

@ -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<RootState, undefined, AnyAction>)(sendNotification(notification));
}
});
});
}, [dispatch, notificationFeed.store, ReduxUDB, readNotifications]);
}, [dispatch, notificationFeed.store, readNotifications]);
useEffect(() => {
const muted = getMutedKeys(mutedFeed.store.notes);

View File

@ -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<HexKey>): Map<HexKey, MetadataCache> | undefined {
const users = useKeys(pubKeys);
useEffect(() => {
if (pubKeys) {
System.TrackMetadata(pubKeys);
return () => System.UntrackMetadata(pubKeys);
}
}, [pubKeys]);
return users;
}

View File

@ -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<MetadataCache | undefined>(
h => UserCache.hook(h, pubKey),
() => UserCache.get(pubKey)
);
useEffect(() => {
if (pubKey) {
System.TrackMetadata(pubKey);
return () => System.UntrackMetadata(pubKey);
}
}, [pubKey]);
return user;
}

View File

@ -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<NotificationRequest | null> {
export async function makeNotification(ev: TaggedRawEvent): Promise<NotificationRequest | null> {
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,
};

View File

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

View File

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

View File

@ -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<string>();
const [keyword, setKeyword] = useState<string | undefined>(params.keyword);
const allUsers = useQuery(keyword || "");
const [allUsers, setAllUsers] = useState<MetadataCache[]>();
useEffect(() => {
if (keyword) {
// "navigate" changing only url
router.navigate(`/search/${encodeURIComponent(keyword)}`);
UserCache.search(keyword).then(v => setAllUsers(v));
}
}, [keyword]);

View File

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

View File

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

View File

@ -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<DbType>) => {
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

View File

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

View File

@ -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<boolean>;
query(str: string): Promise<MetadataCache[]>;
find(key: HexKey): Promise<MetadataCache | undefined>;
add(user: MetadataCache): Promise<void>;
put(user: MetadataCache): Promise<void>;
bulkAdd(users: MetadataCache[]): Promise<void>;
bulkGet(keys: HexKey[]): Promise<MetadataCache[]>;
bulkPut(users: MetadataCache[]): Promise<void>;
update(key: HexKey, fields: Record<string, string | number>): Promise<void>;
}
export interface UsersStore {
/**
* A list of seen users
*/
users: Record<HexKey, MetadataCache>;
}
const InitState = { users: {} } as UsersStore;
const UsersSlice = createSlice({
name: "Users",
initialState: InitState,
reducers: {
setUsers(state, action: PayloadAction<Record<HexKey, MetadataCache>>) {
state.users = action.payload;
},
},
});
export const { setUsers } = UsersSlice.actions;
export const reducer = UsersSlice.reducer;

View File

@ -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<boolean>(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<string, string | number>) {
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<string, string | number>) {
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();

View File

@ -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<HexKey, MetadataCache> {
const { users } = useSelector((state: RootState) => state.users);
return new Map((pubKeys ?? []).map(a => [a, users[a]]));
}

View File

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

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

View File

@ -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<TaggedRawEvent[]>(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<HexKey>();
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);
}
}

View File

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