feat: upgrade caches to worker

This commit is contained in:
2024-01-17 15:47:01 +00:00
parent 3c808688f8
commit aa58ec4185
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
*/