diff --git a/packages/app/src/Cache/FollowsFeed.ts b/packages/app/src/Cache/FollowsFeed.ts new file mode 100644 index 00000000..0abee1c0 --- /dev/null +++ b/packages/app/src/Cache/FollowsFeed.ts @@ -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 { + #kinds = [EventKind.TextNote, EventKind.Repost, EventKind.Polls]; + #oldest: number = 0; + + constructor() { + super("FollowsFeedCache", db.followsFeed); + } + + key(of: TWithCreated): string { + return of.id; + } + + takeSnapshot(): TWithCreated[] { + 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 { + 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(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) { + 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) { + 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(), + ); + } +} diff --git a/packages/app/src/Cache/GiftWrapCache.ts b/packages/app/src/Cache/GiftWrapCache.ts index 184ad3ad..3cf752f8 100644 --- a/packages/app/src/Cache/GiftWrapCache.ts +++ b/packages/app/src/Cache/GiftWrapCache.ts @@ -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 { +export class GiftWrapCache extends RefreshFeedCache { constructor() { super("GiftWrapCache", db.gifts); } @@ -12,22 +13,20 @@ export class GiftWrapCache extends FeedCache { return of.id; } - override async preload(): Promise { - 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 { return [...this.cache.values()]; } - async onEvent(evs: Array, pub: EventPublisher) { + override async onEvent(evs: Array, pub: EventPublisher) { const unwrapped = ( await Promise.all( evs.map(async v => { diff --git a/packages/app/src/Cache/Notifications.ts b/packages/app/src/Cache/Notifications.ts index fc94bd32..833ea901 100644 --- a/packages/app/src/Cache/Notifications.ts +++ b/packages/app/src/Cache/Notifications.ts @@ -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 { #kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]; @@ -17,7 +18,7 @@ export class NotificationsCache extends RefreshFeedCache { 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); } } diff --git a/packages/app/src/Cache/RefreshFeedCache.ts b/packages/app/src/Cache/RefreshFeedCache.ts index debb299d..a062afcb 100644 --- a/packages/app/src/Cache/RefreshFeedCache.ts +++ b/packages/app/src/Cache/RefreshFeedCache.ts @@ -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 & { created_at: number }; +export type TWithCreated = (T | Readonly) & { created_at: number }; export abstract class RefreshFeedCache extends FeedCache> { abstract buildSub(session: LoginSession, rb: RequestBuilder): void; - abstract onEvent(evs: Readonly>): void; + abstract onEvent(evs: Readonly>, pub: EventPublisher): void; /** * Get latest event diff --git a/packages/app/src/Cache/index.ts b/packages/app/src/Cache/index.ts index 81921532..63c1a0f7 100644 --- a/packages/app/src/Cache/index.ts +++ b/packages/app/src/Cache/index.ts @@ -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) { const preloads = [ @@ -23,6 +25,7 @@ export async function preload(follows?: Array) { RelayMetrics.preload(), GiftsCache.preload(), Notifications.preload(), + FollowsFeed.preload(), ]; await Promise.all(preloads); } diff --git a/packages/app/src/Const.ts b/packages/app/src/Const.ts index a412edcc..2eefd906 100644 --- a/packages/app/src/Const.ts +++ b/packages/app/src/Const.ts @@ -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 */ diff --git a/packages/app/src/Db/index.ts b/packages/app/src/Db/index.ts index 963382ff..67b8fece 100644 --- a/packages/app/src/Db/index.ts +++ b/packages/app/src/Db/index.ts @@ -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; gifts!: Table; notifications!: Table; + followsFeed!: Table; constructor() { super(NAME); diff --git a/packages/app/src/Element/DM.tsx b/packages/app/src/Element/DM.tsx index 3a16b32e..0c1b5ea0 100644 --- a/packages/app/src/Element/DM.tsx +++ b/packages/app/src/Element/DM.tsx @@ -56,7 +56,7 @@ export default function DM(props: DMProps) {
{sender()} - +
diff --git a/packages/app/src/Element/FollowButton.tsx b/packages/app/src/Element/FollowButton.tsx index 3319c5fa..64a1dc38 100644 --- a/packages/app/src/Element/FollowButton.tsx +++ b/packages/app/src/Element/FollowButton.tsx @@ -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); } } diff --git a/packages/app/src/Element/FollowListBase.tsx b/packages/app/src/Element/FollowListBase.tsx index c10eaa5a..31e84f34 100644 --- a/packages/app/src/Element/FollowListBase.tsx +++ b/packages/app/src/Element/FollowListBase.tsx @@ -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); } } diff --git a/packages/app/src/Element/NostrFileHeader.tsx b/packages/app/src/Element/NostrFileHeader.tsx index b4efdc0d..39f7d636 100644 --- a/packages/app/src/Element/NostrFileHeader.tsx +++ b/packages/app/src/Element/NostrFileHeader.tsx @@ -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"; diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index b2606bdc..4cd4b876 100644 --- a/packages/app/src/Element/Note.tsx +++ b/packages/app/src/Element/Note.tsx @@ -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 ; + } if (ev.kind === EventKind.FileHeader) { return ; } @@ -95,10 +99,19 @@ export default function Note(props: NoteProps) { if (ev.kind === EventKind.LiveEvent) { return ; } + if (ev.kind === EventKind.SetMetadata) { + return } pubkey={ev.pubkey} className="card" />; + } if (ev.kind === (9041 as EventKind)) { return ; } + return +} + +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) { )} }> - + ); } return ( - +
); } else { @@ -379,7 +393,7 @@ export default function Note(props: NoteProps) { {options.showContextMenu && ( {}} + react={async () => { }} onTranslated={t => setTranslated(t)} setShowReactions={setShowReactions} /> diff --git a/packages/app/src/Element/NoteQuote.tsx b/packages/app/src/Element/NoteQuote.tsx index 262b1ca6..d7e33380 100644 --- a/packages/app/src/Element/NoteQuote.tsx +++ b/packages/app/src/Element/NoteQuote.tsx @@ -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"; diff --git a/packages/app/src/Element/NoteReaction.tsx b/packages/app/src/Element/NoteReaction.tsx index ba49392f..66f19abb 100644 --- a/packages/app/src/Element/NoteReaction.tsx +++ b/packages/app/src/Element/NoteReaction.tsx @@ -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) { }} />
- {root ? : null} + {root ? : null} {!root && refEvent ? (

diff --git a/packages/app/src/Element/Poll.tsx b/packages/app/src/Element/Poll.tsx index 128077a0..07b14e08 100644 --- a/packages/app/src/Element/Poll.tsx +++ b/packages/app/src/Element/Poll.tsx @@ -116,7 +116,7 @@ export default function Poll(props: PollProps) { {opt === voting ? ( ) : ( - + )} {showResults && ( diff --git a/packages/app/src/Element/Relay.tsx b/packages/app/src/Element/Relay.tsx index ab3d3de9..1fca237a 100644 --- a/packages/app/src/Element/Relay.tsx +++ b/packages/app/src/Element/Relay.tsx @@ -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"; diff --git a/packages/app/src/Element/Text.css b/packages/app/src/Element/Text.css index 09c498fe..9201a171 100644 --- a/packages/app/src/Element/Text.css +++ b/packages/app/src/Element/Text.css @@ -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 { diff --git a/packages/app/src/Element/Text.tsx b/packages/app/src/Element/Text.tsx index 013cff67..7cc6c88b 100644 --- a/packages/app/src/Element/Text.tsx +++ b/packages/app/src/Element/Text.tsx @@ -11,70 +11,109 @@ import { ProxyImg } from "./ProxyImg"; import { SpotlightMedia } from "./SpotlightMedia"; export interface TextProps { + id: string; content: string; creator: HexKey; tags: Array>; 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>(); + +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 ( - e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> - {a.content} - - ); + 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

{a.content.slice(0, truncate - lenCtr)}...
+ } else { + lenCtr += a.content.length; + } } - return ( - { - 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 ; - case "hashtag": - return ; - case "cashu": - return ; - case "media": - case "link": - return ; - case "custom_emoji": - return ; - default: - return
{a.content}
; + + if (a.type === "media" && !a.mimeType?.startsWith("unknown")) { + if (disableMedia ?? false) { + return ( + e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> + {a.content} + + ); + } + return ( + { + 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 ; + case "hashtag": + return ; + case "cashu": + return ; + case "media": + case "link": + return ; + case "custom_emoji": + return ; + default: + return
{a.content}
; + } } } + + return elements.map(a => renderChunk(a)); } return ( -
- {elements.map(a => renderChunk(a))} +
+ {renderContent()} {showSpotlight && setShowSpotlight(false)} idx={imageIdx} />}
); diff --git a/packages/app/src/Element/Timeline.tsx b/packages/app/src/Element/Timeline.tsx index 541a9d9e..cb290d24 100644 --- a/packages/app/src/Element/Timeline.tsx +++ b/packages/app/src/Element/Timeline.tsx @@ -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 } pubkey={e.pubkey} className="card" />; - } - case EventKind.Polls: - case EventKind.TextNote: { - const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1); - if (eRef) { - return ; - } - return ( - - ); - } - case EventKind.ZapReceipt: { - const zap = parseZap(e, UserCache); - return zap.event ? null : ; - } - case EventKind.Reaction: - case EventKind.Repost: { - const eRef = findTag(e, "e"); - return ; - } - } - } - function onShowLatest(scrollToTop = false) { feed.showLatest(); if (scrollToTop) { @@ -144,7 +106,7 @@ const Timeline = (props: TimelineProps) => { )} )} - {mainFeed.map(eventElement)} + {mainFeed.map(e => )} {(props.loadMore === undefined || props.loadMore === true) && (
{(content?.length ?? 0) > 0 && sender && (
- +
)}
diff --git a/packages/app/src/Feed/EventFeed.ts b/packages/app/src/Feed/EventFeed.ts index 23532025..617e84c4 100644 --- a/packages/app/src/Feed/EventFeed.ts +++ b/packages/app/src/Feed/EventFeed.ts @@ -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) { + 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); +} diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index 0fa698d1..7d528397 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -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) diff --git a/packages/app/src/Feed/TimelineFeed.ts b/packages/app/src/Feed/TimelineFeed.ts index 4022e1b1..ea45ede4 100644 --- a/packages/app/src/Feed/TimelineFeed.ts +++ b/packages/app/src/Feed/TimelineFeed.ts @@ -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"; diff --git a/packages/app/src/Hooks/useRefreshFeedcache.tsx b/packages/app/src/Hooks/useRefreshFeedcache.tsx new file mode 100644 index 00000000..2b67d17a --- /dev/null +++ b/packages/app/src/Hooks/useRefreshFeedcache.tsx @@ -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(c: RefreshFeedCache, 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 | undefined; + let tBuf: Array = []; + 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]); +} \ No newline at end of file diff --git a/packages/app/src/Login/Functions.ts b/packages/app/src/Login/Functions.ts index 16a924c6..399d4fe4 100644 --- a/packages/app/src/Login/Functions.ts +++ b/packages/app/src/Login/Functions.ts @@ -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, 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) { diff --git a/packages/app/src/Login/Nip7OsSigner.ts b/packages/app/src/Login/Nip7OsSigner.ts index 123eede1..e44f0cdb 100644 --- a/packages/app/src/Login/Nip7OsSigner.ts +++ b/packages/app/src/Login/Nip7OsSigner.ts @@ -13,6 +13,10 @@ export class Nip7OsSigner implements EventSigner { } } + get supports(): string[] { + return ["nip04"]; + } + init(): Promise { return Promise.resolve(); } diff --git a/packages/app/src/Pages/MessagesPage.tsx b/packages/app/src/Pages/MessagesPage.tsx index 6646d7ec..a6daa1b0 100644 --- a/packages/app/src/Pages/MessagesPage.tsx +++ b/packages/app/src/Pages/MessagesPage.tsx @@ -148,7 +148,7 @@ function ProfileDmActions({ id }: { id: string }) {

