wip: In-memory fallback for user state

This commit is contained in:
Alejandro Gomez 2023-01-20 23:32:02 +01:00
parent eb2137b16f
commit a8348597e2
No known key found for this signature in database
GPG Key ID: 4DF39E566658C817
19 changed files with 293 additions and 112 deletions

View File

@ -1,34 +0,0 @@
import { HexKey, TaggedRawEvent, UserMetadata } from "Nostr";
import { hexToBech32 } from "../Util";
export interface MetadataCache extends UserMetadata {
/**
* When the object was saved in cache
*/
loaded: number,
/**
* When the source metadata event was created
*/
created: number,
/**
* The pubkey of the owner of this metadata
*/
pubkey: HexKey
};
export function mapEventToProfile(ev: TaggedRawEvent) {
try {
let data: UserMetadata = JSON.parse(ev.content);
return {
pubkey: ev.pubkey,
npub: hexToBech32("npub", ev.pubkey),
created: ev.created_at,
loaded: new Date().getTime(),
...data
} as MetadataCache;
} catch (e) {
console.error("Failed to parse JSON", ev, e);
}
}

View File

@ -1,8 +1,7 @@
import Dexie, { Table } from "dexie";
import { MetadataCache } from "Db/User";
import { MetadataCache } from "State/Users";
import { hexToBech32 } from "Util";
export class SnortDB extends Dexie {
users!: Table<MetadataCache>;

View File

@ -1,11 +1,11 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import useProfile from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "Nostr";
import { hexToBech32, profileLink } from "Util";
export default function Mention({ pubkey }: { pubkey: HexKey }) {
const user = useProfile(pubkey)?.get(pubkey);
const user = useUserProfile(pubkey)
const name = useMemo(() => {
let name = hexToBech32("npub", pubkey).substring(0, 12);

View File

@ -13,7 +13,7 @@ import {
import AsyncButton from "Element/AsyncButton";
import LNURLTip from "Element/LNURLTip";
import Copy from "Element/Copy";
import useProfile from "Feed/ProfileFeed";
import { useUserProfile }from "Feed/ProfileFeed";
import useEventPublisher from "Feed/EventPublisher";
import { hexToBech32 } from "Util";
import { UserMetadata } from "Nostr";
@ -31,7 +31,7 @@ type ReduxStore = any;
export default function Nip5Service(props: Nip05ServiceProps) {
const navigate = useNavigate();
const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey);
const user = useProfile(pubkey);
const user = useUserProfile(pubkey);
const publisher = useEventPublisher();
const svc = new ServiceProvider(props.service);
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();

View File

@ -9,7 +9,7 @@ import { eventLink, getReactions, hexToBech32 } from "Util";
import NoteFooter from "Element/NoteFooter";
import NoteTime from "Element/NoteTime";
import EventKind from "Nostr/EventKind";
import useProfile from "Feed/ProfileFeed";
import { useUserProfiles } from "Feed/ProfileFeed";
import { TaggedRawEvent, u256 } from "Nostr";
import { useInView } from "react-intersection-observer";
@ -31,7 +31,7 @@ export default function Note(props: NoteProps) {
const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent } = props
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
const users = useProfile(pubKeys);
const users = useUserProfiles(pubKeys);
const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
const { ref, inView } = useInView({ triggerOnce: true });

View File

@ -9,7 +9,7 @@ import useEventPublisher from "Feed/EventPublisher";
import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util";
import { NoteCreator } from "Element/NoteCreator";
import LNURLTip from "Element/LNURLTip";
import useProfile from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import { default as NEvent } from "Nostr/Event";
import { RootState } from "State/Store";
import { HexKey, TaggedRawEvent } from "Nostr";
@ -26,7 +26,7 @@ export default function NoteFooter(props: NoteFooterProps) {
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey);
const author = useUserProfile(ev.RootPubKey);
const publisher = useEventPublisher();
const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false);

View File

@ -3,7 +3,7 @@ import "./NoteToSelf.css";
import { Link, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons"
import useProfile from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import Nip05 from "Element/Nip05";
import { profileLink } from "Util";
@ -15,7 +15,7 @@ export interface NoteToSelfProps {
};
function NoteLabel({pubkey, link}:NoteToSelfProps) {
const user = useProfile(pubkey)?.get(pubkey);
const user = useUserProfile(pubkey);
return (
<div>
Note to Self <FontAwesomeIcon icon={faCertificate} size="xs" />

View File

@ -2,12 +2,12 @@ import "./ProfileImage.css";
import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import useProfile from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import { hexToBech32, profileLink } from "Util";
import Avatar from "Element/Avatar"
import Nip05 from "Element/Nip05";
import { HexKey } from "Nostr";
import { MetadataCache } from "Db/User";
import { MetadataCache } from "State/Users";
export interface ProfileImageProps {
pubkey: HexKey,
@ -19,7 +19,7 @@ export interface ProfileImageProps {
export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }: ProfileImageProps) {
const navigate = useNavigate();
const user = useProfile(pubkey)?.get(pubkey);
const user = useUserProfile(pubkey);
const name = useMemo(() => {
return getDisplayName(user, pubkey);

View File

@ -3,7 +3,7 @@ import { ReactNode } from "react";
import ProfileImage from "Element/ProfileImage";
import FollowButton from "Element/FollowButton";
import useProfile from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "Nostr";
import { useInView } from "react-intersection-observer";
@ -16,7 +16,7 @@ export interface ProfilePreviewProps {
}
export default function ProfilePreview(props: ProfilePreviewProps) {
const pubkey = props.pubkey;
const user = useProfile(pubkey)?.get(pubkey);
const user = useUserProfile(pubkey);
const { ref, inView } = useInView({ triggerOnce: true });
const options = {
about: true,

View File

@ -10,7 +10,7 @@ import Invoice from "Element/Invoice";
import Hashtag from "Element/Hashtag";
import Tag from "Nostr/Tag";
import { MetadataCache } from "Db/User";
import { MetadataCache } from "State/Users";
import Mention from "Element/Mention";
import TidalEmbed from "Element/TidalEmbed";
import { useSelector } from 'react-redux';

View File

@ -2,7 +2,6 @@ import "@webscopeio/react-textarea-autocomplete/style.css";
import "./Textarea.css";
import { useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
import emoji from "@jukben/emoji-search";
import TextareaAutosize from "react-textarea-autosize";
@ -11,7 +10,7 @@ import Avatar from "Element/Avatar";
import Nip05 from "Element/Nip05";
import { hexToBech32 } from "Util";
import { db } from "Db";
import { MetadataCache } from "Db/User";
import { useQuery, MetadataCache } from "State/Users";
interface EmojiItemProps {
name: string
@ -45,16 +44,7 @@ const UserItem = (metadata: MetadataCache) => {
const Textarea = ({ users, onChange, ...rest }: any) => {
const [query, setQuery] = useState('')
const allUsers = useLiveQuery(
() => db.users
.where("npub").startsWithIgnoreCase(query)
.or("name").startsWithIgnoreCase(query)
.or("display_name").startsWithIgnoreCase(query)
.or("nip05").startsWithIgnoreCase(query)
.limit(5)
.toArray(),
[query],
);
const allUsers = useQuery(query)
const userDataProvider = (token: string) => {
setQuery(token)

View File

@ -2,12 +2,12 @@ import "./ZapButton.css";
import { faBolt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react";
import useProfile from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "Nostr";
import LNURLTip from "Element/LNURLTip";
const ZapButton = ({ pubkey }: { pubkey: HexKey }) => {
const profile = useProfile(pubkey)?.get(pubkey);
const profile = useUserProfile(pubkey);
const [zap, setZap] = useState(false);
const svc = profile?.lud16 || profile?.lud06;

View File

@ -7,9 +7,8 @@ import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
import { addDirectMessage, addNotifications, setFollows, setRelays } from "State/Login";
import { RootState } from "State/Store";
import { db } from "Db";
import { mapEventToProfile, MetadataCache, find, put, bulkGet } from "State/Users";
import useSubscription from "Feed/Subscription";
import { mapEventToProfile, MetadataCache } from "Db/User";
import { getDisplayName } from "Element/ProfileImage";
import { MentionRegex } from "Const";
@ -81,9 +80,9 @@ export default function useLoginFeed() {
return acc;
}, { created: 0, profile: <MetadataCache | null>null });
if (maxProfile.profile) {
let existing = await db.users.get(maxProfile.profile.pubkey);
let existing = await find(maxProfile.profile.pubkey);
if ((existing?.created ?? 0) < maxProfile.created) {
await db.users.put(maxProfile.profile);
await put(maxProfile.profile);
}
}
})().catch(console.warn);
@ -94,7 +93,8 @@ async function makeNotification(ev: TaggedRawEvent) {
switch (ev.kind) {
case EventKind.TextNote: {
const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]);
const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!);
const users = await bulkGet(Array.from(pubkeys))
// @ts-ignore
const fromUser = users.find(a => a?.pubkey === ev.pubkey);
const name = getDisplayName(fromUser, ev.pubkey);
const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture;

View File

@ -1,27 +1,13 @@
import { useLiveQuery } from "dexie-react-hooks";
import { useSelector } from "react-redux";
import { useEffect, useMemo } from "react";
import { db } from "Db";
import { MetadataCache } from "Db/User";
import { RootState } from "State/Store";
import { MetadataCache, find, bulkGet, useQuery, useKey, useKeys } from "State/Users";
import { HexKey } from "Nostr";
import { System } from "Nostr/System";
export default function useProfile(pubKey: HexKey | Array<HexKey> | undefined): Map<HexKey, MetadataCache> | undefined {
const user = useLiveQuery(async () => {
let userList = new Map<HexKey, MetadataCache>();
if (pubKey) {
if (Array.isArray(pubKey)) {
let ret = await db.users.bulkGet(pubKey);
let filtered = ret.filter(a => a !== undefined).map(a => a!);
return new Map(filtered.map(a => [a.pubkey, a]))
} else {
let ret = await db.users.get(pubKey);
if (ret) {
userList.set(ret.pubkey, ret);
}
}
}
return userList;
}, [pubKey]);
export function useUserProfile(pubKey: HexKey): MetadataCache | undefined {
const users = useKey(pubKey);
useEffect(() => {
if (pubKey) {
@ -30,5 +16,19 @@ export default function useProfile(pubKey: HexKey | Array<HexKey> | undefined):
}
}, [pubKey]);
return user;
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

@ -1,7 +1,6 @@
import { HexKey, TaggedRawEvent } from "Nostr";
import { ProfileCacheExpire } from "Const";
import { db } from "Db";
import { mapEventToProfile, MetadataCache } from "Db/User";
import { mapEventToProfile, MetadataCache, add, bulkAdd, bulkGet, find, put, update, bulkPut } from "State/Users";
import Connection, { RelaySettings } from "Nostr/Connection";
import Event from "Nostr/Event";
import EventKind from "Nostr/EventKind";
@ -167,7 +166,7 @@ export class NostrSystem {
async _FetchMetadata() {
let missing = new Set<HexKey>();
let meta = await db.users.bulkGet(Array.from(this.WantsMetadata));
let meta = await bulkGet(Array.from(this.WantsMetadata));
let expire = new Date().getTime() - ProfileCacheExpire;
for (let pk of this.WantsMetadata) {
let m = meta.find(a => a?.pubkey === pk);
@ -190,18 +189,18 @@ export class NostrSystem {
sub.OnEvent = async (e) => {
let profile = mapEventToProfile(e);
if (profile) {
let existing = await db.users.get(profile.pubkey);
if ((existing?.created ?? 0) < profile.created) {
await db.users.put(profile);
} else if (existing) {
await db.users.update(profile.pubkey, { loaded: new Date().getTime() });
let existing = await find(profile.pubkey);
if((existing?.created ?? 0) < profile.created) {
await put(profile);
} else if(existing) {
await update(profile.pubkey, { loaded: new Date().getTime() });
}
}
}
let results = await this.RequestSubscription(sub);
let couldNotFetch = Array.from(missing).filter(a => !results.some(b => b.pubkey === a));
console.debug("No profiles: ", couldNotFetch);
await db.users.bulkPut(couldNotFetch.map(a => {
await bulkPut(couldNotFetch.map(a => {
return {
pubkey: a,
loaded: new Date().getTime()

View File

@ -6,7 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGear, faEnvelope } from "@fortawesome/free-solid-svg-icons";
import { useNavigate, useParams } from "react-router-dom";
import useProfile from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import FollowButton from "Element/FollowButton";
import { extractLnAddress, parseId, hexToBech32 } from "Util";
import Avatar from "Element/Avatar";
@ -33,7 +33,7 @@ export default function ProfilePage() {
const params = useParams();
const navigate = useNavigate();
const id = useMemo(() => parseId(params.id!), [params]);
const user = useProfile(id)?.get(id);
const user = useUserProfile(id);
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
const isMe = loginPubKey === id;

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 useProfile from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import VoidUpload from "Feed/VoidUpload";
import { logout } from "State/Login";
import { hexToBech32, openFile } from "Util";
@ -21,7 +21,7 @@ export default function ProfileSettings() {
const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const dispatch = useDispatch();
const user = useProfile(id)?.get(id || "");
const user = useUserProfile(id!);
const publisher = useEventPublisher();
const [name, setName] = useState<string>();

View File

@ -1,9 +1,11 @@
import { configureStore } from "@reduxjs/toolkit";
import { reducer as LoginReducer } from "State/Login";
import { reducer as UsersReducer } from "State/Users";
const store = configureStore({
reducer: {
login: LoginReducer
login: LoginReducer,
users: UsersReducer,
}
});

225
src/State/Users.ts Normal file
View File

@ -0,0 +1,225 @@
import { useMemo } from "react";
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { useLiveQuery } from "dexie-react-hooks";
import { HexKey, TaggedRawEvent, UserMetadata } from "Nostr";
import { hexToBech32 } from "../Util";
import { db } from "Db";
import store from "State/Store";
export interface MetadataCache extends UserMetadata {
/**
* When the object was saved in cache
*/
loaded: number,
/**
* When the source metadata event was created
*/
created: number,
/**
* The pubkey of the owner of this metadata
*/
pubkey: HexKey
/**
* The bech32 encoded pubkey
*/
npub: string
};
export function mapEventToProfile(ev: TaggedRawEvent) {
try {
let data: UserMetadata = JSON.parse(ev.content);
return {
pubkey: ev.pubkey,
npub: hexToBech32("npub", ev.pubkey),
created: ev.created_at,
loaded: new Date().getTime(),
...data
} as MetadataCache;
} catch (e) {
console.error("Failed to parse JSON", ev, e);
}
}
export interface 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
}
}
});
const { setUsers } = UsersSlice.actions
function groupByPubkey(acc: Record<HexKey, MetadataCache>, user: MetadataCache) {
return { ...acc, [user.pubkey]: user }
}
function groupByPubkeyMap(acc: Map<HexKey, MetadataCache>, user: MetadataCache) {
acc.set(user.pubkey, user)
return acc
}
export const add = async (user: MetadataCache) => {
try {
return await db.users.add(user)
} catch (error) {
const state = store.getState()
const { users } = state.users
store.dispatch(setUsers({...users, [user.pubkey]: user }))
}
}
export const bulkAdd = async (newUserProfiles: MetadataCache[]) => {
try {
return await db.users.bulkAdd(newUserProfiles)
} catch (error) {
const state = store.getState()
const { users } = state.users
const newUsers = newUserProfiles.reduce(groupByPubkey, {})
store.dispatch(setUsers({...users, ...newUsers }))
}
}
export const bulkGet = async (pubKeys: HexKey[]) => {
try {
const ret = await db.users.bulkGet(pubKeys);
return ret.filter(a => a !== undefined).map(a => a!);
} catch (error) {
const state = store.getState()
const { users } = state.users
const ids = new Set([...pubKeys])
return Object.values(users).filter(user => {
return ids.has(user.pubkey)
})
}
}
export const find = async (pubKey: HexKey) => {
try {
const user = await db.users.get(pubKey);
return user
} catch (error) {
const { users } = store.getState()
return users.users[pubKey]
}
}
export const put = async (user: MetadataCache) => {
try {
await db.users.put(user)
} catch (error) {
const state = store.getState()
const { users } = state.users
store.dispatch(setUsers({...users, [user.pubkey]: user }))
}
}
export const update = async (pubKey: HexKey, fields: Record<string, any>) => {
try {
await db.users.update(pubKey, fields)
} catch (error) {
const state = store.getState()
const { users } = state.users
const current = users[pubKey]
store.dispatch(setUsers({...users, [pubKey]: {...current, ...fields }}))
}
}
export const bulkPut = async (newUsers: MetadataCache[]) => {
try {
await db.users.bulkPut(newUsers)
} catch (error) {
const state = store.getState()
const { users } = state.users
const newProfiles = newUsers.reduce(groupByPubkey, {})
store.dispatch(setUsers({ ...users, ...newProfiles }))
}
}
export function useQuery(query: string, limit: number = 5) {
const state = store.getState()
const { users } = state.users
const inMemoryUsers = useMemo(() => {
return Object.values(users).filter((user) => {
return user.name?.includes(query)
|| user.npub?.includes(query)
|| user.display_name?.includes(query)
|| user.nip05?.includes(query)
})
}, [users, query])
const allUsers = useLiveQuery(
() => db.users
.where("npub").startsWithIgnoreCase(query)
.or("name").startsWithIgnoreCase(query)
.or("display_name").startsWithIgnoreCase(query)
.or("nip05").startsWithIgnoreCase(query)
.limit(5)
.toArray()
.catch((err) => {
return inMemoryUsers
}),
[query],
)
return allUsers
}
export function useKey(pubKey: HexKey) {
const state = store.getState()
const { users } = state.users
const inMemoryUser = useMemo(() => {
return users[pubKey]
}, [users, pubKey])
const user = useLiveQuery(async () => {
if (pubKey) {
return await find(pubKey);
}
}, [pubKey]);
return user ?? inMemoryUser
}
export function useKeys(pubKeys: HexKey[]): Map<HexKey, MetadataCache> {
const state = store.getState()
const { users } = state.users
const inMemoryUsers = useMemo(() => {
const res = new Map()
Object.values(users).forEach(u => {
if (pubKeys.includes(u.pubkey)) {
res.set(u.pubkey, u)
}
})
return res
}, [users, pubKeys])
const dbUsers = useLiveQuery(async () => {
if (pubKeys) {
const ret = await bulkGet(pubKeys);
return new Map(ret.map(a => [a.pubkey, a]))
}
return new Map()
}, [pubKeys]);
return dbUsers || inMemoryUsers
}
export const reducer = UsersSlice.reducer;