Cache all the things
This commit is contained in:
parent
5521f685fc
commit
b1459d0f49
124
packages/app/src/Cache/FollowsFeed.ts
Normal file
124
packages/app/src/Cache/FollowsFeed.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
@ -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 => {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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);
|
||||
|
@ -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)} />
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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";
|
||||
|
@ -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])}>
|
||||
|
@ -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 && (
|
||||
|
@ -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";
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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()}>
|
||||
|
118
packages/app/src/Element/TimelineFollows.tsx
Normal file
118
packages/app/src/Element/TimelineFollows.tsx
Normal 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;
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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";
|
||||
|
52
packages/app/src/Hooks/useRefreshFeedcache.tsx
Normal file
52
packages/app/src/Hooks/useRefreshFeedcache.tsx
Normal 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]);
|
||||
}
|
@ -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) {
|
||||
|
@ -13,6 +13,10 @@ export class Nip7OsSigner implements EventSigner {
|
||||
}
|
||||
}
|
||||
|
||||
get supports(): string[] {
|
||||
return ["nip04"];
|
||||
}
|
||||
|
||||
init(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
@ -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))}>
|
||||
|
@ -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 */
|
||||
}
|
@ -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()}`)}
|
||||
/>
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
);
|
||||
|
@ -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 = () => {
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { unixNow } from "SnortUtils";
|
||||
import { unixNow } from "@snort/shared";
|
||||
|
||||
export enum SubscriptionType {
|
||||
Supporter = 0,
|
||||
|
@ -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)));
|
||||
}
|
||||
|
@ -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));
|
||||
|
@ -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;
|
||||
},
|
||||
|
@ -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
23
packages/system/src/cache/events.ts
vendored
Normal 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()];
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
||||
|
@ -21,6 +21,10 @@ declare global {
|
||||
}
|
||||
|
||||
export class Nip7Signer implements EventSigner {
|
||||
get supports(): string[] {
|
||||
return ["nip04"];
|
||||
}
|
||||
|
||||
init(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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.");
|
||||
|
Loading…
x
Reference in New Issue
Block a user