{getDisplayName(profile, pubkey)}

- +

(blocked ? unblock(pubkey) : block(pubkey))}> diff --git a/packages/app/src/Pages/Notifications.css b/packages/app/src/Pages/Notifications.css index 168437fc..85ab4860 100644 --- a/packages/app/src/Pages/Notifications.css +++ b/packages/app/src/Pages/Notifications.css @@ -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 */ +} \ No newline at end of file diff --git a/packages/app/src/Pages/Notifications.tsx b/packages/app/src/Pages/Notifications.tsx index f510b7b5..bb8f53be 100644 --- a/packages/app/src/Pages/Notifications.tsx +++ b/packages/app/src/Pages/Notifications.tsx @@ -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 }) { )}
)} -
{context && }
+ {context && } )} @@ -228,15 +228,15 @@ function NotificationGroup({ evs }: { evs: Array }) { function NotificationContext({ link }: { link: NostrLink }) { const { data: ev } = useEventFeed(link); const navigate = useNavigate(); - const content = ev?.content ?? ""; - return ( -
navigate(`/${link.encode()}`)} className="pointer"> - 120 ? `${content.substring(0, 120)}...` : content} - tags={ev?.tags ?? []} - creator={ev?.pubkey ?? ""} - /> -
- ); + return ev && navigate(`/${link.encode()}`)} + /> } diff --git a/packages/app/src/Pages/ProfilePage.tsx b/packages/app/src/Pages/ProfilePage.tsx index ca06a568..8e89eace 100644 --- a/packages/app/src/Pages/ProfilePage.tsx +++ b/packages/app/src/Pages/ProfilePage.tsx @@ -118,13 +118,6 @@ export default function ProfilePage() { const [showLnQr, setShowLnQr] = useState(false); const [showProfileQr, setShowProfileQr] = useState(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 && (
- {about} +
) ); diff --git a/packages/app/src/Pages/Root.tsx b/packages/app/src/Pages/Root.tsx index aee22f9e..8f03f52d 100644 --- a/packages/app/src/Pages/Root.tsx +++ b/packages/app/src/Pages/Root.tsx @@ -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 ( <> - + ); }; const ConversationsTab = () => { - const { follows, publicKey } = useLogin(); - const subject: TimelineSubject = { - type: "pubkey", - items: follows.item, - discriminator: `follows:${publicKey?.slice(0, 12)}`, - }; - - return ; + return ; }; const TagsTab = () => { diff --git a/packages/app/src/Pages/settings/Relays.tsx b/packages/app/src/Pages/settings/Relays.tsx index 9470ed22..2942d75b 100644 --- a/packages/app/src/Pages/settings/Relays.tsx +++ b/packages/app/src/Pages/settings/Relays.tsx @@ -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(); diff --git a/packages/app/src/SnortUtils/index.ts b/packages/app/src/SnortUtils/index.ts index 33ca7af6..242b516f 100644 --- a/packages/app/src/SnortUtils/index.ts +++ b/packages/app/src/SnortUtils/index.ts @@ -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(obj: T) { if ("structuredClone" in window) { return structuredClone(obj); diff --git a/packages/app/src/Subscription/index.ts b/packages/app/src/Subscription/index.ts index 6486f2b6..e3cf86a9 100644 --- a/packages/app/src/Subscription/index.ts +++ b/packages/app/src/Subscription/index.ts @@ -1,4 +1,4 @@ -import { unixNow } from "SnortUtils"; +import { unixNow } from "@snort/shared"; export enum SubscriptionType { Supporter = 0, diff --git a/packages/app/src/chat/index.ts b/packages/app/src/chat/index.ts index 246c023b..f29f9122 100644 --- a/packages/app/src/chat/index.ts +++ b/packages/app/src/chat/index.ts @@ -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))); } diff --git a/packages/app/src/chat/nip4.ts b/packages/app/src/chat/nip4.ts index 52f9a5ab..c766ba5a 100644 --- a/packages/app/src/chat/nip4.ts +++ b/packages/app/src/chat/nip4.ts @@ -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> 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>); - 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) { 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> 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)); diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index bfd248c8..fd467a2e 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -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: , errorElement: , 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; }, diff --git a/packages/shared/src/feed-cache.ts b/packages/shared/src/feed-cache.ts index c4be82e7..8829a37a 100644 --- a/packages/shared/src/feed-cache.ts +++ b/packages/shared/src/feed-cache.ts @@ -15,7 +15,7 @@ export interface KeyedHookFilter { export abstract class FeedCache { #name: string; #hooks: Array = []; - #snapshot: Readonly> = []; + #snapshot: Array = []; #changed = true; #hits = 0; #miss = 0; @@ -37,6 +37,10 @@ export abstract class FeedCache { }, 30_000); } + get name() { + return this.#name; + } + async preload() { const keys = (await this.table?.toCollection().primaryKeys()) ?? []; this.onTable = new Set(keys.map(a => a as string)); @@ -111,7 +115,7 @@ export abstract class FeedCache { this.notifyChange([k]); } - async bulkSet(obj: Array) { + async bulkSet(obj: Array | Readonly>) { if (this.table) { await this.table.bulkPut(obj); obj.forEach(a => this.onTable.add(this.key(a))); diff --git a/packages/system/src/cache/events.ts b/packages/system/src/cache/events.ts new file mode 100644 index 00000000..59457cbe --- /dev/null +++ b/packages/system/src/cache/events.ts @@ -0,0 +1,23 @@ +import { NostrEvent } from "nostr"; +import { db } from "."; +import { FeedCache } from "@snort/shared"; + +export class EventsCache extends FeedCache { + constructor() { + super("EventsCache", db.events); + } + + key(of: NostrEvent): string { + return of.id; + } + + override async preload(): Promise { + await super.preload(); + // load everything + await this.buffer([...this.onTable]); + } + + takeSnapshot(): Array { + return [...this.cache.values()]; + } +} diff --git a/packages/system/src/event-publisher.ts b/packages/system/src/event-publisher.ts index 6c3dd0ac..ab62e21a 100644 --- a/packages/system/src/event-publisher.ts +++ b/packages/system/src/event-publisher.ts @@ -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; } diff --git a/packages/system/src/impl/nip46.ts b/packages/system/src/impl/nip46.ts index df531089..3c748124 100644 --- a/packages/system/src/impl/nip46.ts +++ b/packages/system/src/impl/nip46.ts @@ -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]; } diff --git a/packages/system/src/impl/nip7.ts b/packages/system/src/impl/nip7.ts index c472edbf..db9697c0 100644 --- a/packages/system/src/impl/nip7.ts +++ b/packages/system/src/impl/nip7.ts @@ -21,6 +21,10 @@ declare global { } export class Nip7Signer implements EventSigner { + get supports(): string[] { + return ["nip04"]; + } + init(): Promise { return Promise.resolve(); } diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index 3064fb4c..059f7208 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -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; + + /** + * Get an active query by ID + * @param id Query ID + */ GetQuery(id: string): Query | undefined; - Query(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(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) => void) : Promise; + + /** + * Create a new permanent connection to a relay + * @param address Relay URL + * @param options Read/Write settings + */ ConnectToRelay(address: string, options: RelaySettings): Promise; + + /** + * 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; + + /** + * Profile cache/loader + */ get ProfileLoader(): ProfileLoaderService; } diff --git a/packages/system/src/nostr-link.ts b/packages/system/src/nostr-link.ts index 54e9ec22..72f69a00 100644 --- a/packages/system/src/nostr-link.ts +++ b/packages/system/src/nostr-link.ts @@ -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, diff --git a/packages/system/src/nostr-system.ts b/packages/system/src/nostr-system.ts index 7145e9e0..2d33b5f9 100644 --- a/packages/system/src/nostr-system.ts +++ b/packages/system/src/nostr-system.ts @@ -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 implements System */ #relayMetrics: RelayMetricHandler; + /** + * General events cache + */ + #eventsCache: FeedCache; + constructor(props: { authHandler?: AuthHandler; relayCache?: FeedCache; profileCache?: FeedCache; relayMetrics?: FeedCache; + eventsCache?: FeedCache; }) { 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 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 implements System return this.Queries.get(id); } + Fetch(req: RequestBuilder, cb?: (evs: Array) => void) { + const q = this.Query(NoteCollection, req); + return new Promise((resolve) => { + let t: ReturnType | undefined; + let tBuf: Array = []; + 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(type: { new (): T }, req: RequestBuilder): Query { const existing = this.Queries.get(req.id); if (existing) { @@ -214,6 +255,11 @@ export class NostrSystem extends ExternalStore 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 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); + } + } + } + + // check for empty filters + qSend.filters = qSend.filters.filter(a => Object.values(a).filter(v => Array.isArray(v)).every(b => (b as Array).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); diff --git a/packages/system/src/note-collection.ts b/packages/system/src/note-collection.ts index 97448dbd..54009ddf 100644 --- a/packages/system/src/note-collection.ts +++ b/packages/system/src/note-collection.ts @@ -20,7 +20,7 @@ export const EmptySnapshot = { }, } as StoreSnapshot; -export type NoteStoreSnapshotData = Readonly> | Readonly; +export type NoteStoreSnapshotData = Array | TaggedNostrEvent; export type NoteStoreHook = () => void; export type NoteStoreHookRelease = () => void; export type OnEventCallback = (e: Readonly>) => void; @@ -134,10 +134,28 @@ export abstract class HookedNoteStore i } } +/** + * A store which doesnt store anything, useful for hooks only + */ +export class NoopStore extends HookedNoteStore> { + override add(ev: readonly TaggedNostrEvent[] | Readonly): 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>> { +export class FlatNoteStore extends HookedNoteStore> { #events: Array = []; #ids: Set = new Set(); @@ -176,7 +194,7 @@ export class FlatNoteStore extends HookedNoteStore>> { +export class KeyedReplaceableNoteStore extends HookedNoteStore> { #keyFn: (ev: TaggedNostrEvent) => string; #events: Map = new Map(); diff --git a/packages/system/src/signer.ts b/packages/system/src/signer.ts index d2d25c81..7a67e9cd 100644 --- a/packages/system/src/signer.ts +++ b/packages/system/src/signer.ts @@ -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; getPubKey(): Promise | string; @@ -15,6 +17,7 @@ export interface EventSigner { nip44Encrypt(content: string, key: string): Promise; nip44Decrypt(content: string, otherKey: string): Promise; sign(ev: NostrEvent): Promise; + get supports(): Array; } 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; } diff --git a/packages/system/src/system-worker.ts b/packages/system/src/system-worker.ts index 2697c0cb..967f3b70 100644 --- a/packages/system/src/system-worker.ts +++ b/packages/system/src/system-worker.ts @@ -20,6 +20,10 @@ export class SystemWorker extends ExternalStore implements Syste throw new Error("SharedWorker is not supported"); } } + + Fetch(req: RequestBuilder): Promise { + throw new Error("Method not implemented."); + } get ProfileLoader(): ProfileLoaderService { throw new Error("Method not implemented.");