1
0
forked from Kieran/snort

feat: upgrade caches to worker

This commit is contained in:
Kieran 2024-01-17 15:47:01 +00:00
parent 3c808688f8
commit aa58ec4185
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
32 changed files with 698 additions and 417 deletions

View File

@ -23,6 +23,7 @@
"debug": "^4.3.4",
"dexie": "^3.2.4",
"emojilib": "^3.0.10",
"eventemitter3": "^5.0.1",
"fuse.js": "^7.0.0",
"highlight.js": "^11.8.0",
"light-bolt11-decoder": "^2.1.0",

View File

@ -2,11 +2,7 @@ import { RelayMetricCache, UserProfileCache, UserRelaysCache } from "@snort/syst
import { SnortSystemDb } from "@snort/system-web";
import { ChatCache } from "./ChatCache";
import { EventInteractionCache } from "./EventInteractionCache";
import { FollowListCache } from "./FollowListCache";
import { FollowsFeedCache } from "./FollowsFeed";
import { GiftWrapCache } from "./GiftWrapCache";
import { NotificationsCache } from "./Notifications";
import { Payments } from "./PaymentsCache";
export const SystemDb = new SnortSystemDb();
@ -16,23 +12,15 @@ export const RelayMetrics = new RelayMetricCache(SystemDb.relayMetrics);
export const Chats = new ChatCache();
export const PaymentsCache = new Payments();
export const InteractionCache = new EventInteractionCache();
export const GiftsCache = new GiftWrapCache();
export const Notifications = new NotificationsCache();
export const FollowsFeed = new FollowsFeedCache();
export const FollowLists = new FollowListCache();
export async function preload(follows?: Array<string>) {
const preloads = [
UserCache.preload(follows),
Chats.preload(),
InteractionCache.preload(),
UserRelays.preload(follows),
RelayMetrics.preload(),
GiftsCache.preload(),
Notifications.preload(),
FollowsFeed.preload(),
FollowLists.preload(),
UserRelays.preload(follows),
];
await Promise.all(preloads);
}

View File

