Cache all the things
continuous-integration/drone/push Build encountered an error Details

This commit is contained in:
Kieran 2023-09-05 14:57:50 +01:00
parent 5521f685fc
commit b1459d0f49
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
49 changed files with 805 additions and 243 deletions

View File

@ -0,0 +1,124 @@
import debug from "debug";
import { EventKind, RequestBuilder, SystemInterface, TaggedNostrEvent } from "@snort/system";
import { unixNow, unixNowMs } from "@snort/shared";
import { db } from "Db";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
import { LoginSession } from "Login";
import { Day, Hour } from "Const";
const WindowSize = Hour * 6;
const MaxCacheWindow = Day * 7;
export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
#kinds = [EventKind.TextNote, EventKind.Repost, EventKind.Polls];
#oldest: number = 0;
constructor() {
super("FollowsFeedCache", db.followsFeed);
}
key(of: TWithCreated<TaggedNostrEvent>): string {
return of.id;
}
takeSnapshot(): TWithCreated<TaggedNostrEvent>[] {
return [...this.cache.values()];
}
buildSub(session: LoginSession, rb: RequestBuilder): void {
const since = this.newest();
rb.withFilter()
.kinds(this.#kinds)
.authors(session.follows.item)
.since(since === 0 ? unixNow() - WindowSize : since);
}
async onEvent(evs: readonly TaggedNostrEvent[]): Promise<void> {
const filtered = evs.filter(a => this.#kinds.includes(a.kind));
if(filtered.length > 0) {
await this.bulkSet(filtered);
this.notifyChange(filtered.map(a => this.key(a)));
}
}
override async preload() {
const start = unixNowMs();
const keys = (await this.table?.toCollection().primaryKeys()) ?? [];
this.onTable = new Set<string>(keys.map(a => a as string));
// load only latest 10 posts, rest can be loaded on-demand
const latest = await this.table?.orderBy("created_at").reverse().limit(50).toArray();
latest?.forEach(v => this.cache.set(this.key(v), v));
// cleanup older than 7 days
await this.table?.where("created_at").below(unixNow() - MaxCacheWindow).delete();
const oldest = await this.table?.orderBy("created_at").first();
this.#oldest = oldest?.created_at ?? 0;
this.notifyChange(latest?.map(a => this.key(a)) ?? []);
debug(this.name)(
`Loaded %d/%d in %d ms`,
latest?.length ?? 0,
keys.length,
(unixNowMs() - start).toLocaleString(),
);
}
async loadMore(system: SystemInterface, session: LoginSession, before: number) {
if(before <= this.#oldest) {
const rb = new RequestBuilder(`${this.name}-loadmore`);
rb.withFilter()
.kinds(this.#kinds)
.authors(session.follows.item)
.until(before)
.since(before - WindowSize);
await system.Fetch(rb, async evs => {
await this.bulkSet(evs);
});
} else {
const latest = await this.table?.where("created_at").between(before - WindowSize, before).reverse().sortBy("created_at");
latest?.forEach(v => {
const k = this.key(v);
this.cache.set(k, v);
this.onTable.add(k);
});
this.notifyChange(latest?.map(a => this.key(a)) ?? []);
}
}
/**
* Backfill cache with new follows
*/
async backFill(system: SystemInterface, keys: Array<string>) {
if(keys.length === 0) return;
const rb = new RequestBuilder(`${this.name}-backfill`);
rb.withFilter()
.kinds(this.#kinds)
.authors(keys)
.until(unixNow())
.since(this.#oldest ?? unixNow() - MaxCacheWindow);
await system.Fetch(rb, async evs => {
await this.bulkSet(evs);
});
}
/**
* Backfill cache based on follows list
*/
async backFillIfMissing(system: SystemInterface, keys: Array<string>) {
const start = unixNowMs();
const everything = await this.table?.toArray();
const allKeys = new Set(everything?.map(a => a.pubkey));
const missingKeys = keys.filter(a => !allKeys.has(a));
await this.backFill(system, missingKeys);
debug(this.name)(
`Backfilled %d keys in %d ms`,
missingKeys.length,
(unixNowMs() - start).toLocaleString(),
);
}
}

View File

@ -1,9 +1,10 @@
import { FeedCache } from "@snort/shared";
import { EventKind, EventPublisher, TaggedNostrEvent } from "@snort/system";
import { EventKind, EventPublisher, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { UnwrappedGift, db } from "Db";
import { findTag, unwrap } from "SnortUtils";
import { RefreshFeedCache } from "./RefreshFeedCache";
import { LoginSession } from "Login";
export class GiftWrapCache extends FeedCache<UnwrappedGift> {
export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
constructor() {
super("GiftWrapCache", db.gifts);
}
@ -12,22 +13,20 @@ export class GiftWrapCache extends FeedCache<UnwrappedGift> {
return of.id;
}
override async preload(): Promise<void> {
await super.preload();
await this.buffer([...this.onTable]);
buildSub(session: LoginSession, rb: RequestBuilder): void {
const pubkey = session.publicKey;
if(pubkey) {
rb.withFilter()
.kinds([EventKind.GiftWrap])
.tag("p", [pubkey]).since(this.newest());
}
}
newest(): number {
let ret = 0;
this.cache.forEach(v => (ret = v.created_at > ret ? v.created_at : ret));
return ret;
}
takeSnapshot(): Array<UnwrappedGift> {
return [...this.cache.values()];
}
async onEvent(evs: Array<TaggedNostrEvent>, pub: EventPublisher) {
override async onEvent(evs: Array<TaggedNostrEvent>, pub: EventPublisher) {
const unwrapped = (
await Promise.all(
evs.map(async v => {

View File

@ -1,8 +1,9 @@
import { EventKind, NostrEvent, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
import { LoginSession } from "Login";
import { unixNow } from "SnortUtils";
import { db } from "Db";
import { Day } from "Const";
import { unixNow } from "@snort/shared";
export class NotificationsCache extends RefreshFeedCache<NostrEvent> {
#kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt];
@ -17,7 +18,7 @@ export class NotificationsCache extends RefreshFeedCache<NostrEvent> {
rb.withFilter()
.kinds(this.#kinds)
.tag("p", [session.publicKey])
.since(newest === 0 ? unixNow() - 60 * 60 * 24 * 30 : newest);
.since(newest === 0 ? unixNow() - (Day * 30): newest);
}
}

View File

@ -1,12 +1,12 @@
import { FeedCache } from "@snort/shared";
import { RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { EventPublisher, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { LoginSession } from "Login";
export type TWithCreated<T> = T & { created_at: number };
export type TWithCreated<T> = (T | Readonly<T>) & { created_at: number };
export abstract class RefreshFeedCache<T> extends FeedCache<TWithCreated<T>> {
abstract buildSub(session: LoginSession, rb: RequestBuilder): void;
abstract onEvent(evs: Readonly<Array<TaggedNostrEvent>>): void;
abstract onEvent(evs: Readonly<Array<TaggedNostrEvent>>, pub: EventPublisher): void;
/**
* Get latest event

View File

@ -4,6 +4,7 @@ import { ChatCache } from "./ChatCache";
import { Payments } from "./PaymentsCache";
import { GiftWrapCache } from "./GiftWrapCache";
import { NotificationsCache } from "./Notifications";
import { FollowsFeedCache } from "./FollowsFeed";
export const UserCache = new UserProfileCache();
export const UserRelays = new UserRelaysCache();
@ -13,6 +14,7 @@ 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 async function preload(follows?: Array<string>) {
const preloads = [
@ -23,6 +25,7 @@ export async function preload(follows?: Array<string>) {
RelayMetrics.preload(),
GiftsCache.preload(),
Notifications.preload(),
FollowsFeed.preload(),
];
await Promise.all(preloads);
}

View File

@ -1,5 +1,15 @@
import { RelaySettings } from "@snort/system";
/**
* 1 Hour in seconds
*/
export const Hour = 60 * 60;
/**
* 1 Day in seconds
*/
export const Day = Hour * 24;
/**
* Add-on api for snort features
*/

View File

@ -1,8 +1,8 @@
import Dexie, { Table } from "dexie";
import { HexKey, NostrEvent, u256 } from "@snort/system";
import { HexKey, NostrEvent, TaggedNostrEvent, u256 } from "@snort/system";
export const NAME = "snortDB";
export const VERSION = 13;
export const VERSION = 14;
export interface SubCache {
id: string;
@ -41,6 +41,7 @@ const STORES = {
payments: "++url",
gifts: "++id",
notifications: "++id",
followsFeed: "++id, created_at, kind"
};
export class SnortDB extends Dexie {
@ -50,6 +51,7 @@ export class SnortDB extends Dexie {
payments!: Table<Payment>;
gifts!: Table<UnwrappedGift>;
notifications!: Table<NostrEvent>;
followsFeed!: Table<TaggedNostrEvent>;
constructor() {
super(NAME);

View File

@ -56,7 +56,7 @@ export default function DM(props: DMProps) {
<div className={isMe ? "dm me" : "dm other"} ref={ref}>
<div>
{sender()}
<Text content={content} tags={[]} creator={otherPubkey} />
<Text id={msg.id} content={content} tags={[]} creator={otherPubkey} />
</div>
<div>
<NoteTime from={msg.created_at * 1000} fallback={formatMessage(messages.JustNow)} />

View File

@ -9,6 +9,7 @@ import AsyncButton from "Element/AsyncButton";
import { System } from "index";
import messages from "./messages";
import { FollowsFeed } from "Cache";
export interface FollowButtonProps {
pubkey: HexKey;
@ -24,6 +25,7 @@ export default function FollowButton(props: FollowButtonProps) {
async function follow(pubkey: HexKey) {
if (publisher) {
const ev = await publisher.contactList([pubkey, ...follows.item], relays.item);
await FollowsFeed.backFill(System, [pubkey]);
System.BroadcastEvent(ev);
}
}

View File

@ -8,6 +8,7 @@ import useLogin from "Hooks/useLogin";
import { System } from "index";
import messages from "./messages";
import { FollowsFeed } from "Cache";
export interface FollowListBaseProps {
pubkeys: HexKey[];
@ -34,6 +35,7 @@ export default function FollowListBase({
async function followAll() {
if (publisher) {
const ev = await publisher.contactList([...pubkeys, ...follows.item], relays.item);
await FollowsFeed.backFill(System, pubkeys);
System.BroadcastEvent(ev);
}
}

View File

@ -2,7 +2,7 @@ import { FormattedMessage } from "react-intl";
import { NostrEvent, NostrLink } from "@snort/system";
import { findTag } from "SnortUtils";
import useEventFeed from "Feed/EventFeed";
import { useEventFeed } from "Feed/EventFeed";
import PageSpinner from "Element/PageSpinner";
import Reveal from "Element/Reveal";
import { MediaElement } from "Element/MediaElement";

View File

@ -36,6 +36,8 @@ import { LiveEvent } from "Element/LiveEvent";
import { NoteContextMenu, NoteTranslation } from "Element/NoteContextMenu";
import Reactions from "Element/Reactions";
import { ZapGoal } from "Element/ZapGoal";
import NoteReaction from "Element/NoteReaction";
import ProfilePreview from "Element/ProfilePreview";
import messages from "./messages";
@ -81,8 +83,10 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => {
};
export default function Note(props: NoteProps) {
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props;
const { data: ev, className } = props;
if (ev.kind === EventKind.Repost) {
return <NoteReaction data={ev} key={ev.id} root={undefined} depth={(props.depth ?? 0) + 1} />;
}
if (ev.kind === EventKind.FileHeader) {
return <NostrFileElement ev={ev} />;
}
@ -95,10 +99,19 @@ export default function Note(props: NoteProps) {
if (ev.kind === EventKind.LiveEvent) {
return <LiveEvent ev={ev} />;
}
if (ev.kind === EventKind.SetMetadata) {
return <ProfilePreview actions={<></>} pubkey={ev.pubkey} className="card" />;
}
if (ev.kind === (9041 as EventKind)) {
return <ZapGoal ev={ev} />;
}
return <NoteInner {...props} />
}
export function NoteInner(props: NoteProps) {
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props;
const baseClassName = `note card${className ? ` ${className}` : ""}`;
const navigate = useNavigate();
const [showReactions, setShowReactions] = useState(false);
@ -209,12 +222,13 @@ export default function Note(props: NoteProps) {
)}
</>
}>
<Text content={body} tags={ev.tags} creator={ev.pubkey} />
<Text id={ev.id} content={body} tags={ev.tags} creator={ev.pubkey} />
</Reveal>
);
}
return (
<Text
id={ev.id}
content={body}
tags={ev.tags}
creator={ev.pubkey}
@ -307,7 +321,7 @@ export default function Note(props: NoteProps) {
if (alt) {
return (
<div className="note-quote">
<Text content={alt} tags={[]} creator={ev.pubkey} />
<Text id={ev.id} content={alt} tags={[]} creator={ev.pubkey} />
</div>
);
} else {
@ -379,7 +393,7 @@ export default function Note(props: NoteProps) {
{options.showContextMenu && (
<NoteContextMenu
ev={ev}
react={async () => {}}
react={async () => { }}
onTranslated={t => setTranslated(t)}
setShowReactions={setShowReactions}
/>

View File

@ -1,4 +1,4 @@
import useEventFeed from "Feed/EventFeed";
import { useEventFeed } from "Feed/EventFeed";
import { NostrLink } from "@snort/system";
import Note from "Element/Note";
import PageSpinner from "Element/PageSpinner";

View File

@ -14,6 +14,7 @@ import { useUserProfile } from "@snort/system-react";
export interface NoteReactionProps {
data: TaggedNostrEvent;
root?: TaggedNostrEvent;
depth?: number;
}
export default function NoteReaction(props: NoteReactionProps) {
const { data: ev } = props;
@ -47,7 +48,7 @@ export default function NoteReaction(props: NoteReactionProps) {
try {
const r: NostrEvent = JSON.parse(ev.content);
EventExt.fixupEvent(r);
if(!EventExt.verify(r)) {
if (!EventExt.verify(r)) {
console.debug("Event in repost is invalid");
return undefined;
}
@ -78,7 +79,7 @@ export default function NoteReaction(props: NoteReactionProps) {
}}
/>
</div>
{root ? <Note data={root} options={opt} related={[]} /> : null}
{root ? <Note data={root} options={opt} related={[]} depth={props.depth} /> : null}
{!root && refEvent ? (
<p>
<Link to={eventLink(refEvent[1] ?? "", refEvent[2])}>

View File

@ -116,7 +116,7 @@ export default function Poll(props: PollProps) {
{opt === voting ? (
<Spinner />
) : (
<Text content={desc} tags={props.ev.tags} creator={props.ev.pubkey} disableMediaSpotlight={true} />
<Text id={props.ev.id} content={desc} tags={props.ev.tags} creator={props.ev.pubkey} disableMediaSpotlight={true} />
)}
</div>
{showResults && (

View File

@ -3,10 +3,11 @@ import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { RelaySettings } from "@snort/system";
import { unixNowMs } from "@snort/shared";
import useRelayState from "Feed/RelayState";
import { System } from "index";
import { getRelayName, unixNowMs, unwrap } from "SnortUtils";
import { getRelayName, unwrap } from "SnortUtils";
import useLogin from "Hooks/useLogin";
import { setRelays } from "Login";
import Icon from "Icons/Icon";

View File

@ -6,10 +6,8 @@
.text .text-frag {
text-overflow: ellipsis;
white-space: pre-wrap;
word-break: normal;
overflow-x: hidden;
overflow-y: visible;
display: inline;
overflow-wrap: break-word;
}
.text .text-frag > a {

View File

@ -11,70 +11,109 @@ import { ProxyImg } from "./ProxyImg";
import { SpotlightMedia } from "./SpotlightMedia";
export interface TextProps {
id: string;
content: string;
creator: HexKey;
tags: Array<Array<string>>;
disableMedia?: boolean;
disableMediaSpotlight?: boolean;
disableLinkPreview?: boolean;
depth?: number;
truncate?: number;
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
export default function Text({ content, tags, creator, disableMedia, depth, disableMediaSpotlight }: TextProps) {
const TextCache = new Map<string, Array<ParsedFragment>>();
export default function Text({
id,
content,
tags,
creator,
disableMedia,
depth,
disableMediaSpotlight,
disableLinkPreview,
truncate,
className,
onClick
}: TextProps) {
const [showSpotlight, setShowSpotlight] = useState(false);
const [imageIdx, setImageIdx] = useState(0);
const elements = useMemo(() => {
return transformText(content, tags);
}, [content]);
const cached = TextCache.get(id);
if (cached) return cached;
const newCache = transformText(content, tags);
TextCache.set(id, newCache);
return newCache;
}, [content, id]);
const images = elements.filter(a => a.type === "media" && a.mimeType?.startsWith("image")).map(a => a.content);
function renderChunk(a: ParsedFragment) {
if (a.type === "media" && !a.mimeType?.startsWith("unknown")) {
if (disableMedia ?? false) {
return (
<a href={a.content} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a.content}
</a>
);
const renderContent = () => {
let lenCtr = 0;
function renderChunk(a: ParsedFragment) {
if (truncate) {
if (lenCtr > truncate) {
return null;
} else if (lenCtr + a.content.length > truncate) {
lenCtr += a.content.length;
return <div className="text-frag">{a.content.slice(0, truncate - lenCtr)}...</div>
} else {
lenCtr += a.content.length;
}
}
return (
<RevealMedia
link={a.content}
creator={creator}
onMediaClick={e => {
if (!disableMediaSpotlight) {
e.stopPropagation();
e.preventDefault();
setShowSpotlight(true);
const selected = images.findIndex(b => b === a.content);
setImageIdx(selected === -1 ? 0 : selected);
}
}}
/>
);
} else {
switch (a.type) {
case "invoice":
return <Invoice invoice={a.content} />;
case "hashtag":
return <Hashtag tag={a.content} />;
case "cashu":
return <CashuNuts token={a.content} />;
case "media":
case "link":
return <HyperText link={a.content} depth={depth} showLinkPreview={!(disableMedia ?? false)}/>;
case "custom_emoji":
return <ProxyImg src={a.content} size={15} className="custom-emoji" />;
default:
return <div className="text-frag">{a.content}</div>;
if (a.type === "media" && !a.mimeType?.startsWith("unknown")) {
if (disableMedia ?? false) {
return (
<a href={a.content} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a.content}
</a>
);
}
return (
<RevealMedia
link={a.content}
creator={creator}
onMediaClick={e => {
if (!disableMediaSpotlight) {
e.stopPropagation();
e.preventDefault();
setShowSpotlight(true);
const selected = images.findIndex(b => b === a.content);
setImageIdx(selected === -1 ? 0 : selected);
}
}}
/>
);
} else {
switch (a.type) {
case "invoice":
return <Invoice invoice={a.content} />;
case "hashtag":
return <Hashtag tag={a.content} />;
case "cashu":
return <CashuNuts token={a.content} />;
case "media":
case "link":
return <HyperText link={a.content} depth={depth} showLinkPreview={!(disableLinkPreview ?? false)} />;
case "custom_emoji":
return <ProxyImg src={a.content} size={15} className="custom-emoji" />;
default:
return <div className="text-frag">{a.content}</div>;
}
}
}
return elements.map(a => renderChunk(a));
}
return (
<div dir="auto" className="text">
{elements.map(a => renderChunk(a))}
<div dir="auto" className={`text${className ? ` ${className}` : ""}`} onClick={onClick}>
{renderContent()}
{showSpotlight && <SpotlightMedia images={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />}
</div>
);

View File

@ -2,18 +2,14 @@ import "./Timeline.css";
import { FormattedMessage } from "react-intl";
import { useCallback, useMemo } from "react";
import { useInView } from "react-intersection-observer";
import { TaggedNostrEvent, EventKind, u256, parseZap } from "@snort/system";
import { TaggedNostrEvent, EventKind, u256 } from "@snort/system";
import Icon from "Icons/Icon";
import { dedupeByPubkey, findTag, tagFilterOfTextRepost } from "SnortUtils";
import { dedupeByPubkey, findTag } from "SnortUtils";
import ProfileImage from "Element/ProfileImage";
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "Feed/TimelineFeed";
import Zap from "Element/Zap";
import Note from "Element/Note";
import NoteReaction from "Element/NoteReaction";
import useModeration from "Hooks/useModeration";
import ProfilePreview from "Element/ProfilePreview";
import { UserCache } from "Cache";
import { LiveStreams } from "Element/LiveStreams";
export interface TimelineProps {
@ -28,7 +24,7 @@ export interface TimelineProps {
}
/**
* A list of notes by pubkeys
* A list of notes by "subject"
*/
const Timeline = (props: TimelineProps) => {
const feedOptions = useMemo(() => {
@ -70,44 +66,10 @@ const Timeline = (props: TimelineProps) => {
return (feed.main ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
}, [feed]);
const findRelated = useCallback(
(id?: u256) => {
if (!id) return undefined;
return (feed.related ?? []).find(a => a.id === id);
},
[feed.related]
);
const latestAuthors = useMemo(() => {
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
}, [latestFeed]);
function eventElement(e: TaggedNostrEvent) {
switch (e.kind) {
case EventKind.SetMetadata: {
return <ProfilePreview actions={<></>} pubkey={e.pubkey} className="card" />;
}
case EventKind.Polls:
case EventKind.TextNote: {
const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1);
if (eRef) {
return <NoteReaction data={e} key={e.id} root={findRelated(eRef)} />;
}
return (
<Note key={e.id} data={e} related={relatedFeed(e.id)} ignoreModeration={props.ignoreModeration} depth={0} />
);
}
case EventKind.ZapReceipt: {
const zap = parseZap(e, UserCache);
return zap.event ? null : <Zap zap={zap} key={e.id} />;
}
case EventKind.Reaction:
case EventKind.Repost: {
const eRef = findTag(e, "e");
return <NoteReaction data={e} key={e.id} root={findRelated(eRef)} />;
}
}
}
function onShowLatest(scrollToTop = false) {
feed.showLatest();
if (scrollToTop) {
@ -144,7 +106,7 @@ const Timeline = (props: TimelineProps) => {
)}
</>
)}
{mainFeed.map(eventElement)}
{mainFeed.map(e => <Note key={e.id} data={e} related={relatedFeed(e.id)} ignoreModeration={props.ignoreModeration} depth={0} />)}
{(props.loadMore === undefined || props.loadMore === true) && (
<div className="flex f-center">
<button type="button" onClick={() => feed.loadMore()}>

View File

@ -0,0 +1,118 @@
import "./Timeline.css";
import { useCallback, useContext, useMemo, useState, useSyncExternalStore } from "react";
import { FormattedMessage } from "react-intl";
import { TaggedNostrEvent, EventKind, u256, NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import { SnortContext } from "@snort/system-react";
import { useInView } from "react-intersection-observer";
import { dedupeByPubkey, findTag, orderDescending } from "SnortUtils";
import Note from "Element/Note";
import useModeration from "Hooks/useModeration";
import { FollowsFeed } from "Cache";
import { LiveStreams } from "Element/LiveStreams";
import { useReactions } from "Feed/FeedReactions";
import AsyncButton from "./AsyncButton";
import useLogin from "Hooks/useLogin";
import ProfileImage from "Element/ProfileImage";
import Icon from "Icons/Icon";
export interface TimelineFollowsProps {
postsOnly: boolean;
}
/**
* A list of notes by "subject"
*/
const TimelineFollows = (props: TimelineFollowsProps) => {
const [latest, setLatest] = useState(unixNow());
const feed = useSyncExternalStore(cb => FollowsFeed.hook(cb, "*"), () => FollowsFeed.snapshot())
const reactions = useReactions("follows-feed-reactions", feed.map(a => a.id));
const system = useContext(SnortContext);
const login = useLogin();
const { muted, isMuted } = useModeration();
const { ref, inView } = useInView();
const sortedFeed = useMemo(() => orderDescending(feed), [feed]);
const filterPosts = useCallback(
function <T extends NostrEvent>(nts: Array<T>) {
const a = nts.filter(a => a.kind !== EventKind.LiveEvent);
return a
?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : true))
.filter(a => !isMuted(a.pubkey) && login.follows.item.includes(a.pubkey));
},
[props.postsOnly, muted, login.follows.timestamp]
);
const mainFeed = useMemo(() => {
return filterPosts((sortedFeed ?? []).filter(a => a.created_at <= latest));
}, [sortedFeed, filterPosts, latest, login.follows.timestamp]);
const latestFeed = useMemo(() => {
return filterPosts((sortedFeed ?? []).filter(a => a.created_at > latest));
}, [sortedFeed, latest]);
const relatedFeed = useCallback(
(id: u256) => {
return (reactions?.data ?? []).filter(a => findTag(a, "e") === id);
},
[reactions]
);
const liveStreams = useMemo(() => {
return (sortedFeed ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
}, [sortedFeed]);
const latestAuthors = useMemo(() => {
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
}, [latestFeed]);
function onShowLatest(scrollToTop = false) {
setLatest(unixNow());
if (scrollToTop) {
window.scrollTo(0, 0);
}
}
return (
<>
<LiveStreams evs={liveStreams} />
{latestFeed.length > 0 && (
<>
<div className="card latest-notes" onClick={() => onShowLatest()} ref={ref}>
{latestAuthors.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
values={{ n: latestFeed.length }}
/>
<Icon name="arrowUp" />
</div>
{!inView && (
<div className="card latest-notes latest-notes-fixed pointer fade-in" onClick={() => onShowLatest(true)}>
{latestAuthors.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
values={{ n: latestFeed.length }}
/>
<Icon name="arrowUp" />
</div>
)}
</>
)}
{mainFeed.map(a => <Note data={a as TaggedNostrEvent} related={relatedFeed(a.id)} key={a.id} depth={0} />)}
<div className="flex f-center p">
<AsyncButton onClick={async () => {
await FollowsFeed.loadMore(system, login, sortedFeed[sortedFeed.length - 1].created_at);
}}>
<FormattedMessage defaultMessage="Load more" />
</AsyncButton>
</div>
</>
);
};
export default TimelineFollows;

View File

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

View File

@ -1,10 +1,10 @@
import { useMemo } from "react";
import { NostrPrefix, RequestBuilder, ReplaceableNoteStore, NostrLink } from "@snort/system";
import { NostrPrefix, RequestBuilder, ReplaceableNoteStore, NostrLink, NoteCollection } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { unwrap } from "SnortUtils";
export default function useEventFeed(link: NostrLink) {
export function useEventFeed(link: NostrLink) {
const sub = useMemo(() => {
const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`);
if (link.type === NostrPrefix.Address) {
@ -29,3 +29,31 @@ export default function useEventFeed(link: NostrLink) {
return useRequestBuilder(ReplaceableNoteStore, sub);
}
export function useEventsFeed(id: string, links: Array<NostrLink>) {
const sub = useMemo(() => {
const b = new RequestBuilder(`events:${id}`);
for(const l of links) {
if (l.type === NostrPrefix.Address) {
const f = b.withFilter().tag("d", [l.id]);
if (l.author) {
f.authors([unwrap(l.author)]);
}
if (l.kind) {
f.kinds([unwrap(l.kind)]);
}
} else {
const f = b.withFilter().ids([l.id]);
if (l.relays) {
l.relays.slice(0, 2).forEach(r => f.relay(r));
}
if (l.author) {
f.authors([l.author]);
}
}
}
return b;
}, [id, links]);
return useRequestBuilder(NoteCollection, sub);
}

View File

@ -12,9 +12,10 @@ import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPi
import { SnortPubKey } from "Const";
import { SubscriptionEvent } from "Subscription";
import useRelaysFeedFollows from "./RelaysFeedFollows";
import { GiftsCache, Notifications, UserRelays } from "Cache";
import { FollowsFeed, GiftsCache, Notifications, UserRelays } from "Cache";
import { System } from "index";
import { Nip29Chats, Nip4Chats } from "chat";
import { Nip4Chats } from "chat";
import { useRefreshFeedCache } from "Hooks/useRefreshFeedcache";
/**
* Managed loading data for the current logged in user
@ -25,6 +26,12 @@ export default function useLoginFeed() {
const { isMuted } = useModeration();
const publisher = useEventPublisher();
useRefreshFeedCache(Notifications, true);
useRefreshFeedCache(FollowsFeed, true);
if(publisher?.supports("nip44")) {
useRefreshFeedCache(GiftsCache, true);
}
const subLogin = useMemo(() => {
if (!pubKey) return null;
@ -38,10 +45,8 @@ export default function useLoginFeed() {
.authors([bech32ToHex(SnortPubKey)])
.tag("p", [pubKey])
.limit(1);
b.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubKey]).since(GiftsCache.newest());
b.add(Nip4Chats.subscription(pubKey));
Notifications.buildSub(login, b);
return b;
}, [pubKey]);
@ -73,14 +78,11 @@ export default function useLoginFeed() {
}
const pTags = contactList.tags.filter(a => a[0] === "p").map(a => a[1]);
setFollows(login, pTags, contactList.created_at * 1000);
FollowsFeed.backFillIfMissing(System, pTags);
}
Nip4Chats.onEvent(loginFeed.data);
Nip29Chats.onEvent(loginFeed.data);
Notifications.onEvent(loginFeed.data);
const giftWraps = loginFeed.data.filter(a => a.kind === EventKind.GiftWrap);
GiftsCache.onEvent(giftWraps, publisher);
const subs = loginFeed.data.filter(
a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey)

View File

@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo } from "react";
import { EventKind, NoteCollection, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared";
import { unixNow, unwrap, tagFilterOfTextRepost } from "SnortUtils";
import { unwrap, tagFilterOfTextRepost } from "SnortUtils";
import useTimelineWindow from "Hooks/useTimelineWindow";
import useLogin from "Hooks/useLogin";
import { SearchRelays } from "Const";

View File

@ -0,0 +1,52 @@
import { SnortContext } from "@snort/system-react";
import { useContext, useEffect, useMemo } from "react";
import { NoopStore, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { unwrap } from "@snort/shared";
import { RefreshFeedCache } from "Cache/RefreshFeedCache";
import useLogin from "./useLogin";
export function useRefreshFeedCache<T>(c: RefreshFeedCache<T>, leaveOpen = false) {
const system = useContext(SnortContext);
const login = useLogin();
const sub = useMemo(() => {
if (login) {
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(NoopStore, sub);
let t: ReturnType<typeof setTimeout> | undefined;
let tBuf: Array<TaggedNostrEvent> = [];
const releaseOnEvent = q.feed.onEvent(evs => {
if (!t) {
tBuf = [...evs];
t = setTimeout(() => {
t = undefined;
c.onEvent(tBuf, unwrap(login.publisher));
}, 100);
} else {
tBuf.push(...evs);
}
})
q.uncancel();
return () => {
q.cancel();
q.sendClose();
releaseOnEvent();
};
}
return () => {
// noop
};
}, [sub]);
}

View File

@ -1,13 +1,15 @@
import { HexKey, RelaySettings, EventPublisher } from "@snort/system";
import { unixNowMs } from "@snort/shared";
import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
import { DefaultRelays, SnortPubKey } from "Const";
import { LoginStore, UserPreferences, LoginSession } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unixNowMs, unwrap } from "SnortUtils";
import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unwrap } from "SnortUtils";
import { SubscriptionEvent } from "Subscription";
import { System } from "index";
import { Chats, FollowsFeed, GiftsCache, Notifications } from "Cache";
export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) {
if (state.relays.timestamp >= createdAt) {
@ -41,8 +43,10 @@ export function updatePreferences(state: LoginSession, p: UserPreferences) {
export function logout(k: HexKey) {
LoginStore.removeSession(k);
//TODO: delete giftwarps for:k
//TODO: delete notifications for:k
GiftsCache.clear();
Notifications.clear();
FollowsFeed.clear();
Chats.clear();
}
export function markNotificationsRead(state: LoginSession) {

View File

@ -13,6 +13,10 @@ export class Nip7OsSigner implements EventSigner {
}
}
get supports(): string[] {
return ["nip04"];
}
init(): Promise<void> {
return Promise.resolve();
}

View File

@ -148,7 +148,7 @@ function ProfileDmActions({ id }: { id: string }) {
<Avatar pubkey={pubkey} user={profile} size={210} />
<h2>{getDisplayName(profile, pubkey)}</h2>
<p>
<Text content={truncAbout(profile?.about) ?? ""} tags={[]} creator={pubkey} disableMedia={true} depth={0} />
<Text id={pubkey} content={truncAbout(profile?.about) ?? ""} tags={[]} creator={pubkey} disableMedia={true} depth={0} />
</p>
<div className="settings-row" onClick={() => (blocked ? unblock(pubkey) : block(pubkey))}>

View File

@ -25,6 +25,10 @@
line-height: 1em;
}
.notification-group > div:last-of-type {
max-width: calc(100% - 64px);
}
.notification-group .avatar {
width: 40px;
height: 40px;
@ -39,8 +43,11 @@
}
.notification-group .content {
font-size: 14px;
line-height: 22px;
cursor: pointer;
color: var(--font-secondary-color);
word-break: break-all;
}
.notification-group .content img {
width: unset;
max-height: 300px; /* Cap images in notifications to 300px height */
}

View File

@ -14,6 +14,7 @@ import { unwrap } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import useLogin from "Hooks/useLogin";
import { markNotificationsRead } from "Login";
@ -22,10 +23,9 @@ import { dedupe, findTag, orderDescending } from "SnortUtils";
import Icon from "Icons/Icon";
import ProfileImage, { getDisplayName } from "Element/ProfileImage";
import useModeration from "Hooks/useModeration";
import useEventFeed from "Feed/EventFeed";
import { useEventFeed } from "Feed/EventFeed";
import Text from "Element/Text";
import { formatShort } from "Number";
import { useNavigate } from "react-router-dom";
function notificationContext(ev: TaggedNostrEvent) {
switch (ev.kind) {
@ -85,7 +85,7 @@ export default function NotificationsPage() {
const timeGrouped = useMemo(() => {
return orderDescending([...notifications])
.filter(a => !isMuted(a.pubkey) && findTag(a, "p") === login.publicKey)
.filter(a => !isMuted(a.pubkey) && a.tags.some(b => b[0] === "p" && b[1] === login.publicKey))
.reduce((acc, v) => {
const key = `${timeKey(v)}:${notificationContext(v as TaggedNostrEvent)?.encode()}:${v.kind}`;
if (acc.has(key)) {
@ -217,7 +217,7 @@ function NotificationGroup({ evs }: { evs: Array<TaggedNostrEvent> }) {
)}
</div>
)}
<div className="content">{context && <NotificationContext link={context} />}</div>
{context && <NotificationContext link={context} />}
</div>
</>
)}
@ -228,15 +228,15 @@ function NotificationGroup({ evs }: { evs: Array<TaggedNostrEvent> }) {
function NotificationContext({ link }: { link: NostrLink }) {
const { data: ev } = useEventFeed(link);
const navigate = useNavigate();
const content = ev?.content ?? "";
return (
<div onClick={() => navigate(`/${link.encode()}`)} className="pointer">
<Text
content={content.length > 120 ? `${content.substring(0, 120)}...` : content}
tags={ev?.tags ?? []}
creator={ev?.pubkey ?? ""}
/>
</div>
);
return ev && <Text
id={ev.id}
content={ev.content}
tags={ev.tags}
creator={ev.pubkey}
truncate={120}
disableLinkPreview={true}
className="content"
onClick={() => navigate(`/${link.encode()}`)}
/>
}

View File

@ -118,13 +118,6 @@ export default function ProfilePage() {
const [showLnQr, setShowLnQr] = useState<boolean>(false);
const [showProfileQr, setShowProfileQr] = useState<boolean>(false);
const aboutText = user?.about || "";
const about = Text({
content: aboutText,
tags: [],
creator: id ?? "",
disableMedia: true,
disableMediaSpotlight: true,
});
const npub = !id?.startsWith(NostrPrefix.PublicKey) ? hexToBech32(NostrPrefix.PublicKey, id || undefined) : id;
const lnurl = (() => {
@ -309,10 +302,12 @@ export default function ProfilePage() {
}
function bio() {
if (!id) return null;
return (
aboutText.length > 0 && (
<div dir="auto" className="about">
{about}
<Text id={id} content={aboutText} tags={[]} creator={id} disableMedia={true} disableLinkPreview={true} disableMediaSpotlight={true} />
</div>
)
);

View File

@ -7,7 +7,7 @@ import "./Root.css";
import Timeline from "Element/Timeline";
import { System } from "index";
import { TimelineSubject } from "Feed/TimelineFeed";
import { debounce, getRelayName, sha256, unixNow } from "SnortUtils";
import { debounce, getRelayName, sha256 } from "SnortUtils";
import useLogin from "Hooks/useLogin";
import Discover from "Pages/Discover";
import Icon from "Icons/Icon";
@ -16,8 +16,10 @@ import TrendingNotes from "Element/TrendingPosts";
import HashTagsPage from "Pages/HashTagsPage";
import SuggestedProfiles from "Element/SuggestedProfiles";
import { TaskList } from "Tasks/TaskList";
import TimelineFollows from "Element/TimelineFollows";
import messages from "./messages";
import { unixNow } from "@snort/shared";
interface RelayOption {
url: string;
@ -271,32 +273,17 @@ const GlobalTab = () => {
};
const NotesTab = () => {
const { follows, publicKey } = useLogin();
const subject: TimelineSubject = {
type: "pubkey",
items: follows.item,
discriminator: `follows:${publicKey?.slice(0, 12)}`,
streams: true,
};
return (
<>
<FollowsHint />
<TaskList />
<Timeline subject={subject} postsOnly={true} method={"TIME_RANGE"} />
<TimelineFollows postsOnly={true} />
</>
);
};
const ConversationsTab = () => {
const { follows, publicKey } = useLogin();
const subject: TimelineSubject = {
type: "pubkey",
items: follows.item,
discriminator: `follows:${publicKey?.slice(0, 12)}`,
};
return <Timeline subject={subject} postsOnly={false} method={"TIME_RANGE"} />;
return <TimelineFollows postsOnly={false} />;
};
const TagsTab = () => {

View File

@ -1,7 +1,8 @@
import { useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
import { unixNowMs } from "@snort/shared";
import { randomSample, unixNowMs } from "SnortUtils";
import { randomSample } from "SnortUtils";
import Relay from "Element/Relay";
import useEventPublisher from "Feed/EventPublisher";
import { System } from "index";
@ -9,6 +10,7 @@ import useLogin from "Hooks/useLogin";
import { setRelays } from "Login";
import messages from "./messages";
const RelaySettingsPage = () => {
const publisher = useEventPublisher();
const login = useLogin();

View File

@ -173,14 +173,6 @@ export function getAllReactions(notes: readonly TaggedNostrEvent[] | undefined,
return notes?.filter(a => a.kind === (kind ?? a.kind) && a.tags.some(a => a[0] === "e" && ids.includes(a[1]))) || [];
}
export function unixNow() {
return Math.floor(unixNowMs() / 1000);
}
export function unixNowMs() {
return new Date().getTime();
}
export function deepClone<T>(obj: T) {
if ("structuredClone" in window) {
return structuredClone(obj);

View File

@ -1,4 +1,4 @@
import { unixNow } from "SnortUtils";
import { unixNow } from "@snort/shared";
export enum SubscriptionType {
Supporter = 0,

View File

@ -13,9 +13,9 @@ import {
UserMetadata,
encodeTLVEntries,
} from "@snort/system";
import { unwrap } from "@snort/shared";
import { unwrap, unixNow } from "@snort/shared";
import { Chats, GiftsCache } from "Cache";
import { findTag, unixNow } from "SnortUtils";
import { findTag } from "SnortUtils";
import { Nip29ChatSystem } from "./nip29";
import useModeration from "Hooks/useModeration";
import useLogin from "Hooks/useLogin";
@ -182,8 +182,8 @@ export function useNip24Chat() {
export function useChatSystem() {
const nip4 = useNip4Chat();
const nip24 = useNip24Chat();
//const nip24 = useNip24Chat();
const { muted, blocked } = useModeration();
return [...nip4, ...nip24].filter(a => !(muted.includes(a.id) || blocked.includes(a.id)));
return [...nip4].filter(a => !(muted.includes(a.id) || blocked.includes(a.id)));
}

View File

@ -1,4 +1,4 @@
import { ExternalStore, FeedCache, dedupe } from "@snort/shared";
import { ExternalStore, FeedCache } from "@snort/shared";
import {
EventKind,
NostrEvent,
@ -6,7 +6,6 @@ import {
RequestBuilder,
SystemInterface,
TLVEntryType,
decodeTLV,
encodeTLVEntries,
TaggedNostrEvent,
} from "@snort/system";
@ -50,32 +49,31 @@ export class Nip4ChatSystem extends ExternalStore<Array<Chat>> implements ChatSy
listChats(pk: string): Chat[] {
const myDms = this.#nip4Events();
const chatId = (a: NostrEvent) => {
return encodeTLVEntries("chat4" as NostrPrefix, {
type: TLVEntryType.Author,
value: inChatWith(a, pk),
length: 0,
});
};
const chats = myDms.reduce((acc, v) => {
const chatId = inChatWith(v, pk);
acc[chatId] ??= [];
acc[chatId].push(v);
return acc;
}, {} as Record<string, Array<NostrEvent>>);
return dedupe(myDms.map(chatId)).map(a => {
const messages = myDms.filter(b => chatId(b) === a);
return Nip4ChatSystem.createChatObj(a, messages);
});
return [...Object.entries(chats)].map(([k, v]) => Nip4ChatSystem.createChatObj(k, v));
}
static createChatObj(id: string, messages: Array<NostrEvent>) {
const last = lastReadInChat(id);
const pk = decodeTLV(id).find(a => a.type === TLVEntryType.Author)?.value as string;
return {
type: ChatType.DirectMessage,
id,
id: encodeTLVEntries("chat4" as NostrPrefix, {
type: TLVEntryType.Author,
value: id,
length: 0,
}),
unread: messages.reduce((acc, v) => (v.created_at > last ? acc++ : acc), 0),
lastMessage: messages.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0),
participants: [
{
type: "pubkey",
id: pk,
id: id,
},
],
messages: messages.map(m => ({
@ -90,7 +88,7 @@ export class Nip4ChatSystem extends ExternalStore<Array<Chat>> implements ChatSy
},
})),
createMessage: async (msg, pub) => {
return [await pub.sendDm(msg, pk)];
return [await pub.sendDm(msg, id)];
},
sendMessage: (ev, system: SystemInterface) => {
ev.forEach(a => system.BroadcastEvent(a));

View File

@ -68,30 +68,39 @@ export const DefaultPowWorker = new PowWorker("/pow.js");
serviceWorkerRegistration.register();
async function initSite() {
const login = LoginStore.takeSnapshot();
db.ready = await db.isAvailable();
if (db.ready) {
await preload(login.follows.item);
}
for (const [k, v] of Object.entries(login.relays.item)) {
System.ConnectToRelay(k, v);
}
try {
if ("registerProtocolHandler" in window.navigator) {
window.navigator.registerProtocolHandler(
"web+nostr",
`${window.location.protocol}//${window.location.host}/%s`
);
console.info("Registered protocol handler for 'web+nostr'");
}
} catch (e) {
console.error("Failed to register protocol handler", e);
}
return null;
}
let didInit = false;
export const router = createBrowserRouter([
{
element: <Layout />,
errorElement: <ErrorPage />,
loader: async () => {
const login = LoginStore.takeSnapshot();
db.ready = await db.isAvailable();
if (db.ready) {
await preload(login.follows.item);
}
for (const [k, v] of Object.entries(login.relays.item)) {
System.ConnectToRelay(k, v);
}
try {
if ("registerProtocolHandler" in window.navigator) {
window.navigator.registerProtocolHandler(
"web+nostr",
`${window.location.protocol}//${window.location.host}/%s`
);
console.info("Registered protocol handler for 'web+nostr'");
}
} catch (e) {
console.error("Failed to register protocol handler", e);
if (!didInit) {
didInit = true;
return await initSite()
}
return null;
},

View File

@ -15,7 +15,7 @@ export interface KeyedHookFilter {
export abstract class FeedCache<TCached> {
#name: string;
#hooks: Array<KeyedHookFilter> = [];
#snapshot: Readonly<Array<TCached>> = [];
#snapshot: Array<TCached> = [];
#changed = true;
#hits = 0;
#miss = 0;
@ -37,6 +37,10 @@ export abstract class FeedCache<TCached> {
}, 30_000);
}
get name() {
return this.#name;
}
async preload() {
const keys = (await this.table?.toCollection().primaryKeys()) ?? [];
this.onTable = new Set<string>(keys.map(a => a as string));
@ -111,7 +115,7 @@ export abstract class FeedCache<TCached> {
this.notifyChange([k]);
}
async bulkSet(obj: Array<TCached>) {
async bulkSet(obj: Array<TCached> | Readonly<Array<TCached>>) {
if (this.table) {
await this.table.bulkPut(obj);
obj.forEach(a => this.onTable.add(this.key(a)));

23
packages/system/src/cache/events.ts vendored Normal file
View File

@ -0,0 +1,23 @@
import { NostrEvent } from "nostr";
import { db } from ".";
import { FeedCache } from "@snort/shared";
export class EventsCache extends FeedCache<NostrEvent> {
constructor() {
super("EventsCache", db.events);
}
key(of: NostrEvent): string {
return of.id;
}
override async preload(): Promise<void> {
await super.preload();
// load everything
await this.buffer([...this.onTable]);
}
takeSnapshot(): Array<NostrEvent> {
return [...this.cache.values()];
}
}

View File

@ -13,6 +13,7 @@ import {
PowMiner,
PrivateKeySigner,
RelaySettings,
SignerSupports,
TaggedNostrEvent,
u256,
UserMetadata,
@ -57,6 +58,10 @@ export class EventPublisher {
return new EventPublisher(signer, signer.getPubKey());
}
supports(t: SignerSupports) {
return this.#signer.supports.includes(t);
}
get pubKey() {
return this.#pubKey;
}

View File

@ -63,6 +63,10 @@ export class Nip46Signer implements EventSigner {
this.#insideSigner = insideSigner ?? new PrivateKeySigner(secp256k1.utils.randomPrivateKey());
}
get supports(): string[] {
return ["nip04"]
}
get relays() {
return [this.#relay];
}

View File

@ -21,6 +21,10 @@ declare global {
}
export class Nip7Signer implements EventSigner {
get supports(): string[] {
return ["nip04"];
}
init(): Promise<void> {
return Promise.resolve();
}

View File

@ -1,8 +1,8 @@
import { AuthHandler, RelaySettings, ConnectionStateSnapshot } from "./connection";
import { RequestBuilder } from "./request-builder";
import { NoteStore } from "./note-collection";
import { NoteStore, NoteStoreHook, NoteStoreSnapshotData } from "./note-collection";
import { Query } from "./query";
import { NostrEvent, ReqFilter } from "./nostr";
import { NostrEvent, ReqFilter, TaggedNostrEvent } from "./nostr";
import { ProfileLoaderService } from "./profile-cache";
export * from "./nostr-system";
@ -40,13 +40,61 @@ export interface SystemInterface {
* Handler function for NIP-42
*/
HandleAuth?: AuthHandler;
/**
* Get a snapshot of the relay connections
*/
get Sockets(): Array<ConnectionStateSnapshot>;
/**
* Get an active query by ID
* @param id Query ID
*/
GetQuery(id: string): Query | undefined;
Query<T extends NoteStore>(type: { new (): T }, req: RequestBuilder | null): Query;
/**
* Open a new query to relays
* @param type Store type
* @param req Request to send to relays
*/
Query<T extends NoteStore>(type: { new (): T }, req: RequestBuilder): Query;
/**
* Fetch data from nostr relays asynchronously
* @param req Request to send to relays
* @param cb A callback which will fire every 100ms when new data is received
*/
Fetch(req: RequestBuilder, cb?: (evs: Array<TaggedNostrEvent>) => void) : Promise<NoteStoreSnapshotData>;
/**
* Create a new permanent connection to a relay
* @param address Relay URL
* @param options Read/Write settings
*/
ConnectToRelay(address: string, options: RelaySettings): Promise<void>;
/**
* Disconnect permanent relay connection
* @param address Relay URL
*/
DisconnectRelay(address: string): void;
/**
* Send an event to all permanent connections
* @param ev Event to broadcast
*/
BroadcastEvent(ev: NostrEvent): void;
/**
* Connect to a specific relay and send an event and wait for the response
* @param relay Relay URL
* @param ev Event to send
*/
WriteOnceToRelay(relay: string, ev: NostrEvent): Promise<void>;
/**
* Profile cache/loader
*/
get ProfileLoader(): ProfileLoaderService;
}

View File

@ -1,5 +1,6 @@
import { bech32ToHex, hexToBech32 } from "@snort/shared";
import { NostrPrefix, decodeTLV, TLVEntryType, encodeTLV } from ".";
import { bech32ToHex, hexToBech32, unwrap } from "@snort/shared";
import { NostrPrefix, decodeTLV, TLVEntryType, encodeTLV, NostrEvent, TaggedNostrEvent } from ".";
import { findTag } from "./utils";
export interface NostrLink {
type: NostrPrefix;
@ -10,6 +11,29 @@ export interface NostrLink {
encode(): string;
}
export function createNostrLinkToEvent(ev: TaggedNostrEvent | NostrEvent) {
const relays = "relays" in ev ? ev.relays : undefined;
if (ev.kind >= 30_000 && ev.kind < 40_000) {
const dTag = unwrap(findTag(ev, "d"));
return createNostrLink(NostrPrefix.Address, dTag, relays, ev.kind, ev.pubkey);
}
return createNostrLink(NostrPrefix.Event, ev.id, relays, ev.kind, ev.pubkey);
}
export function linkMatch(link: NostrLink, ev: NostrEvent) {
if(link.type === NostrPrefix.Address) {
const dTag = findTag(ev, "d");
if(dTag && dTag === link.id && unwrap(link.author) === ev.pubkey && unwrap(link.kind) === ev.kind) {
return true;
}
} else if(link.type === NostrPrefix.Event || link.type === NostrPrefix.Note) {
return link.id === ev.id;
}
return false;
}
export function createNostrLink(prefix: NostrPrefix, id: string, relays?: string[], kind?: number, author?: string) {
return {
type: prefix,

View File

@ -4,7 +4,7 @@ import { unwrap, sanitizeRelayUrl, ExternalStore, FeedCache } from "@snort/share
import { NostrEvent, TaggedNostrEvent } from "./nostr";
import { AuthHandler, Connection, RelaySettings, ConnectionStateSnapshot } from "./connection";
import { Query } from "./query";
import { NoteStore } from "./note-collection";
import { NoteCollection, NoteStore, NoteStoreHook, NoteStoreSnapshotData } from "./note-collection";
import { BuiltRawReqFilter, RequestBuilder } from "./request-builder";
import { RelayMetricHandler } from "./relay-metric-handler";
import {
@ -19,6 +19,7 @@ import {
db,
UsersRelays,
} from ".";
import { EventsCache } from "./cache/events";
/**
* Manages nostr content retrieval system
@ -66,22 +67,30 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
*/
#relayMetrics: RelayMetricHandler;
/**
* General events cache
*/
#eventsCache: FeedCache<NostrEvent>;
constructor(props: {
authHandler?: AuthHandler;
relayCache?: FeedCache<UsersRelays>;
profileCache?: FeedCache<MetadataCache>;
relayMetrics?: FeedCache<RelayMetrics>;
eventsCache?: FeedCache<NostrEvent>;
}) {
super();
this.#handleAuth = props.authHandler;
this.#relayCache = props.relayCache ?? new UserRelaysCache();
this.#profileCache = props.profileCache ?? new UserProfileCache();
this.#relayMetricsCache = props.relayMetrics ?? new RelayMetricCache();
this.#eventsCache = props.eventsCache ?? new EventsCache();
this.#profileLoader = new ProfileLoaderService(this, this.#profileCache);
this.#relayMetrics = new RelayMetricHandler(this.#relayMetricsCache);
this.#cleanup();
}
HandleAuth?: AuthHandler | undefined;
/**
* Profile loader service allows you to request profiles
@ -99,7 +108,12 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
*/
async Init() {
db.ready = await db.isAvailable();
const t = [this.#relayCache.preload(), this.#profileCache.preload(), this.#relayMetricsCache.preload()];
const t = [
this.#relayCache.preload(),
this.#profileCache.preload(),
this.#relayMetricsCache.preload(),
this.#eventsCache.preload()
];
await Promise.all(t);
}
@ -190,6 +204,33 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
return this.Queries.get(id);
}
Fetch(req: RequestBuilder, cb?: (evs: Array<TaggedNostrEvent>) => void) {
const q = this.Query(NoteCollection, req);
return new Promise<NoteStoreSnapshotData>((resolve) => {
let t: ReturnType<typeof setTimeout> | undefined;
let tBuf: Array<TaggedNostrEvent> = [];
const releaseOnEvent = cb ? q.feed.onEvent(evs => {
if(!t) {
tBuf = [...evs];
t = setTimeout(() => {
t = undefined;
cb(tBuf);
}, 100);
} else {
tBuf.push(...evs);
}
}) : undefined;
const releaseFeedHook = q.feed.hook(() => {
if(q.progress === 1) {
releaseOnEvent?.();
releaseFeedHook();
q.cancel();
resolve(unwrap(q.feed.snapshot.data));
}
})
})
}
Query<T extends NoteStore>(type: { new (): T }, req: RequestBuilder): Query {
const existing = this.Queries.get(req.id);
if (existing) {
@ -214,6 +255,11 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
const filters = req.build(this.#relayCache);
const q = new Query(req.id, req.instance, store, req.options?.leaveOpen);
if(filters.some(a => a.filters.some(b=>b.ids))) {
q.feed.onEvent(async evs => {
await this.#eventsCache.bulkSet(evs);
});
}
this.Queries.set(req.id, q);
for (const subQ of filters) {
this.SendQuery(q, subQ);
@ -224,6 +270,24 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
}
async SendQuery(q: Query, qSend: BuiltRawReqFilter) {
// trim query of cached ids
for(const f of qSend.filters) {
if (f.ids) {
const cacheResults = await this.#eventsCache.bulkGet(f.ids);
if(cacheResults.length > 0) {
const resultIds = new Set(cacheResults.map(a => a.id));
f.ids = f.ids.filter(a => !resultIds.has(a));
q.feed.add(cacheResults as Array<TaggedNostrEvent>);
}
}
}
// check for empty filters
qSend.filters = qSend.filters.filter(a => Object.values(a).filter(v => Array.isArray(v)).every(b => (b as Array<string | number>).length > 0));
if(qSend.filters.length === 0) {
return;
}
if (qSend.relay) {
this.#log("Sending query to %s %O", qSend.relay, qSend);
const s = this.#sockets.get(qSend.relay);

View File

@ -20,7 +20,7 @@ export const EmptySnapshot = {
},
} as StoreSnapshot<FlatNoteStore>;
export type NoteStoreSnapshotData = Readonly<Array<TaggedNostrEvent>> | Readonly<TaggedNostrEvent>;
export type NoteStoreSnapshotData = Array<TaggedNostrEvent> | TaggedNostrEvent;
export type NoteStoreHook = () => void;
export type NoteStoreHookRelease = () => void;
export type OnEventCallback = (e: Readonly<Array<TaggedNostrEvent>>) => void;
@ -134,10 +134,28 @@ export abstract class HookedNoteStore<TSnapshot extends NoteStoreSnapshotData> i
}
}
/**
* A store which doesnt store anything, useful for hooks only
*/
export class NoopStore extends HookedNoteStore<Array<TaggedNostrEvent>> {
override add(ev: readonly TaggedNostrEvent[] | Readonly<TaggedNostrEvent>): void {
this.onChange(Array.isArray(ev) ? ev : [ev]);
}
override clear(): void {
// nothing to do
}
protected override takeSnapshot(): TaggedNostrEvent[] | undefined {
// nothing to do
return undefined;
}
}
/**
* A simple flat container of events with no duplicates
*/
export class FlatNoteStore extends HookedNoteStore<Readonly<Array<TaggedNostrEvent>>> {
export class FlatNoteStore extends HookedNoteStore<Array<TaggedNostrEvent>> {
#events: Array<TaggedNostrEvent> = [];
#ids: Set<u256> = new Set();
@ -176,7 +194,7 @@ export class FlatNoteStore extends HookedNoteStore<Readonly<Array<TaggedNostrEve
/**
* A note store that holds a single replaceable event for a given user defined key generator function
*/
export class KeyedReplaceableNoteStore extends HookedNoteStore<Readonly<Array<TaggedNostrEvent>>> {
export class KeyedReplaceableNoteStore extends HookedNoteStore<Array<TaggedNostrEvent>> {
#keyFn: (ev: TaggedNostrEvent) => string;
#events: Map<string, TaggedNostrEvent> = new Map();

View File

@ -7,6 +7,8 @@ import { MessageEncryptorPayload, MessageEncryptorVersion } from "./index";
import { NostrEvent } from "./nostr";
import { base64 } from "@scure/base";
export type SignerSupports = "nip04" | "nip44" | string;
export interface EventSigner {
init(): Promise<void>;
getPubKey(): Promise<string> | string;
@ -15,6 +17,7 @@ export interface EventSigner {
nip44Encrypt(content: string, key: string): Promise<string>;
nip44Decrypt(content: string, otherKey: string): Promise<string>;
sign(ev: NostrEvent): Promise<NostrEvent>;
get supports(): Array<SignerSupports>;
}
export class PrivateKeySigner implements EventSigner {
@ -30,6 +33,10 @@ export class PrivateKeySigner implements EventSigner {
this.#publicKey = getPublicKey(this.#privateKey);
}
get supports(): string[] {
return ["nip04", "nip44"]
}
get privateKey() {
return this.#privateKey;
}

View File

@ -20,6 +20,10 @@ export class SystemWorker extends ExternalStore<SystemSnapshot> implements Syste
throw new Error("SharedWorker is not supported");
}
}
Fetch(req: RequestBuilder): Promise<Query> {
throw new Error("Method not implemented.");
}
get ProfileLoader(): ProfileLoaderService {
throw new Error("Method not implemented.");