feat: improve profile cache (again)
This commit is contained in:
parent
27edf5f592
commit
32549522d4
@ -17,12 +17,28 @@ const STORES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class SnortDB extends Dexie {
|
export class SnortDB extends Dexie {
|
||||||
|
ready = false;
|
||||||
users!: Table<MetadataCache>;
|
users!: Table<MetadataCache>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(NAME);
|
super(NAME);
|
||||||
this.version(VERSION).stores(STORES);
|
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();
|
export const db = new SnortDB();
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import "./Avatar.css";
|
import "./Avatar.css";
|
||||||
import Nostrich from "nostrich.webp";
|
import Nostrich from "nostrich.webp";
|
||||||
|
|
||||||
import { CSSProperties, useEffect, useState } from "react";
|
import { CSSProperties, useEffect, useState } from "react";
|
||||||
import type { UserMetadata } from "@snort/nostr";
|
import type { UserMetadata } from "@snort/nostr";
|
||||||
|
|
||||||
import useImgProxy from "Hooks/useImgProxy";
|
import useImgProxy from "Hooks/useImgProxy";
|
||||||
|
|
||||||
const Avatar = ({ user, ...rest }: { user?: UserMetadata; onClick?: () => void }) => {
|
const Avatar = ({ user, ...rest }: { user?: UserMetadata; onClick?: () => void }) => {
|
||||||
|
@ -5,8 +5,8 @@ import { FormattedMessage } from "react-intl";
|
|||||||
import { dedupeByPubkey } from "Util";
|
import { dedupeByPubkey } from "Util";
|
||||||
import Note from "Element/Note";
|
import Note from "Element/Note";
|
||||||
import { HexKey, TaggedRawEvent } from "@snort/nostr";
|
import { HexKey, TaggedRawEvent } from "@snort/nostr";
|
||||||
import { useUserProfiles } from "Feed/ProfileFeed";
|
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
|
import { UserCache } from "State/Users/UserCache";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
|
||||||
@ -22,10 +22,9 @@ const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
|
|||||||
const ps = useMemo(() => {
|
const ps = useMemo(() => {
|
||||||
return dedupeByPubkey(bookmarks).map(ev => ev.pubkey);
|
return dedupeByPubkey(bookmarks).map(ev => ev.pubkey);
|
||||||
}, [bookmarks]);
|
}, [bookmarks]);
|
||||||
const profiles = useUserProfiles(ps);
|
|
||||||
|
|
||||||
function renderOption(p: HexKey) {
|
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;
|
return profile ? <option value={p}>{profile?.display_name || profile?.name}</option> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ export default function DM(props: DMProps) {
|
|||||||
<NoteTime from={props.data.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
|
<NoteTime from={props.data.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-max">
|
<div className="w-max">
|
||||||
<Text content={content} tags={[]} users={new Map()} creator={otherPubkey} />
|
<Text content={content} tags={[]} creator={otherPubkey} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Hooks/useUserProfile";
|
||||||
import { HexKey } from "@snort/nostr";
|
import { HexKey } from "@snort/nostr";
|
||||||
import { hexToBech32, profileLink } from "Util";
|
import { hexToBech32, profileLink } from "Util";
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ import {
|
|||||||
import AsyncButton from "Element/AsyncButton";
|
import AsyncButton from "Element/AsyncButton";
|
||||||
import SendSats from "Element/SendSats";
|
import SendSats from "Element/SendSats";
|
||||||
import Copy from "Element/Copy";
|
import Copy from "Element/Copy";
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Hooks/useUserProfile";
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import { debounce } from "Util";
|
import { debounce } from "Util";
|
||||||
import { UserMetadata } from "@snort/nostr";
|
import { UserMetadata } from "@snort/nostr";
|
||||||
|
@ -18,14 +18,15 @@ import {
|
|||||||
hexToBech32,
|
hexToBech32,
|
||||||
normalizeReaction,
|
normalizeReaction,
|
||||||
Reaction,
|
Reaction,
|
||||||
|
profileLink,
|
||||||
} from "Util";
|
} from "Util";
|
||||||
import NoteFooter, { Translation } from "Element/NoteFooter";
|
import NoteFooter, { Translation } from "Element/NoteFooter";
|
||||||
import NoteTime from "Element/NoteTime";
|
import NoteTime from "Element/NoteTime";
|
||||||
import { useUserProfiles } from "Feed/ProfileFeed";
|
|
||||||
import { TaggedRawEvent, u256, HexKey, Event as NEvent, EventKind } from "@snort/nostr";
|
import { TaggedRawEvent, u256, HexKey, Event as NEvent, EventKind } from "@snort/nostr";
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
import { setPinned, setBookmarked } from "State/Login";
|
import { setPinned, setBookmarked } from "State/Login";
|
||||||
import type { RootState } from "State/Store";
|
import type { RootState } from "State/Store";
|
||||||
|
import { UserCache } from "State/Users/UserCache";
|
||||||
|
|
||||||
import messages from "./messages";
|
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 { data, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false } = props;
|
||||||
const [showReactions, setShowReactions] = useState(false);
|
const [showReactions, setShowReactions] = useState(false);
|
||||||
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
|
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 deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
|
||||||
const { isMuted } = useModeration();
|
const { isMuted } = useModeration();
|
||||||
const isOpMuted = isMuted(ev.PubKey);
|
const isOpMuted = isMuted(ev.PubKey);
|
||||||
@ -162,7 +161,7 @@ export default function Note(props: NoteProps) {
|
|||||||
</b>
|
</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]);
|
}, [ev]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@ -188,22 +187,14 @@ export default function Note(props: NoteProps) {
|
|||||||
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||||
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
|
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
|
||||||
for (const pk of ev.Thread?.PubKeys ?? []) {
|
for (const pk of ev.Thread?.PubKeys ?? []) {
|
||||||
const u = users?.get(pk);
|
const u = UserCache.get(pk);
|
||||||
const npub = hexToBech32("npub", pk);
|
const npub = hexToBech32("npub", pk);
|
||||||
const shortNpub = npub.substring(0, 12);
|
const shortNpub = npub.substring(0, 12);
|
||||||
if (u) {
|
|
||||||
mentions.push({
|
mentions.push({
|
||||||
pk,
|
pk,
|
||||||
name: u.name ?? shortNpub,
|
name: u?.name ?? shortNpub,
|
||||||
link: <Link to={`/p/${npub}`}>{u.name ? `@${u.name}` : shortNpub}</Link>,
|
link: <Link to={profileLink(pk)}>{u?.name ? `@${u.name}` : shortNpub}</Link>,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
mentions.push({
|
|
||||||
pk,
|
|
||||||
name: shortNpub,
|
|
||||||
link: <Link to={`/p/${npub}`}>{shortNpub}</Link>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
mentions.sort(a => (a.name.startsWith("npub") ? 1 : -1));
|
mentions.sort(a => (a.name.startsWith("npub") ? 1 : -1));
|
||||||
const othersLength = mentions.length - maxMentions;
|
const othersLength = mentions.length - maxMentions;
|
||||||
|
@ -15,7 +15,7 @@ import { NoteCreator } from "Element/NoteCreator";
|
|||||||
import Reactions from "Element/Reactions";
|
import Reactions from "Element/Reactions";
|
||||||
import SendSats from "Element/SendSats";
|
import SendSats from "Element/SendSats";
|
||||||
import { ParsedZap, ZapsSummary } from "Element/Zap";
|
import { ParsedZap, ZapsSummary } from "Element/Zap";
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Hooks/useUserProfile";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
|
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
|
@ -2,7 +2,7 @@ import "./ProfileImage.css";
|
|||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Hooks/useUserProfile";
|
||||||
import { hexToBech32, profileLink } from "Util";
|
import { hexToBech32, profileLink } from "Util";
|
||||||
import Avatar from "Element/Avatar";
|
import Avatar from "Element/Avatar";
|
||||||
import Nip05 from "Element/Nip05";
|
import Nip05 from "Element/Nip05";
|
||||||
|
@ -3,7 +3,7 @@ import { ReactNode } from "react";
|
|||||||
|
|
||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
import FollowButton from "Element/FollowButton";
|
import FollowButton from "Element/FollowButton";
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Hooks/useUserProfile";
|
||||||
import { HexKey } from "@snort/nostr";
|
import { HexKey } from "@snort/nostr";
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
|
@ -10,7 +10,6 @@ import Invoice from "Element/Invoice";
|
|||||||
import Hashtag from "Element/Hashtag";
|
import Hashtag from "Element/Hashtag";
|
||||||
|
|
||||||
import { Tag } from "@snort/nostr";
|
import { Tag } from "@snort/nostr";
|
||||||
import { MetadataCache } from "State/Users";
|
|
||||||
import Mention from "Element/Mention";
|
import Mention from "Element/Mention";
|
||||||
import HyperText from "Element/HyperText";
|
import HyperText from "Element/HyperText";
|
||||||
import { HexKey } from "@snort/nostr";
|
import { HexKey } from "@snort/nostr";
|
||||||
@ -21,17 +20,15 @@ export type Fragment = string | React.ReactNode;
|
|||||||
export interface TextFragment {
|
export interface TextFragment {
|
||||||
body: React.ReactNode[];
|
body: React.ReactNode[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
users: Map<string, MetadataCache>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextProps {
|
export interface TextProps {
|
||||||
content: string;
|
content: string;
|
||||||
creator: HexKey;
|
creator: HexKey;
|
||||||
tags: Tag[];
|
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[]) {
|
function extractLinks(fragments: Fragment[]) {
|
||||||
return fragments
|
return fragments
|
||||||
.map(f => {
|
.map(f => {
|
||||||
@ -143,9 +140,9 @@ export default function Text({ content, tags, creator, users }: TextProps) {
|
|||||||
|
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
return {
|
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} />,
|
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]);
|
}, [content]);
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import "@webscopeio/react-textarea-autocomplete/style.css";
|
import "@webscopeio/react-textarea-autocomplete/style.css";
|
||||||
import "./Textarea.css";
|
import "./Textarea.css";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useIntl } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
|
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
|
||||||
import emoji from "@jukben/emoji-search";
|
import emoji from "@jukben/emoji-search";
|
||||||
@ -11,7 +10,7 @@ import Avatar from "Element/Avatar";
|
|||||||
import Nip05 from "Element/Nip05";
|
import Nip05 from "Element/Nip05";
|
||||||
import { hexToBech32 } from "Util";
|
import { hexToBech32 } from "Util";
|
||||||
import { MetadataCache } from "State/Users";
|
import { MetadataCache } from "State/Users";
|
||||||
import { useQuery } from "State/Users/Hooks";
|
import { UserCache } from "State/Users/UserCache";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
|
||||||
@ -53,14 +52,10 @@ interface TextareaProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Textarea = (props: TextareaProps) => {
|
const Textarea = (props: TextareaProps) => {
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
const allUsers = useQuery(query);
|
const userDataProvider = async (token: string) => {
|
||||||
|
return await UserCache.search(token);
|
||||||
const userDataProvider = (token: string) => {
|
|
||||||
setQuery(token);
|
|
||||||
return allUsers ?? [];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const emojiDataProvider = (token: string) => {
|
const emojiDataProvider = (token: string) => {
|
||||||
|
@ -109,7 +109,7 @@ const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean
|
|||||||
</div>
|
</div>
|
||||||
{content.length > 0 && zapper && (
|
{content.length > 0 && zapper && (
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<Text creator={zapper} content={content} tags={[]} users={new Map()} />
|
<Text creator={zapper} content={content} tags={[]} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { HexKey } from "@snort/nostr";
|
import { HexKey } from "@snort/nostr";
|
||||||
|
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Hooks/useUserProfile";
|
||||||
import SendSats from "Element/SendSats";
|
import SendSats from "Element/SendSats";
|
||||||
|
|
||||||
const ZapButton = ({ pubkey, lnurl }: { pubkey: HexKey; lnurl?: string }) => {
|
const ZapButton = ({ pubkey, lnurl }: { pubkey: HexKey; lnurl?: string }) => {
|
||||||
|
@ -18,14 +18,11 @@ import {
|
|||||||
setLatestNotifications,
|
setLatestNotifications,
|
||||||
} from "State/Login";
|
} from "State/Login";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { mapEventToProfile, MetadataCache } from "State/Users";
|
|
||||||
import useSubscription from "Feed/Subscription";
|
import useSubscription from "Feed/Subscription";
|
||||||
import { barrierNip07 } from "Feed/EventPublisher";
|
import { barrierNip07 } from "Feed/EventPublisher";
|
||||||
import { getMutedKeys } from "Feed/MuteList";
|
import { getMutedKeys } from "Feed/MuteList";
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
import { unwrap } from "Util";
|
|
||||||
import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit";
|
import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit";
|
||||||
import { ReduxUDB } from "State/Users/Db";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Managed loading data for the current logged in user
|
* Managed loading data for the current logged in user
|
||||||
@ -46,7 +43,7 @@ export default function useLoginFeed() {
|
|||||||
const sub = new Subscriptions();
|
const sub = new Subscriptions();
|
||||||
sub.Id = `login:meta`;
|
sub.Id = `login:meta`;
|
||||||
sub.Authors = new Set([pubKey]);
|
sub.Authors = new Set([pubKey]);
|
||||||
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]);
|
sub.Kinds = new Set([EventKind.ContactList]);
|
||||||
sub.Limit = 2;
|
sub.Limit = 2;
|
||||||
|
|
||||||
return sub;
|
return sub;
|
||||||
@ -148,12 +145,6 @@ export default function useLoginFeed() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList);
|
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) {
|
for (const cl of contactList) {
|
||||||
if (cl.content !== "" && cl.content !== "{}") {
|
if (cl.content !== "" && cl.content !== "{}") {
|
||||||
const relays = JSON.parse(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]);
|
const pTags = cl.tags.filter(a => a[0] === "p").map(a => a[1]);
|
||||||
dispatch(setFollows({ keys: pTags, createdAt: cl.created_at }));
|
dispatch(setFollows({ keys: pTags, createdAt: cl.created_at }));
|
||||||
}
|
}
|
||||||
|
}, [dispatch, metadataFeed.store]);
|
||||||
(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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const replies = notificationFeed.store.notes.filter(
|
const replies = notificationFeed.store.notes.filter(
|
||||||
@ -189,13 +161,13 @@ export default function useLoginFeed() {
|
|||||||
);
|
);
|
||||||
replies.forEach(nx => {
|
replies.forEach(nx => {
|
||||||
dispatch(setLatestNotifications(nx.created_at));
|
dispatch(setLatestNotifications(nx.created_at));
|
||||||
makeNotification(ReduxUDB, nx).then(notification => {
|
makeNotification(nx).then(notification => {
|
||||||
if (notification) {
|
if (notification) {
|
||||||
(dispatch as ThunkDispatch<RootState, undefined, AnyAction>)(sendNotification(notification));
|
(dispatch as ThunkDispatch<RootState, undefined, AnyAction>)(sendNotification(notification));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [dispatch, notificationFeed.store, ReduxUDB, readNotifications]);
|
}, [dispatch, notificationFeed.store, readNotifications]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const muted = getMutedKeys(mutedFeed.store.notes);
|
const muted = getMutedKeys(mutedFeed.store.notes);
|
||||||
|
@ -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;
|
|
||||||
}
|
|
21
packages/app/src/Hooks/useUserProfile.ts
Normal file
21
packages/app/src/Hooks/useUserProfile.ts
Normal 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;
|
||||||
|
}
|
@ -3,25 +3,30 @@ import Nostrich from "nostrich.webp";
|
|||||||
import { TaggedRawEvent } from "@snort/nostr";
|
import { TaggedRawEvent } from "@snort/nostr";
|
||||||
import { EventKind } from "@snort/nostr";
|
import { EventKind } from "@snort/nostr";
|
||||||
import type { NotificationRequest } from "State/Login";
|
import type { NotificationRequest } from "State/Login";
|
||||||
import { MetadataCache, UsersDb } from "State/Users";
|
import { MetadataCache } from "State/Users";
|
||||||
import { getDisplayName } from "Element/ProfileImage";
|
import { getDisplayName } from "Element/ProfileImage";
|
||||||
import { MentionRegex } from "Const";
|
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) {
|
switch (ev.kind) {
|
||||||
case EventKind.TextNote: {
|
case EventKind.TextNote: {
|
||||||
if (ev.tags.some(tagFilterOfTextRepost(ev))) {
|
if (ev.tags.some(tagFilterOfTextRepost(ev))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1])]);
|
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));
|
await UserCache.buffer([...pubkeys]);
|
||||||
const fromUser = users.find(a => a?.pubkey === ev.pubkey);
|
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 name = getDisplayName(fromUser, ev.pubkey);
|
||||||
const avatarUrl = fromUser?.picture || Nostrich;
|
const avatarUrl = fromUser?.picture || Nostrich;
|
||||||
return {
|
return {
|
||||||
title: `Reply from ${name}`,
|
title: `Reply from ${name}`,
|
||||||
body: replaceTagsWithUser(ev, users).substring(0, 50),
|
body: replaceTagsWithUser(ev, allUsers).substring(0, 50),
|
||||||
icon: avatarUrl,
|
icon: avatarUrl,
|
||||||
timestamp: ev.created_at * 1000,
|
timestamp: ev.created_at * 1000,
|
||||||
};
|
};
|
||||||
|
@ -14,12 +14,13 @@ import { totalUnread } from "Pages/MessagesPage";
|
|||||||
import { SearchRelays, SnortPubKey } from "Const";
|
import { SearchRelays, SnortPubKey } from "Const";
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
import { IndexedUDB } from "State/Users/Db";
|
|
||||||
import { bech32ToHex } from "Util";
|
import { bech32ToHex } from "Util";
|
||||||
import { NoteCreator } from "Element/NoteCreator";
|
import { NoteCreator } from "Element/NoteCreator";
|
||||||
import { RelaySettings } from "@snort/nostr";
|
import { RelaySettings } from "@snort/nostr";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
import { db } from "Db";
|
||||||
|
import { UserCache } from "State/Users/UserCache";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -119,16 +120,13 @@ export default function Layout() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// check DB support then init
|
// check DB support then init
|
||||||
IndexedUDB.isAvailable().then(async a => {
|
db.isAvailable().then(async a => {
|
||||||
const dbType = a ? "indexdDb" : "redux";
|
db.ready = a;
|
||||||
|
if (a) {
|
||||||
// cleanup on load
|
await UserCache.preload();
|
||||||
if (dbType === "indexdDb") {
|
|
||||||
IndexedUDB.ready = true;
|
|
||||||
}
|
}
|
||||||
|
console.debug(`Using db: ${a ? "IndexedDB" : "In-Memory"}`);
|
||||||
console.debug(`Using db: ${dbType}`);
|
dispatch(init());
|
||||||
dispatch(init(dbType));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ("registerProtocolHandler" in window.navigator) {
|
if ("registerProtocolHandler" in window.navigator) {
|
||||||
|
@ -18,7 +18,7 @@ import usePinnedFeed from "Feed/PinnedFeed";
|
|||||||
import useBookmarkFeed from "Feed/BookmarkFeed";
|
import useBookmarkFeed from "Feed/BookmarkFeed";
|
||||||
import useFollowersFeed from "Feed/FollowersFeed";
|
import useFollowersFeed from "Feed/FollowersFeed";
|
||||||
import useFollowsFeed from "Feed/FollowsFeed";
|
import useFollowsFeed from "Feed/FollowsFeed";
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Hooks/useUserProfile";
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
import useZapsFeed from "Feed/ZapsFeed";
|
import useZapsFeed from "Feed/ZapsFeed";
|
||||||
import { default as ZapElement } from "Element/Zap";
|
import { default as ZapElement } from "Element/Zap";
|
||||||
@ -69,7 +69,6 @@ export default function ProfilePage() {
|
|||||||
const about = Text({
|
const about = Text({
|
||||||
content: aboutText,
|
content: aboutText,
|
||||||
tags: [],
|
tags: [],
|
||||||
users: new Map(),
|
|
||||||
creator: "",
|
creator: "",
|
||||||
});
|
});
|
||||||
const npub = !id?.startsWith("npub") ? hexToBech32("npub", id || undefined) : id;
|
const npub = !id?.startsWith("npub") ? hexToBech32("npub", id || undefined) : id;
|
||||||
|
@ -7,7 +7,8 @@ import { debounce } from "Util";
|
|||||||
import { router } from "index";
|
import { router } from "index";
|
||||||
import { SearchRelays } from "Const";
|
import { SearchRelays } from "Const";
|
||||||
import { System } from "System";
|
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";
|
import messages from "./messages";
|
||||||
|
|
||||||
@ -16,12 +17,13 @@ const SearchPage = () => {
|
|||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const [search, setSearch] = useState<string>();
|
const [search, setSearch] = useState<string>();
|
||||||
const [keyword, setKeyword] = useState<string | undefined>(params.keyword);
|
const [keyword, setKeyword] = useState<string | undefined>(params.keyword);
|
||||||
const allUsers = useQuery(keyword || "");
|
const [allUsers, setAllUsers] = useState<MetadataCache[]>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
// "navigate" changing only url
|
// "navigate" changing only url
|
||||||
router.navigate(`/search/${encodeURIComponent(keyword)}`);
|
router.navigate(`/search/${encodeURIComponent(keyword)}`);
|
||||||
|
UserCache.search(keyword).then(v => setAllUsers(v));
|
||||||
}
|
}
|
||||||
}, [keyword]);
|
}, [keyword]);
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ import { services } from "Pages/Verification";
|
|||||||
import Nip5Service from "Element/Nip5Service";
|
import Nip5Service from "Element/Nip5Service";
|
||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
import type { RootState } from "State/Store";
|
import type { RootState } from "State/Store";
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Hooks/useUserProfile";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
import { faShop } from "@fortawesome/free-solid-svg-icons";
|
import { faShop } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Hooks/useUserProfile";
|
||||||
import { hexToBech32, openFile } from "Util";
|
import { hexToBech32, openFile } from "Util";
|
||||||
import Copy from "Element/Copy";
|
import Copy from "Element/Copy";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
|
@ -82,14 +82,7 @@ export interface UserPreferences {
|
|||||||
defaultZapAmount: number;
|
defaultZapAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DbType = "indexdDb" | "redux";
|
|
||||||
|
|
||||||
export interface LoginStore {
|
export interface LoginStore {
|
||||||
/**
|
|
||||||
* Which db we will use to cache data
|
|
||||||
*/
|
|
||||||
useDb: DbType;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If there is no login
|
* If there is no login
|
||||||
*/
|
*/
|
||||||
@ -208,7 +201,6 @@ export const DefaultImgProxy = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const InitState = {
|
export const InitState = {
|
||||||
useDb: "redux",
|
|
||||||
loggedOut: undefined,
|
loggedOut: undefined,
|
||||||
publicKey: undefined,
|
publicKey: undefined,
|
||||||
privateKey: undefined,
|
privateKey: undefined,
|
||||||
@ -267,8 +259,7 @@ const LoginSlice = createSlice({
|
|||||||
name: "Login",
|
name: "Login",
|
||||||
initialState: InitState,
|
initialState: InitState,
|
||||||
reducers: {
|
reducers: {
|
||||||
init: (state, action: PayloadAction<DbType>) => {
|
init: state => {
|
||||||
state.useDb = action.payload;
|
|
||||||
state.privateKey = window.localStorage.getItem(PrivateKeyItem) ?? undefined;
|
state.privateKey = window.localStorage.getItem(PrivateKeyItem) ?? undefined;
|
||||||
if (state.privateKey) {
|
if (state.privateKey) {
|
||||||
window.localStorage.removeItem(PublicKeyItem); // reset nip07 if using private key
|
window.localStorage.removeItem(PublicKeyItem); // reset nip07 if using private key
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit";
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
import { reducer as LoginReducer } from "State/Login";
|
import { reducer as LoginReducer } from "State/Login";
|
||||||
import { reducer as UsersReducer } from "State/Users";
|
|
||||||
import { reducer as CacheReducer } from "State/Cache";
|
import { reducer as CacheReducer } from "State/Cache";
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
login: LoginReducer,
|
login: LoginReducer,
|
||||||
users: UsersReducer,
|
|
||||||
cache: CacheReducer,
|
cache: CacheReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -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;
|
|
@ -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();
|
|
@ -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]]));
|
|
||||||
}
|
|
145
packages/app/src/State/Users/UserCache.ts
Normal file
145
packages/app/src/State/Users/UserCache.ts
Normal 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();
|
39
packages/app/src/State/Users/index.ts
Normal file
39
packages/app/src/State/Users/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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 { ProfileCacheExpire } from "Const";
|
||||||
import { mapEventToProfile } from "State/Users";
|
import { mapEventToProfile, MetadataCache } from "State/Users";
|
||||||
import { ReduxUDB } from "State/Users/Db";
|
import { UserCache } from "State/Users/UserCache";
|
||||||
import { unwrap } from "Util";
|
import { unixNowMs, unwrap } from "Util";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages nostr content retrieval system
|
* Manages nostr content retrieval system
|
||||||
@ -137,15 +145,15 @@ export class NostrSystem {
|
|||||||
/**
|
/**
|
||||||
* Request/Response pattern
|
* Request/Response pattern
|
||||||
*/
|
*/
|
||||||
RequestSubscription(sub: Subscriptions) {
|
RequestSubscription(sub: Subscriptions, timeout?: number) {
|
||||||
return new Promise<TaggedRawEvent[]>(resolve => {
|
return new Promise<TaggedRawEvent[]>(resolve => {
|
||||||
const events: TaggedRawEvent[] = [];
|
const events: TaggedRawEvent[] = [];
|
||||||
|
|
||||||
// force timeout returning current results
|
// force timeout returning current results
|
||||||
const timeout = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
this.RemoveSubscription(sub.Id);
|
this.RemoveSubscription(sub.Id);
|
||||||
resolve(events);
|
resolve(events);
|
||||||
}, 10_000);
|
}, timeout ?? 10_000);
|
||||||
|
|
||||||
const onEventPassthrough = sub.OnEvent;
|
const onEventPassthrough = sub.OnEvent;
|
||||||
sub.OnEvent = ev => {
|
sub.OnEvent = ev => {
|
||||||
@ -166,7 +174,7 @@ export class NostrSystem {
|
|||||||
sub.OnEnd = c => {
|
sub.OnEnd = c => {
|
||||||
c.RemoveSubscription(sub.Id);
|
c.RemoveSubscription(sub.Id);
|
||||||
if (sub.IsFinished()) {
|
if (sub.IsFinished()) {
|
||||||
clearInterval(timeout);
|
clearInterval(t);
|
||||||
console.debug(`[${sub.Id}] Finished`);
|
console.debug(`[${sub.Id}] Finished`);
|
||||||
resolve(events);
|
resolve(events);
|
||||||
}
|
}
|
||||||
@ -176,24 +184,15 @@ export class NostrSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _FetchMetadata() {
|
async _FetchMetadata() {
|
||||||
const userDb = ReduxUDB;
|
const missingFromCache = await UserCache.buffer([...this.WantsMetadata]);
|
||||||
|
|
||||||
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 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) {
|
if (missing.size > 0) {
|
||||||
console.debug("Wants profiles: ", missing);
|
console.debug(`Wants profiles: ${missingFromCache.length} missing, ${expired.length} expired`);
|
||||||
|
|
||||||
const sub = new Subscriptions();
|
const sub = new Subscriptions();
|
||||||
sub.Id = `profiles:${sub.Id.slice(0, 8)}`;
|
sub.Id = `profiles:${sub.Id.slice(0, 8)}`;
|
||||||
@ -202,29 +201,21 @@ export class NostrSystem {
|
|||||||
sub.OnEvent = async e => {
|
sub.OnEvent = async e => {
|
||||||
const profile = mapEventToProfile(e);
|
const profile = mapEventToProfile(e);
|
||||||
if (profile) {
|
if (profile) {
|
||||||
const existing = await userDb.find(profile.pubkey);
|
await UserCache.update(profile);
|
||||||
if ((existing?.created ?? 0) < profile.created) {
|
|
||||||
await userDb.put(profile);
|
|
||||||
} else if (existing) {
|
|
||||||
await userDb.update(profile.pubkey, {
|
|
||||||
loaded: profile.loaded,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const results = await this.RequestSubscription(sub);
|
const results = await this.RequestSubscription(sub, 5_000);
|
||||||
const couldNotFetch = Array.from(missing).filter(a => !results.some(b => b.pubkey === a));
|
const couldNotFetch = [...missing].filter(a => !results.some(b => b.pubkey === a));
|
||||||
console.debug("No profiles: ", couldNotFetch);
|
|
||||||
if (couldNotFetch.length > 0) {
|
if (couldNotFetch.length > 0) {
|
||||||
const updates = couldNotFetch
|
console.debug("No profiles: ", couldNotFetch);
|
||||||
.map(a => {
|
const empty = couldNotFetch.map(a =>
|
||||||
return {
|
UserCache.update({
|
||||||
pubkey: a,
|
pubkey: a,
|
||||||
loaded: new Date().getTime(),
|
loaded: unixNowMs(),
|
||||||
};
|
created: 69,
|
||||||
})
|
} as MetadataCache)
|
||||||
.map(a => userDb.update(a.pubkey, a));
|
);
|
||||||
await Promise.all(updates);
|
await Promise.all(empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Hooks/useUserProfile";
|
||||||
import Icon from "Icons/Icon";
|
import Icon from "Icons/Icon";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
Loading…
Reference in New Issue
Block a user