@ -10,7 +10,6 @@ import { ZapperQueue } from "@/Components/Event/Note/NoteFooter/ZapperQueue";
import { ZapsSummary } from "@/Components/Event/ZapsSummary";
import ZapModal from "@/Components/ZapModal/ZapModal";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { useInteractionCache } from "@/Hooks/useInteractionCache";
import useLogin from "@/Hooks/useLogin";
import { getDisplayName } from "@/Utils";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
@ -35,10 +34,9 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
}));
const walletState = useWallet();
const wallet = walletState.wallet;
const interactionCache = useInteractionCache(publicKey, ev.id);
const link = NostrLink.fromEvent(ev);
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = interactionCache.data.zapped || zaps.some(a => a.sender === publicKey);
const didZap = zaps.some(a => a.sender === publicKey);
const [showZapModal, setShowZapModal] = useState(false);
const { formatMessage } = useIntl();
const [zapping, setZapping] = useState(false);
@ -102,7 +100,6 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
if (CONFIG.features.zapPool) {
ZapPoolController?.allocate(totalSent);
}
await interactionCache.zap();
}
});
}
@ -143,7 +140,7 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
value={zapTotal}
onClick={fastZap}
/>
<ZapsSummary zaps={zaps} onClick={onClickZappers} />
<ZapsSummary zaps={zaps} onClick={onClickZappers ?? (() => { })} />
</div>
{showZapModal && (
<ZapModal

View File

@ -5,7 +5,6 @@ import { useIntl } from "react-intl";
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { useInteractionCache } from "@/Hooks/useInteractionCache";
import useLogin from "@/Hooks/useLogin";
export const LikeButton = ({
@ -17,12 +16,10 @@ export const LikeButton = ({
}) => {
const { formatMessage } = useIntl();
const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
const interactionCache = useInteractionCache(publicKey, ev.id);
const { publisher, system } = useEventPublisher();
const hasReacted = (emoji: string) => {
return (
interactionCache.data.reacted ||
positiveReactions?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey)
);
};
@ -31,7 +28,6 @@ export const LikeButton = ({
if (!hasReacted(content) && publisher) {
const evLike = await publisher.react(ev, content);
system.BroadcastEvent(evLike);
interactionCache.react();
}
};

View File

@ -1,5 +1,5 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import { useEventReactions } from "@snort/system-react";
import React, { useMemo, useState } from "react";
import { FooterZapButton } from "@/Components/Event/Note/NoteFooter/FooterZapButton";
@ -8,6 +8,7 @@ import { PowIcon } from "@/Components/Event/Note/NoteFooter/PowIcon";
import { ReplyButton } from "@/Components/Event/Note/NoteFooter/ReplyButton";
import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton";
import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
import { useReactionsView } from "@/Feed/WorkerRelayView";
import useLogin from "@/Hooks/useLogin";
export interface NoteFooterProps {
@ -21,7 +22,7 @@ export default function NoteFooter(props: NoteFooterProps) {
const ids = useMemo(() => [link], [link]);
const [showReactions, setShowReactions] = useState(false);
const related = useReactions("note:reactions", ids, undefined, false);
const related = useReactionsView(ids, false);
const { reactions, zaps, reposts } = useEventReactions(link, related);
const { positive } = reactions;

View File

@ -7,7 +7,6 @@ import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterI
import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { useInteractionCache } from "@/Hooks/useInteractionCache";
import useLogin from "@/Hooks/useLogin";
import { useNoteCreator } from "@/State/NoteCreator";
@ -18,11 +17,10 @@ export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: T
preferences: s.appData.item.preferences,
publicKey: s.publicKey,
}));
const interactionCache = useInteractionCache(publicKey, ev.id);
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
const hasReposted = () => {
return interactionCache.data.reposted || reposts.some(a => a.pubkey === publicKey);
return reposts.some(a => a.pubkey === publicKey);
};
const repost = async () => {
@ -30,7 +28,6 @@ export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: T
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev);
system.BroadcastEvent(evRepost);
await interactionCache.repost();
}
}
};

View File

@ -1,4 +1,4 @@
import React, { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
export interface NoteTimeProps {
@ -38,7 +38,7 @@ const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
}
}, []);
const [time, setTime] = useState<string | ReactNode>(calcTime(from));
const [time] = useState<string | ReactNode>(calcTime(from));
const absoluteTime = useMemo(
() =>
@ -51,15 +51,6 @@ const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
const isoDate = useMemo(() => new Date(from).toISOString(), [from]);
useEffect(() => {
const t = setInterval(() => {
const newTime = calcTime(from);
setTime(s => (s !== newTime ? newTime : s));
}, 60_000); // update every minute
return () => clearInterval(t);
}, [from]);
return (
<time dateTime={isoDate} title={absoluteTime}>
{time || fallback}

View File

@ -2,16 +2,15 @@ import "./Timeline.css";
import { unixNow } from "@snort/shared";
import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { ReactNode, useCallback, useContext, useMemo, useState, useSyncExternalStore } from "react";
import { ReactNode, useCallback, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { FollowsFeed } from "@/Cache";
import { ShowMoreInView } from "@/Components/Event/ShowMore";
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
import { LiveStreams } from "@/Components/LiveStream/LiveStreams";
import useHashtagsFeed from "@/Feed/HashtagsFeed";
import { useFollowsTimelineView } from "@/Feed/WorkerRelayView";
import useHistoryState from "@/Hooks/useHistoryState";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
@ -35,11 +34,8 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const [latest, setLatest] = useHistoryState(unixNow(), "TimelineFollowsLatest");
const feed = useSyncExternalStore(
cb => FollowsFeed.hook(cb, "*"),
() => FollowsFeed.snapshot(),
);
const system = useContext(SnortContext);
const [limit, setLimit] = useState(50);
const feed = useFollowsTimelineView(limit);
const { muted, isEventMuted } = useModeration();
const sortedFeed = useMemo(() => orderDescending(feed), [feed]);
@ -125,7 +121,9 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
displayAs={displayAs}
/>
{sortedFeed.length > 0 && (
<ShowMoreInView onClick={async () => await FollowsFeed.loadMore(system, login, oldest ?? unixNow())} />
<ShowMoreInView onClick={() => {
setLimit(s => s + 20);
}} />
)}
</>
);

View File

@ -1,7 +1,6 @@
import { HexKey } from "@snort/system";
import { FormattedMessage } from "react-intl";
import { FollowsFeed } from "@/Cache";
import AsyncButton from "@/Components/Button/AsyncButton";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
@ -24,7 +23,6 @@ export default function FollowButton(props: FollowButtonProps) {
if (publisher) {
const ev = await publisher.contactList([pubkey, ...follows.item].map(a => ["p", a]));
system.BroadcastEvent(ev);
await FollowsFeed.backFill(system, [pubkey]);
}
}

View File

@ -3,7 +3,6 @@ import { HexKey } from "@snort/system";
import { ReactNode, useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { FollowsFeed } from "@/Cache";
import ProfilePreview from "@/Components/User/ProfilePreview";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
@ -43,7 +42,6 @@ export default function FollowListBase({
const ev = await publisher.contactList(newFollows.map(a => ["p", a]));
setFollows(id, newFollows, ev.created_at);
await system.BroadcastEvent(ev);
await FollowsFeed.backFill(system, pubkeys);
}
}

View File

@ -1,14 +1,19 @@
import { EventKind, NostrLink, parseRelayTags, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import {
EventKind,
NostrLink,
parseRelayTags,
RequestBuilder,
socialGraphInstance,
TaggedNostrEvent,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { usePrevious } from "@uidotdev/usehooks";
import { useEffect, useMemo } from "react";
import { FollowLists, FollowsFeed, GiftsCache, Notifications } from "@/Cache";
import { Nip4Chats, Nip28Chats } from "@/chat";
import { Nip28ChatSystem } from "@/chat/nip28";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { useRefreshFeedCache } from "@/Hooks/useRefreshFeedcache";
import { bech32ToHex, debounce, getNewest, getNewestEventTagsByKey, unwrap } from "@/Utils";
import { SnortPubKey } from "@/Utils/Const";
import {
@ -26,6 +31,8 @@ import {
} from "@/Utils/Login";
import { SubscriptionEvent } from "@/Utils/Subscription";
import { useFollowsContactListView } from "./WorkerRelayView";
/**
* Managed loading data for the current logged in user
*/
@ -34,10 +41,10 @@ export default function useLoginFeed() {
const { publicKey: pubKey, follows } = login;
const { publisher, system } = useEventPublisher();
useRefreshFeedCache(Notifications, true);
useRefreshFeedCache(FollowsFeed, true);
useRefreshFeedCache(GiftsCache, true);
useRefreshFeedCache(FollowLists, false);
const followLists = useFollowsContactListView();
useEffect(() => {
followLists.forEach(e => socialGraphInstance.handleEvent(e));
}, followLists);
useEffect(() => {
system.checkSigs = login.appData.item.preferences.checkSigs;
@ -106,7 +113,8 @@ export default function useLoginFeed() {
const pTags = contactList.tags.filter(a => a[0] === "p").map(a => a[1]);
setFollows(login.id, pTags, contactList.created_at * 1000);
FollowsFeed.backFillIfMissing(system, pTags);
// TODO: fixup
// FollowsFeed.backFillIfMissing(system, pTags);
}
const relays = getNewest(loginFeed.filter(a => a.kind === EventKind.Relays));

View File

@ -0,0 +1,164 @@
import { unixNow } from "@snort/shared";
import { EventKind, NostrEvent, NostrLink, ReqFilter, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useEffect, useMemo, useState } from "react";
import useLogin from "@/Hooks/useLogin";
import { Relay } from "@/system";
import { Day } from "@/Utils/Const";
export function useWorkerRelayView(id: string, filters: Array<ReqFilter>, maxWindow?: number) {
const [events, setEvents] = useState<Array<NostrEvent>>([]);
const [rb, setRb] = useState<RequestBuilder>();
useRequestBuilder(rb);
useEffect(() => {
Relay.req([
"REQ",
`${id}+latest`,
...filters.map(f => ({
...f,
until: undefined,
since: undefined,
limit: 1,
})),
]).then(latest => {
const rb = new RequestBuilder(id);
filters
.map((f, i) => ({
...f,
limit: undefined,
until: undefined,
since: latest?.at(i)?.created_at ?? (maxWindow ? unixNow() - maxWindow : undefined),
}))
.forEach(f => rb.withBareFilter(f));
setRb(rb);
});
Relay.req(["REQ", id, ...filters]).then(setEvents);
}, [id, filters, maxWindow]);
return events as Array<TaggedNostrEvent>;
}
export function useWorkerRelayViewCount(id: string, filters: Array<ReqFilter>, maxWindow?: number) {
const [count, setCount] = useState(0);
const [rb, setRb] = useState<RequestBuilder>();
useRequestBuilder(rb);
useEffect(() => {
Relay.req([
"REQ",
`${id}+latest`,
...filters.map(f => ({
...f,
until: undefined,
since: undefined,
limit: 1,
})),
]).then(latest => {
const rb = new RequestBuilder(id);
filters
.map((f, i) => ({
...f,
limit: undefined,
until: undefined,
since: latest?.at(i)?.created_at ?? (maxWindow ? unixNow() - maxWindow : undefined),
}))
.forEach(f => rb.withBareFilter(f));
setRb(rb);
});
Relay.count(["REQ", id, ...filters]).then(setCount);
}, [id, filters, maxWindow]);
return count;
}
export function useFollowsTimelineView(limit = 20) {
const follows = useLogin(s => s.follows.item);
const kinds = [EventKind.TextNote, EventKind.Repost, EventKind.Polls];
const filter = useMemo(() => {
return [
{
authors: follows,
kinds,
limit,
},
];
}, [follows, limit]);
return useWorkerRelayView("follows-timeline", filter, Day * 7);
}
export function useNotificationsView() {
const publicKey = useLogin(s => s.publicKey);
const kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt];
const req = useMemo(() => {
return [
{
"#p": [publicKey ?? ""],
kinds,
since: unixNow() - Day * 7,
},
];
}, [publicKey]);
return useWorkerRelayView("notifications", req, Day * 30);
}
export function useReactionsView(ids: Array<NostrLink>, leaveOpen = true) {
const req = useMemo(() => {
const rb = new RequestBuilder("reactions");
rb.withOptions({ leaveOpen });
const grouped = ids.reduce(
(acc, v) => {
acc[v.type] ??= [];
acc[v.type].push(v);
return acc;
},
{} as Record<string, Array<NostrLink>>,
);
for (const [, v] of Object.entries(grouped)) {
rb.withFilter().kinds([EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]).replyToLink(v);
}
return rb.buildRaw();
}, [ids]);
return useWorkerRelayView("reactions", req, undefined);
}
export function useReactionsViewCount(ids: Array<NostrLink>, leaveOpen = true) {
const req = useMemo(() => {
const rb = new RequestBuilder("reactions");
rb.withOptions({ leaveOpen });
const grouped = ids.reduce(
(acc, v) => {
acc[v.type] ??= [];
acc[v.type].push(v);
return acc;
},
{} as Record<string, Array<NostrLink>>,
);
for (const [, v] of Object.entries(grouped)) {
rb.withFilter().kinds([EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]).replyToLink(v);
}
return rb.buildRaw();
}, [ids]);
return useWorkerRelayViewCount("reactions", req, undefined);
}
export function useFollowsContactListView() {
const follows = useLogin(s => s.follows.item);
const kinds = [EventKind.ContactList, EventKind.Relays];
const filter = useMemo(() => {
return [
{
authors: follows,
kinds,
},
];
}, [follows]);
return useWorkerRelayView("follows-contacts-relays", filter, undefined);
}

View File

@ -1,40 +0,0 @@
import { unwrap } from "@snort/shared";
import { RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useEffect, useMemo } from "react";
import { RefreshFeedCache } from "@/Cache/RefreshFeedCache";
import useEventPublisher from "./useEventPublisher";
import useLogin from "./useLogin";
export function useRefreshFeedCache<T>(c: RefreshFeedCache<T>, leaveOpen = false) {
const login = useLogin();
const { publisher, system } = useEventPublisher();
const sub = useMemo(() => {
if (login.publicKey) {
const rb = new RequestBuilder(`using-${c.name}`);
rb.withOptions({
leaveOpen,
});
c.buildSub(login, rb);
return rb;
}
return undefined;
}, [login]);
useEffect(() => {
if (sub) {
const q = system.Query(sub);
const handler = (evs: Array<TaggedNostrEvent>) => {
c.onEvent(evs, unwrap(login.publicKey), publisher);
};
q.on("event", handler);
q.uncancel();
return () => {
q.off("event", handler);
q.cancel();
};
}
}, [sub]);
}

View File

@ -1,14 +1,11 @@
import { useMemo, useSyncExternalStore } from "react";
import { useMemo } from "react";
import { Notifications } from "@/Cache";
import { useNotificationsView } from "@/Feed/WorkerRelayView";
import useLogin from "@/Hooks/useLogin";
export function HasNotificationsMarker() {
const readNotifications = useLogin(s => s.readNotifications);
const notifications = useSyncExternalStore(
c => Notifications.hook(c, "*"),
() => Notifications.snapshot(),
);
const notifications = useNotificationsView();
const latestNotification = useMemo(
() => notifications.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0),
[notifications],

View File

@ -0,0 +1,146 @@
import { unwrap } from "@snort/shared";
import { EventKind, NostrLink, parseZap, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useMemo } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import Icon from "@/Components/Icons/Icon";
import ProfileImage from "@/Components/User/ProfileImage";
import { dedupe, getDisplayName } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import { notificationContext } from "./notificationContext";
import { NotificationContext } from "./Notifications";
export function NotificationGroup({ evs, onClick }: { evs: Array<TaggedNostrEvent>; onClick?: (link: NostrLink) => void; }) {
const { ref, inView } = useInView({ triggerOnce: true });
const { formatMessage } = useIntl();
const kind = evs[0].kind;
const navigate = useNavigate();
const zaps = useMemo(() => {
return evs.filter(a => a.kind === EventKind.ZapReceipt).map(a => parseZap(a));
}, [evs]);
const pubkeys = dedupe(
evs.map(a => {
if (a.kind === EventKind.ZapReceipt) {
const zap = unwrap(zaps.find(b => b.id === a.id));
return zap.anonZap ? "anon" : zap.sender ?? a.pubkey;
}
return a.pubkey;
})
);
const firstPubkey = pubkeys[0];
const firstPubkeyProfile = useUserProfile(inView ? (firstPubkey === "anon" ? "" : firstPubkey) : "");
const context = notificationContext(evs[0]);
const totalZaps = zaps.reduce((acc, v) => acc + v.amount, 0);
const iconName = () => {
switch (kind) {
case EventKind.Reaction:
return "heart-solid";
case EventKind.ZapReceipt:
return "zap-solid";
case EventKind.Repost:
return "repeat";
case EventKind.TextNote:
return "reverse-left";
}
return "";
};
const actionName = (n: number, name: string) => {
switch (kind) {
case EventKind.TextNote: {
return "";
}
case EventKind.Reaction: {
return (
<FormattedMessage
defaultMessage="{n,plural,=0{{name} liked} other{{name} & {n} others liked}}"
id="kuPHYE"
values={{
n,
name,
}} />
);
}
case EventKind.Repost: {
return (
<FormattedMessage
defaultMessage="{n,plural,=0{{name} reposted} other{{name} & {n} others reposted}}"
id="kJYo0u"
values={{
n,
name,
}} />
);
}
case EventKind.ZapReceipt: {
return (
<FormattedMessage
defaultMessage="{n,plural,=0{{name} zapped} other{{name} & {n} others zapped}}"
id="Lw+I+J"
values={{
n,
name,
}} />
);
}
}
return `${kind}'d your post`;
};
return (
<div className="card notification-group" ref={ref}>
{inView && (
<>
<div className="flex flex-col g12">
<div>
<Icon name={iconName()} size={24} className={iconName()} />
</div>
<div>{kind === EventKind.ZapReceipt && formatShort(totalZaps)}</div>
</div>
<div className="flex flex-col w-max g12">
<div className="flex">
{pubkeys
.filter(a => a !== "anon")
.slice(0, 12)
.map(v => (
<ProfileImage
key={v}
showUsername={kind === EventKind.TextNote}
pubkey={v}
size={40}
overrideUsername={v === "" ? formatMessage({ defaultMessage: "Anon", id: "bfvyfs" }) : undefined} />
))}
</div>
{kind !== EventKind.TextNote && (
<div className="names">
{actionName(
pubkeys.length - 1,
firstPubkey === "anon"
? formatMessage({ defaultMessage: "Anon", id: "bfvyfs" })
: getDisplayName(firstPubkeyProfile, firstPubkey)
)}
</div>
)}
{context && (
<NotificationContext
link={context}
onClick={() => {
if (onClick) {
onClick(context);
} else {
navigate(`/${context.encode(CONFIG.eventLinkPrefix)}`);
}
}} />
)}
</div>
</>
)}
</div>
);
}

View File

@ -1,75 +1,35 @@
import "./Notifications.css";
import { unwrap } from "@snort/shared";
import { EventExt, EventKind, NostrEvent, NostrLink, NostrPrefix, parseZap, TaggedNostrEvent } from "@snort/system";
import { useEventFeed, useUserProfile } from "@snort/system-react";
import { lazy, Suspense, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import { EventKind, NostrEvent, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import { lazy, Suspense, useEffect, useMemo } from "react";
import { Notifications } from "@/Cache";
import { ShowMoreInView } from "@/Components/Event/ShowMore";
import Icon from "@/Components/Icons/Icon";
import { LiveEvent } from "@/Components/LiveStream/LiveEvent";
import PageSpinner from "@/Components/PageSpinner";
import Text from "@/Components/Text/Text";
import ProfileImage from "@/Components/User/ProfileImage";
import ProfilePreview from "@/Components/User/ProfilePreview";
import { useNotificationsView } from "@/Feed/WorkerRelayView";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import { dedupe, getDisplayName, orderDescending } from "@/Utils";
import { orderDescending } from "@/Utils";
import { markNotificationsRead } from "@/Utils/Login";
import { formatShort } from "@/Utils/Number";
const NotificationGraph = lazy(() => import("@/Pages/Notifications/NotificationChart"));
function notificationContext(ev: TaggedNostrEvent) {
switch (ev.kind) {
case EventKind.ZapReceipt: {
const aTag = ev.tags.find(a => a[0] === "a");
if (aTag) {
return NostrLink.fromTag(aTag);
}
const eTag = ev.tags.find(a => a[0] === "e");
if (eTag) {
return NostrLink.fromTag(eTag);
}
const pTag = ev.tags.find(a => a[0] === "p");
if (pTag) {
return NostrLink.fromTag(pTag);
}
break;
}
case EventKind.Repost:
case EventKind.Reaction: {
const thread = EventExt.extractThread(ev);
const tag = unwrap(thread?.replyTo ?? thread?.root ?? { value: ev.id, key: "e" });
if (tag.key === "e" || tag.key === "a") {
return NostrLink.fromThreadTag(tag);
} else {
throw new Error("Unknown thread context");
}
}
case EventKind.TextNote: {
return NostrLink.fromEvent(ev);
}
}
}
import { notificationContext } from "./notificationContext";
import { NotificationGroup } from "./NotificationGroup";
const NotificationGraph = lazy(() => import("@/Pages/Notifications/NotificationChart"));
export default function NotificationsPage({ onClick }: { onClick?: (link: NostrLink) => void }) {
const login = useLogin();
const { isMuted } = useModeration();
const groupInterval = 3600 * 3;
const [showN, setShowN] = useState(10);
const groupInterval = 3600 * 6;
useEffect(() => {
markNotificationsRead(login);
}, []);
const notifications = useSyncExternalStore(
c => Notifications.hook(c, "*"),
() => Notifications.snapshot(),
);
const notifications = useNotificationsView();
const timeKey = (ev: NostrEvent) => {
const onHour = ev.created_at - (ev.created_at % groupInterval);
@ -84,9 +44,8 @@ export default function NotificationsPage({ onClick }: { onClick?: (link: NostrL
const timeGrouped = useMemo(() => {
return myNotifications.reduce((acc, v) => {
const key = `${timeKey(v)}:${notificationContext(v as TaggedNostrEvent)?.encode(CONFIG.eventLinkPrefix)}:${
v.kind
}`;
const key = `${timeKey(v)}:${notificationContext(v as TaggedNostrEvent)?.encode(CONFIG.eventLinkPrefix)}:${v.kind
}`;
if (acc.has(key)) {
unwrap(acc.get(key)).push(v as TaggedNostrEvent);
} else {
@ -106,152 +65,15 @@ export default function NotificationsPage({ onClick }: { onClick?: (link: NostrL
)}
{login.publicKey &&
[...timeGrouped.entries()]
.slice(0, showN)
.map(([k, g]) => <NotificationGroup key={k} evs={g} onClick={onClick} />)}
<ShowMoreInView onClick={() => setShowN(s => Math.min(timeGrouped.size, s + 5))} />
<ShowMoreInView onClick={() => {}} />
</div>
</>
);
}
function NotificationGroup({ evs, onClick }: { evs: Array<TaggedNostrEvent>; onClick?: (link: NostrLink) => void }) {
const { ref, inView } = useInView({ triggerOnce: true });
const { formatMessage } = useIntl();
const kind = evs[0].kind;
const navigate = useNavigate();
const zaps = useMemo(() => {
return evs.filter(a => a.kind === EventKind.ZapReceipt).map(a => parseZap(a));
}, [evs]);
const pubkeys = dedupe(
evs.map(a => {
if (a.kind === EventKind.ZapReceipt) {
const zap = unwrap(zaps.find(b => b.id === a.id));
return zap.anonZap ? "anon" : zap.sender ?? a.pubkey;
}
return a.pubkey;
}),
);
const firstPubkey = pubkeys[0];
const firstPubkeyProfile = useUserProfile(inView ? (firstPubkey === "anon" ? "" : firstPubkey) : "");
const context = notificationContext(evs[0]);
const totalZaps = zaps.reduce((acc, v) => acc + v.amount, 0);
const iconName = () => {
switch (kind) {
case EventKind.Reaction:
return "heart-solid";
case EventKind.ZapReceipt:
return "zap-solid";
case EventKind.Repost:
return "repeat";
case EventKind.TextNote:
return "reverse-left";
}
return "";
};
const actionName = (n: number, name: string) => {
switch (kind) {
case EventKind.TextNote: {
return "";
}
case EventKind.Reaction: {
return (
<FormattedMessage
defaultMessage="{n,plural,=0{{name} liked} other{{name} & {n} others liked}}"
id="kuPHYE"
values={{
n,
name,
}}
/>
);
}
case EventKind.Repost: {
return (
<FormattedMessage
defaultMessage="{n,plural,=0{{name} reposted} other{{name} & {n} others reposted}}"
id="kJYo0u"
values={{
n,
name,
}}
/>
);
}
case EventKind.ZapReceipt: {
return (
<FormattedMessage
defaultMessage="{n,plural,=0{{name} zapped} other{{name} & {n} others zapped}}"
id="Lw+I+J"
values={{
n,
name,
}}
/>
);
}
}
return `${kind}'d your post`;
};
return (
<div className="card notification-group" ref={ref}>
{inView && (
<>
<div className="flex flex-col g12">
<div>
<Icon name={iconName()} size={24} className={iconName()} />
</div>
<div>{kind === EventKind.ZapReceipt && formatShort(totalZaps)}</div>
</div>
<div className="flex flex-col w-max g12">
<div className="flex">
{pubkeys
.filter(a => a !== "anon")
.slice(0, 12)
.map(v => (
<ProfileImage
key={v}
showUsername={kind === EventKind.TextNote}
pubkey={v}
size={40}
overrideUsername={v === "" ? formatMessage({ defaultMessage: "Anon", id: "bfvyfs" }) : undefined}
/>
))}
</div>
{kind !== EventKind.TextNote && (
<div className="names">
{actionName(
pubkeys.length - 1,
firstPubkey === "anon"
? formatMessage({ defaultMessage: "Anon", id: "bfvyfs" })
: getDisplayName(firstPubkeyProfile, firstPubkey),
)}
</div>
)}
{context && (
<NotificationContext
link={context}
onClick={() => {
if (onClick) {
onClick(context);
} else {
navigate(`/${context.encode(CONFIG.eventLinkPrefix)}`);
}
}}
/>
)}
</div>
</>
)}
</div>
);
}
function NotificationContext({ link, onClick }: { link: NostrLink; onClick: () => void }) {
export function NotificationContext({ link, onClick }: { link: NostrLink; onClick: () => void }) {
const ev = useEventFeed(link);
if (link.type === NostrPrefix.PublicKey) {
return <ProfilePreview pubkey={link.id} actions={<></>} />;

View File

@ -0,0 +1,35 @@
import { unwrap } from "@snort/shared";
import { EventExt, EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
export function notificationContext(ev: TaggedNostrEvent) {
switch (ev.kind) {
case EventKind.ZapReceipt: {
const aTag = ev.tags.find(a => a[0] === "a");
if (aTag) {
return NostrLink.fromTag(aTag);
}
const eTag = ev.tags.find(a => a[0] === "e");
if (eTag) {
return NostrLink.fromTag(eTag);
}
const pTag = ev.tags.find(a => a[0] === "p");
if (pTag) {
return NostrLink.fromTag(pTag);
}
break;
}
case EventKind.Repost:
case EventKind.Reaction: {
const thread = EventExt.extractThread(ev);
const tag = unwrap(thread?.replyTo ?? thread?.root ?? { value: ev.id, key: "e" });
if (tag.key === "e" || tag.key === "a") {
return NostrLink.fromThreadTag(tag);
} else {
throw new Error("Unknown thread context");
}
}
case EventKind.TextNote: {
return NostrLink.fromEvent(ev);
}
}
}

View File

@ -1,20 +1,16 @@
import { FeedCache } from "@snort/shared";
import { ReactNode, useSyncExternalStore } from "react";
import { ReactNode, useEffect, useState, useSyncExternalStore } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import {
Chats,
FollowLists,
FollowsFeed,
GiftsCache,
InteractionCache,
Notifications,
PaymentsCache,
RelayMetrics,
UserCache,
UserRelays,
} from "@/Cache";
import AsyncButton from "@/Components/Button/AsyncButton";
import { Relay } from "@/system";
export function CacheSettings() {
return (
@ -22,16 +18,12 @@ export function CacheSettings() {
<h3>
<FormattedMessage defaultMessage="Cache" id="DBiVK1" />
</h3>
<RelayCacheStats />
<CacheDetails cache={UserCache} name={<FormattedMessage defaultMessage="Profiles" id="2zJXeA" />} />
<CacheDetails cache={UserRelays} name={<FormattedMessage defaultMessage="Relay Lists" id="tGXF0Q" />} />
<CacheDetails cache={Notifications} name={<FormattedMessage defaultMessage="Notifications" id="NAidKb" />} />
<CacheDetails cache={FollowsFeed} name={<FormattedMessage defaultMessage="Follows Feed" id="uKqSN+" />} />
<CacheDetails cache={Chats} name={<FormattedMessage defaultMessage="Chats" id="ABAQyo" />} />
<CacheDetails cache={RelayMetrics} name={<FormattedMessage defaultMessage="Relay Metrics" id="tjpYlr" />} />
<CacheDetails cache={PaymentsCache} name={<FormattedMessage defaultMessage="Payments" id="iYc3Ld" />} />
<CacheDetails cache={InteractionCache} name={<FormattedMessage defaultMessage="Interactions" id="u+LyXc" />} />
<CacheDetails cache={GiftsCache} name={<FormattedMessage defaultMessage="Gift Wraps" id="fjAcWo" />} />
<CacheDetails cache={FollowLists} name={<FormattedMessage defaultMessage="Social Graph" id="CzHZoc" />} />
</div>
);
}
@ -65,3 +57,39 @@ function CacheDetails<T>({ cache, name }: { cache: FeedCache<T>; name: ReactNode
</div>
);
}
function RelayCacheStats() {
const [counts, setCounts] = useState<Record<string, number>>({});
useEffect(() => {
Relay.summary().then(setCounts);
}, []);
return (
<div className="flex justify-between br p bg-superdark">
<div className="flex flex-col g4">
<FormattedMessage defaultMessage="Worker Relay" id="xSoIUU" />
{Object.entries(counts).map(([k, v]) => {
return <small key={k}>
<FormattedMessage
defaultMessage="{n} kind {k} events"
id="I97cCX"
values={{
n: <FormattedNumber value={v} />,
k: k
}}
/>
</small>
})}
</div>
<div>
<AsyncButton onClick={() => {
}}>
<FormattedMessage defaultMessage="Clear" id="/GCoTA" />
</AsyncButton>
</div>
</div>
);
}

View File

@ -12,7 +12,7 @@ import {
UserMetadata,
} from "@snort/system";
import { Chats, FollowsFeed, GiftsCache, Notifications } from "@/Cache";
import { Chats, GiftsCache } from "@/Cache";
import SnortApi from "@/External/SnortApi";
import { bech32ToHex, dedupeById, deleteRefCode, getCountry, sanitizeRelayUrl, unwrap } from "@/Utils";
import { Blasters } from "@/Utils/Const";
@ -68,8 +68,6 @@ export function updatePreferences(id: string, p: UserPreferences) {
export function logout(id: string) {
LoginStore.removeSession(id);
GiftsCache.clear();
Notifications.clear();
FollowsFeed.clear();
Chats.clear();
deleteRefCode();
localStorage.clear();

View File

@ -35,8 +35,8 @@ import SearchPage from "@/Pages/SearchPage";
import SettingsRoutes from "@/Pages/settings/Routes";
import { SubscribeRoutes } from "@/Pages/subscribe";
import ZapPoolPage from "@/Pages/ZapPool";
import { System } from "@/system";
import { getCountry, storeRefCode, unwrap } from "@/Utils";
import { initRelayWorker, System } from "@/system";
import { storeRefCode, unwrap } from "@/Utils";
import { LoginStore } from "@/Utils/Login";
import { hasWasm, wasmInit, WasmPath } from "@/Utils/wasm";
import { Wallets } from "@/Wallet";
@ -47,10 +47,10 @@ import { WalletReceivePage } from "./Pages/wallet/receive";
import { WalletSendPage } from "./Pages/wallet/send";
async function initSite() {
console.debug(getCountry());
storeRefCode();
if (hasWasm) {
await wasmInit(WasmPath);
await initRelayWorker();
}
const login = LoginStore.takeSnapshot();
db.ready = await db.isAvailable();

View File

@ -1,5 +1,7 @@
import { removeUndefined, throwIfOffline } from "@snort/shared";
import { mapEventToProfile, NostrEvent, NostrSystem, ProfileLoaderService, socialGraphInstance } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import WorkerRelayPath from "@snort/worker-relay/dist/worker?worker&url";
import { RelayMetrics, SystemDb, UserCache, UserRelays } from "@/Cache";
import { addCachedMetadataToFuzzySearch, addEventToFuzzySearch } from "@/Db/FuzzySearch";
@ -59,6 +61,25 @@ export async function fetchProfile(key: string) {
}
}
export const Relay = new WorkerRelayInterface(WorkerRelayPath);
let relayInitStarted = false;
export async function initRelayWorker() {
if (relayInitStarted) return;
relayInitStarted = true;
try {
if (await Relay.init()) {
if (await Relay.open()) {
await Relay.migrate();
System.on("event", async (_, ev) => {
await Relay.event(ev);
});
}
}
} catch (e) {
console.error(e);
}
}
/**
* Singleton user profile loader
*/

View File

@ -10,14 +10,30 @@ export interface KeyedHookFilter {
fn: HookFn;
}
export interface FeedCacheEvents {
export interface CacheEvents {
change: (keys: Array<string>) => void;
}
export type CachedTable<T> = {
preload(): Promise<void>;
keysOnTable(): Array<string>;
getFromCache(key?: string): T | undefined;
get(key?: string): Promise<T | undefined>;
bulkGet(keys: Array<string>): Promise<Array<T>>;
set(obj: T): Promise<void>;
bulkSet(obj: Array<T> | Readonly<Array<T>>): Promise<void>;
update<TWithCreated extends T & { created: number; loaded: number }>(
m: TWithCreated,
): Promise<"new" | "refresh" | "updated" | "no_change">;
buffer(keys: Array<string>): Promise<Array<string>>;
key(of: T): string;
snapshot(): Array<T>;
} & EventEmitter<CacheEvents>;
/**
* Dexie backed generic hookable store
*/
export abstract class FeedCache<TCached> extends EventEmitter<FeedCacheEvents> {
export abstract class FeedCache<TCached> extends EventEmitter<CacheEvents> implements CachedTable<TCached> {
readonly name: string;
#snapshot: Array<TCached> = [];
protected log: ReturnType<typeof debug>;

View File

@ -1,10 +1,10 @@
import debug from "debug";
import { FeedCache, removeUndefined } from "@snort/shared";
import { CachedTable, removeUndefined } from "@snort/shared";
import { SystemInterface, TaggedNostrEvent, RequestBuilder } from ".";
export abstract class BackgroundLoader<T extends { loaded: number; created: number }> {
#system: SystemInterface;
readonly cache: FeedCache<T>;
readonly cache: CachedTable<T>;
#log = debug(this.name());
/**
@ -17,7 +17,7 @@ export abstract class BackgroundLoader<T extends { loaded: number; created: numb
*/
loaderFn?: (pubkeys: Array<string>) => Promise<Array<T>>;
constructor(system: SystemInterface, cache: FeedCache<T>) {
constructor(system: SystemInterface, cache: CachedTable<T>) {
this.#system = system;
this.cache = cache;
this.#FetchMetadata();

View File

@ -5,7 +5,7 @@ import { ProfileLoaderService } from "./profile-cache";
import { RelayCache, RelayMetadataLoader } from "./outbox-model";
import { Optimizer } from "./query-optimizer";
import { base64 } from "@scure/base";
import { FeedCache } from "@snort/shared";
import { CachedTable } from "@snort/shared";
import { ConnectionPool } from "./connection-pool";
import EventEmitter from "eventemitter3";
import { QueryEvents } from "./query";
@ -143,7 +143,7 @@ export interface SystemInterface {
/**
* Generic cache store for events
*/
get eventsCache(): FeedCache<NostrEvent>;
get eventsCache(): CachedTable<NostrEvent>;
/**
* Relay loader loads relay metadata for a set of profiles

View File

@ -1,8 +1,8 @@
import debug from "debug";
import EventEmitter from "eventemitter3";
import { FeedCache } from "@snort/shared";
import { NostrEvent, ReqFilter, TaggedNostrEvent } from "./nostr";
import { CachedTable } from "@snort/shared";
import { NostrEvent, TaggedNostrEvent } from "./nostr";
import { RelaySettings, ConnectionStateSnapshot, OkResponse } from "./connection";
import { BuiltRawReqFilter, RequestBuilder } from "./request-builder";
import { RelayMetricHandler } from "./relay-metric-handler";
@ -17,7 +17,6 @@ import {
RelayMetricCache,
UsersRelays,
SnortSystemDb,
EventExt,
QueryLike,
} from ".";
import { EventsCache } from "./cache/events";
@ -34,10 +33,10 @@ export interface NostrSystemEvents {
}
export interface NostrsystemProps {
relayCache?: FeedCache<UsersRelays>;
profileCache?: FeedCache<CachedMetadata>;
relayMetrics?: FeedCache<RelayMetrics>;
eventsCache?: FeedCache<NostrEvent>;
relayCache?: CachedTable<UsersRelays>;
profileCache?: CachedTable<CachedMetadata>;
relayMetrics?: CachedTable<RelayMetrics>;
eventsCache?: CachedTable<NostrEvent>;
optimizer?: Optimizer;
db?: SnortSystemDb;
checkSigs?: boolean;
@ -53,17 +52,17 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
/**
* Storage class for user relay lists
*/
readonly relayCache: FeedCache<UsersRelays>;
readonly relayCache: CachedTable<UsersRelays>;
/**
* Storage class for user profiles
*/
readonly profileCache: FeedCache<CachedMetadata>;
readonly profileCache: CachedTable<CachedMetadata>;
/**
* Storage class for relay metrics (connects/disconnects)
*/
readonly relayMetricsCache: FeedCache<RelayMetrics>;
readonly relayMetricsCache: CachedTable<RelayMetrics>;
/**
* Profile loading service
@ -81,7 +80,7 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
readonly optimizer: Optimizer;
readonly pool: ConnectionPool;
readonly eventsCache: FeedCache<NostrEvent>;
readonly eventsCache: CachedTable<NostrEvent>;
readonly relayLoader: RelayMetadataLoader;
/**

View File

@ -1,11 +1,11 @@
import { FeedCache, unixNowMs } from "@snort/shared";
import { CachedTable, unixNowMs } from "@snort/shared";
import { RelayMetrics } from "./cache";
import { TraceReport } from "./query";
export class RelayMetricHandler {
readonly #cache: FeedCache<RelayMetrics>;
readonly #cache: CachedTable<RelayMetrics>;
constructor(cache: FeedCache<RelayMetrics>) {
constructor(cache: CachedTable<RelayMetrics>) {
this.#cache = cache;
setInterval(() => {

View File

@ -21,7 +21,7 @@ import {
} from "..";
import { NostrSystemEvents, NostrsystemProps } from "../nostr-system";
import { WorkerCommand, WorkerMessage } from ".";
import { FeedCache } from "@snort/shared";
import { CachedTable } from "@snort/shared";
import { EventsCache } from "../cache/events";
import { RelayMetricHandler } from "../relay-metric-handler";
import debug from "debug";
@ -31,12 +31,12 @@ export class SystemWorker extends EventEmitter<NostrSystemEvents> implements Sys
#log = debug("SystemWorker");
#worker: Worker;
#commandQueue: Map<string, (v: unknown) => void> = new Map();
readonly relayCache: FeedCache<UsersRelays>;
readonly profileCache: FeedCache<CachedMetadata>;
readonly relayMetricsCache: FeedCache<RelayMetrics>;
readonly relayCache: CachedTable<UsersRelays>;
readonly profileCache: CachedTable<CachedMetadata>;
readonly relayMetricsCache: CachedTable<RelayMetrics>;
readonly profileLoader: ProfileLoaderService;
readonly relayMetricsHandler: RelayMetricHandler;
readonly eventsCache: FeedCache<NostrEvent>;
readonly eventsCache: CachedTable<NostrEvent>;
readonly relayLoader: RelayMetadataLoader;
get checkSigs() {

View File

@ -40,7 +40,15 @@ export class WorkerRelayInterface {
return await this.#workerRpc<ReqCommand, Array<NostrEvent>>("req", req);
}
#workerRpc<T, R>(cmd: string, args?: T, timeout = 5_000) {
async count(req: ReqCommand) {
return await this.#workerRpc<ReqCommand, number>("count", req);
}
async summary() {
return await this.#workerRpc<void, Record<string, number>>("summary");
}
#workerRpc<T, R>(cmd: string, args?: T, timeout = 30_000) {
const id = uuid();
const msg = {
id,

View File

@ -1,16 +1,17 @@
import sqlite3InitModule, { Database, Sqlite3Static } from "@sqlite.org/sqlite-wasm";
import debug from "debug";
import { NostrEvent, ReqFilter } from "types";
import { NostrEvent, ReqFilter, unixNowMs } from "./types";
export class WorkerRelay {
#sqlite?: Sqlite3Static;
#log = (msg: string) => console.debug(msg);
#log = (...msg: Array<any>) => console.debug(...msg);
#db?: Database;
/**
* Initialize the SQLite driver
*/
async init() {
if (this.#sqlite) return;
this.#sqlite = await sqlite3InitModule();
this.#log(`Got SQLite version: ${this.#sqlite.version.libVersion}`);
}
@ -20,15 +21,26 @@ export class WorkerRelay {
*/
async open(path: string) {
if (!this.#sqlite) throw new Error("Must call init first");
if (this.#db) return;
if ("opfs" in this.#sqlite) {
this.#db = new this.#sqlite.oo1.OpfsDb(path, "cw");
this.#log(`Opened ${this.#db.filename}`);
try {
this.#db = new this.#sqlite.oo1.OpfsDb(path, "cw");
this.#log(`Opened ${this.#db.filename}`);
} catch (e) {
// wipe db
console.error(e);
}
} else {
throw new Error("OPFS not supported!");
}
}
close() {
this.#db?.close();
this.#db = undefined;
}
/**
* Do database migration
*/
@ -56,34 +68,7 @@ export class WorkerRelay {
event(ev: NostrEvent) {
let eventInserted = false;
this.#db?.transaction(db => {
const legacyReplacable = [0, 3, 41];
if (legacyReplacable.includes(ev.kind) || (ev.kind >= 10_000 && ev.kind < 20_000)) {
db.exec("delete from events where kind = ? and pubkey = ?", {
bind: [ev.kind, ev.pubkey],
});
this.#log(`Deleted old kind=${ev.kind}, author=${ev.pubkey} (rows=${db.changes()})`);
}
if (ev.kind >= 30_000 && ev.kind < 40_000) {
const dTag = ev.tags.find(a => a[0] === "d")![1];
db.exec(
"delete from events where id in (select id from events, tags where events.id = tags.event_id and tags.key = ? and tags.value = ?)",
{
bind: ["d", dTag],
},
);
this.#log(`Deleted old versions of: d=${dTag}, kind=${ev.kind}, author=${ev.pubkey} (rows=${db.changes()})`);
}
db.exec("insert or ignore into events(id, pubkey, created, kind, json) values(?,?,?,?,?)", {
bind: [ev.id, ev.pubkey, ev.created_at, ev.kind, JSON.stringify(ev)],
});
eventInserted = (this.#db?.changes() as number) > 0;
if (eventInserted) {
for (const t of ev.tags.filter(a => a[0].length === 1)) {
db.exec("insert into tags(event_id, key, value) values(?, ?, ?)", {
bind: [ev.id, t[0], t[1]],
});
}
}
eventInserted = this.#insertEvent(db, ev);
});
if (eventInserted) {
this.#log(`Inserted: kind=${ev.kind},authors=${ev.pubkey},id=${ev.id}`);
@ -91,29 +76,125 @@ export class WorkerRelay {
return eventInserted;
}
/**
* Write multiple events
*/
eventBatch(evs: Array<NostrEvent>) {
let eventsInserted: Array<NostrEvent> = [];
this.#db?.transaction(db => {
for (const ev of evs) {
if (this.#insertEvent(db, ev)) {
eventsInserted.push(ev);
}
}
});
if (eventsInserted.length > 0) {
this.#log(`Inserted Batch: ${eventsInserted.length}/${evs.length}`);
}
return eventsInserted.length > 0;
}
#insertEvent(db: Database, ev: NostrEvent) {
const legacyReplacable = [0, 3, 41];
if (legacyReplacable.includes(ev.kind) || (ev.kind >= 10_000 && ev.kind < 20_000)) {
db.exec("delete from events where kind = ? and pubkey = ? and created < ?", {
bind: [ev.kind, ev.pubkey, ev.created_at],
});
const oldDeleted = db.changes();
if (oldDeleted === 0) {
return false;
}
}
if (ev.kind >= 30_000 && ev.kind < 40_000) {
const dTag = ev.tags.find(a => a[0] === "d")![1];
db.exec(
"delete from events where id in (select id from events, tags where events.id = tags.event_id and tags.key = ? and tags.value = ?)",
{
bind: ["d", dTag],
},
);
const oldDeleted = db.changes();
if (oldDeleted === 0) {
return false;
}
}
db.exec("insert or ignore into events(id, pubkey, created, kind, json) values(?,?,?,?,?)", {
bind: [ev.id, ev.pubkey, ev.created_at, ev.kind, JSON.stringify(ev)],
});
let eventInserted = (this.#db?.changes() as number) > 0;
if (eventInserted) {
for (const t of ev.tags.filter(a => a[0].length === 1)) {
db.exec("insert into tags(event_id, key, value) values(?, ?, ?)", {
bind: [ev.id, t[0], t[1]],
});
}
}
return eventInserted;
}
/**
* Query relay by nostr filter
*/
req(req: ReqFilter) {
const start = unixNowMs();
const [sql, params] = this.#buildQuery(req);
const rows = this.#db?.exec(sql, {
bind: params,
returnValue: "resultRows",
});
const results = rows?.map(a => JSON.parse(a[0] as string) as NostrEvent) ?? [];
const time = unixNowMs() - start;
this.#log(`Query results took ${time.toLocaleString()}ms`);
return results;
}
/**
* Count results by nostr filter
*/
count(req: ReqFilter) {
const start = unixNowMs();
const [sql, params] = this.#buildQuery(req, true);
const rows = this.#db?.exec(sql, {
bind: params,
returnValue: "resultRows",
});
const results = (rows?.at(0)?.at(0) as number | undefined) ?? 0;
const time = unixNowMs() - start;
this.#log(`Query count results took ${time.toLocaleString()}ms`);
return results;
}
/**
* Get a summary about events table
*/
summary() {
const res = this.#db?.exec("select kind, count(*) from events group by kind order by 2 desc", {
returnValue: "resultRows",
});
return Object.fromEntries(res?.map(a => [String(a[0]), a[1] as number]) ?? []);
}
#buildQuery(req: ReqFilter, count = false) {
const conditions: Array<string> = [];
const params: Array<any> = [];
const repeatParams = (n: number) => {
const ret = [];
const ret: Array<string> = [];
for (let x = 0; x < n; x++) {
ret.push("?");
}
return ret.join(", ");
};
let sql = `select json from events`;
let sql = `select ${count ? "count(json)" : "json"} from events`;
const tags = Object.entries(req).filter(([k]) => k.startsWith("#"));
for (const [key, values] of tags) {
const vArray = values as Array<string>;
sql += ` inner join tags on events.id = tags.event_id and tags.key = ? and tags.value in (${repeatParams(
vArray.length,
)})`;
params.push(key);
params.push(key.slice(1));
params.push(...vArray);
}
if (req.ids) {
@ -142,13 +223,7 @@ export class WorkerRelay {
if (req.limit) {
sql += ` order by created desc limit ${req.limit}`;
}
this.#log(`Made query ${sql} from ${JSON.stringify(req)}`);
const rows = this.#db?.exec(sql, {
bind: params,
returnValue: "resultRows",
});
return rows?.map(a => JSON.parse(a[0] as string) as NostrEvent) ?? [];
return [sql, params];
}
#migrate_v1() {

View File

@ -1,6 +1,6 @@
export interface WorkerMessage<T> {
id: string;
cmd: "reply" | "init" | "open" | "migrate" | "event" | "req";
cmd: "reply" | "init" | "open" | "migrate" | "event" | "req" | "count" | "summary";
args: T;
}
@ -27,3 +27,7 @@ export interface ReqFilter {
not?: ReqFilter;
[key: string]: Array<string> | Array<number> | string | number | undefined | ReqFilter;
}
export function unixNowMs() {
return new Date().getTime();
}

View File

@ -13,43 +13,77 @@ async function reply<T>(id: string, obj?: T) {
} as WorkerMessage<T>);
}
// Event inserter queue
let eventWriteQueue: Array<NostrEvent> = [];
async function insertBatch() {
if (eventWriteQueue.length > 0) {
relay.eventBatch(eventWriteQueue);
eventWriteQueue = [];
}
setTimeout(() => insertBatch(), 100);
}
setTimeout(() => insertBatch(), 100);
globalThis.onclose = () => {
relay.close();
};
globalThis.onmessage = async ev => {
//console.debug(ev);
const msg = ev.data as WorkerMessage<any>;
switch (msg.cmd) {
case "init": {
await relay.init();
reply(msg.id, true);
break;
}
case "open": {
await relay.open("/relay.db");
reply(msg.id, true);
break;
}
case "migrate": {
await relay.migrate();
reply(msg.id, true);
break;
}
case "event": {
await relay.event(msg.args as NostrEvent);
reply(msg.id, true);
break;
}
case "req": {
const req = msg.args as ReqCommand;
const results = [];
for (const r of req.slice(2)) {
results.push(...(await relay.req(r as ReqFilter)));
try {
switch (msg.cmd) {
case "init": {
await relay.init();
reply(msg.id, true);
break;
}
case "open": {
await relay.open("/relay.db");
reply(msg.id, true);
break;
}
case "migrate": {
relay.migrate();
reply(msg.id, true);
break;
}
case "event": {
eventWriteQueue.push(msg.args as NostrEvent);
reply(msg.id, true);
break;
}
case "req": {
const req = msg.args as ReqCommand;
const results = [];
for (const r of req.slice(2)) {
results.push(...relay.req(r as ReqFilter));
}
reply(msg.id, results);
break;
}
case "count": {
const req = msg.args as ReqCommand;
let results = 0;
for (const r of req.slice(2)) {
const c = relay.count(r as ReqFilter);
results += c;
}
reply(msg.id, results);
break;
}
case "summary": {
const res = relay.summary();
reply(msg.id, res);
break;
}
default: {
reply(msg.id, { error: "Unknown command" });
break;
}
reply(msg.id, results);
break;
}
default: {
reply(msg.id, { error: "Unknown command" });
break;
}
} catch (e) {
reply(msg.id, { error: e });
}
};

View File

@ -2969,6 +2969,7 @@ __metadata:
eslint-plugin-react-hooks: ^4.6.0
eslint-plugin-react-refresh: ^0.4.5
eslint-plugin-simple-import-sort: ^10.0.0
eventemitter3: ^5.0.1
fuse.js: ^7.0.0
highlight.js: ^11.8.0
light-bolt11-decoder: ^2.1.0