refactor: RequestBuilder

This commit is contained in:
Kieran 2023-03-28 15:34:01 +01:00
parent 1bf6c7031e
commit 465c59ea20
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
77 changed files with 3141 additions and 2343 deletions

View File

@ -1,18 +0,0 @@
name: Linting
on:
pull_request:
jobs:
formatting:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Install Dependencies
run: yarn install
- name: Check Eslint
run: yarn workspace @snort/app eslint

View File

@ -1,8 +1,8 @@
name: Formatting
name: Test+Lint
on:
pull_request:
jobs:
formatting:
test_and_lint:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
@ -14,5 +14,11 @@ jobs:
node-version: 16
- name: Install Dependencies
run: yarn install
- name: Build packages
run: yarn workspace @snort/nostr build
- name: Run tests
run: yarn workspace @snort/app test
- name: Check Eslint
run: yarn workspace @snort/app eslint
- name: Check Formatting
run: yarn workspace @snort/app prettier --check .

View File

@ -20,6 +20,7 @@
"bech32": "^2.0.0",
"dexie": "^3.2.2",
"dexie-react-hooks": "^1.1.1",
"events": "^3.3.0",
"light-bolt11-decoder": "^2.1.0",
"qr-code-styling": "^1.6.0-rc.1",
"react": "^18.2.0",
@ -30,9 +31,9 @@
"react-query": "^3.39.2",
"react-redux": "^8.0.5",
"react-router-dom": "^6.5.0",
"react-scripts": "5.0.1",
"react-textarea-autosize": "^8.4.0",
"react-twitter-embed": "^4.0.4",
"throttle-debounce": "^5.0.0",
"unist-util-visit": "^4.1.2",
"use-long-press": "^2.0.3",
"uuid": "^9.0.0",
@ -94,6 +95,7 @@
"lint-staged": ">=10",
"prettier": "2.8.3",
"react-app-rewired": "^2.2.1",
"react-scripts": "5.0.1",
"typescript": "^4.9.4"
},
"lint-staged": {

View File

@ -1,9 +1,9 @@
import Dexie, { Table } from "dexie";
import { u256 } from "@snort/nostr";
import { FullRelaySettings, HexKey, u256 } from "@snort/nostr";
import { MetadataCache } from "State/Users";
export const NAME = "snortDB";
export const VERSION = 4;
export const VERSION = 6;
export interface SubCache {
id: string;
@ -12,13 +12,29 @@ export interface SubCache {
since?: number;
}
export interface RelayMetrics {
addr: string;
events: number;
disconnects: number;
latency: number[];
}
export interface UsersRelays {
pubkey: HexKey;
relays: FullRelaySettings[];
}
const STORES = {
users: "++pubkey, name, display_name, picture, nip05, npub",
relays: "++addr",
userRelays: "++pubkey",
};
export class SnortDB extends Dexie {
ready = false;
users!: Table<MetadataCache>;
relayMetrics!: Table<RelayMetrics>;
userRelays!: Table<UsersRelays>;
constructor() {
super(NAME);

View File

@ -1,10 +1,9 @@
import { useState, useMemo, ChangeEvent } from "react";
import { useSelector } from "react-redux";
import { FormattedMessage } from "react-intl";
import { dedupeByPubkey } from "Util";
import Note from "Element/Note";
import { HexKey, TaggedRawEvent } from "@snort/nostr";
import Note from "Element/Note";
import { RootState } from "State/Store";
import { UserCache } from "State/Users/UserCache";
@ -12,15 +11,15 @@ import messages from "./messages";
interface BookmarksProps {
pubkey: HexKey;
bookmarks: TaggedRawEvent[];
related: TaggedRawEvent[];
bookmarks: readonly TaggedRawEvent[];
related: readonly TaggedRawEvent[];
}
const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
const [onlyPubkey, setOnlyPubkey] = useState<HexKey | "all">("all");
const loginPubKey = useSelector((s: RootState) => s.login.publicKey);
const ps = useMemo(() => {
return dedupeByPubkey(bookmarks).map(ev => ev.pubkey);
return [...new Set(bookmarks.map(ev => ev.pubkey))];
}, [bookmarks]);
function renderOption(p: HexKey) {

View File

@ -3,14 +3,13 @@ import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useIntl } from "react-intl";
import { useInView } from "react-intersection-observer";
import { HexKey, TaggedRawEvent } from "@snort/nostr";
import useEventPublisher from "Feed/EventPublisher";
import { Event } from "@snort/nostr";
import NoteTime from "Element/NoteTime";
import Text from "Element/Text";
import { setLastReadDm } from "Pages/MessagesPage";
import { RootState } from "State/Store";
import { HexKey, TaggedRawEvent } from "@snort/nostr";
import { incDmInteraction } from "State/Login";
import { unwrap } from "Util";
@ -32,11 +31,10 @@ export default function DM(props: DMProps) {
const otherPubkey = isMe ? pubKey : unwrap(props.data.tags.find(a => a[0] === "p")?.[1]);
async function decrypt() {
const e = new Event(props.data);
const decrypted = await publisher.decryptDm(e);
const decrypted = await publisher.decryptDm(props.data);
setContent(decrypted || "<ERROR>");
if (!isMe) {
setLastReadDm(e.PubKey);
setLastReadDm(props.data.pubkey);
dispatch(incDmInteraction());
}
}

View File

@ -111,25 +111,12 @@ export default function HyperText({ link, creator }: { link: string; creator: He
</a>
);
}
} else if (tweetId && !pref.rewriteTwitterPosts) {
} else if (tweetId) {
return (
<div className="tweet" key={tweetId}>
<TwitterTweetEmbed tweetId={tweetId} />
</div>
);
} else if (pref.rewriteTwitterPosts && url.hostname == "twitter.com") {
url.host = "nitter.net";
return (
<a
key={url.toString()}
href={url.toString()}
onClick={e => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext">
{url.toString()}
</a>
);
} else if (youtubeId) {
return (
<iframe

View File

@ -4,6 +4,7 @@ import { useNavigate, Link } from "react-router-dom";
import { useSelector, useDispatch } from "react-redux";
import { useInView } from "react-intersection-observer";
import { useIntl, FormattedMessage } from "react-intl";
import { TaggedRawEvent, HexKey, EventKind, NostrPrefix } from "@snort/nostr";
import useEventPublisher from "Feed/EventPublisher";
import Icon from "Icons/Icon";
@ -19,22 +20,21 @@ import {
normalizeReaction,
Reaction,
profileLink,
unwrap,
} from "Util";
import NoteFooter, { Translation } from "Element/NoteFooter";
import NoteTime from "Element/NoteTime";
import { TaggedRawEvent, HexKey, Event as NEvent, EventKind, NostrPrefix } from "@snort/nostr";
import useModeration from "Hooks/useModeration";
import { setPinned, setBookmarked } from "State/Login";
import type { RootState } from "State/Store";
import { UserCache } from "State/Users/UserCache";
import messages from "./messages";
import { EventExt } from "System/EventExt";
export interface NoteProps {
data?: TaggedRawEvent;
data: TaggedRawEvent;
className?: string;
related: TaggedRawEvent[];
related: readonly TaggedRawEvent[];
highlight?: boolean;
ignoreModeration?: boolean;
options?: {
@ -47,7 +47,6 @@ export interface NoteProps {
canUnpin?: boolean;
canUnbookmark?: boolean;
};
["data-ev"]?: NEvent;
}
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
@ -71,12 +70,11 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => {
export default function Note(props: NoteProps) {
const navigate = useNavigate();
const dispatch = useDispatch();
const { data, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false } = props;
const { data: ev, related, highlight, options: opt, ignoreModeration = false } = props;
const [showReactions, setShowReactions] = useState(false);
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
const deletions = useMemo(() => getReactions(related, ev.id, EventKind.Deletion), [related]);
const { isMuted } = useModeration();
const isOpMuted = isMuted(ev.PubKey);
const isOpMuted = isMuted(ev?.pubkey);
const { ref, inView, entry } = useInView({ triggerOnce: true });
const [extendable, setExtendable] = useState<boolean>(false);
const [showMore, setShowMore] = useState<boolean>(false);
@ -85,7 +83,7 @@ export default function Note(props: NoteProps) {
const publisher = useEventPublisher();
const [translated, setTranslated] = useState<Translation>();
const { formatMessage } = useIntl();
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]);
const reactions = useMemo(() => getReactions(related, ev.id, EventKind.Reaction), [related, ev]);
const groupReactions = useMemo(() => {
const result = reactions?.reduce(
(acc, reaction) => {
@ -108,15 +106,15 @@ export default function Note(props: NoteProps) {
const reposts = useMemo(
() =>
dedupeByPubkey([
...getReactions(related, ev.Id, EventKind.TextNote).filter(e => e.tags.some(tagFilterOfTextRepost(e, ev.Id))),
...getReactions(related, ev.Id, EventKind.Repost),
...getReactions(related, ev.id, EventKind.TextNote).filter(e => e.tags.some(tagFilterOfTextRepost(e, ev.id))),
...getReactions(related, ev.id, EventKind.Repost),
]),
[related, ev]
);
const zaps = useMemo(() => {
const sortedZaps = getReactions(related, ev.Id, EventKind.ZapReceipt)
const sortedZaps = getReactions(related, ev.id, EventKind.ZapReceipt)
.map(parseZap)
.filter(z => z.valid && z.sender !== ev.PubKey);
.filter(z => z.valid && z.sender !== ev.pubkey);
sortedZaps.sort((a, b) => b.amount - a.amount);
return sortedZaps;
}, [related]);
@ -154,7 +152,7 @@ export default function Note(props: NoteProps) {
}
const transformBody = useCallback(() => {
const body = ev?.Content ?? "";
const body = ev?.content ?? "";
if (deletions?.length > 0) {
return (
<b className="error">
@ -162,7 +160,7 @@ export default function Note(props: NoteProps) {
</b>
);
}
return <Text content={body} tags={ev.Tags} creator={ev.PubKey} />;
return <Text content={body} tags={ev.tags} creator={ev.pubkey} />;
}, [ev]);
useLayoutEffect(() => {
@ -189,20 +187,23 @@ export default function Note(props: NoteProps) {
if (e.metaKey) {
window.open(link, "_blank");
} else {
navigate(link);
navigate(link, {
state: ev,
});
}
}
function replyTag() {
if (ev.Thread === null) {
return null;
const thread = EventExt.extractThread(ev);
if (thread === undefined) {
return undefined;
}
const maxMentions = 2;
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
const replyRelayHints = ev?.Thread?.ReplyTo?.Relay ?? ev.Thread.Root?.Relay;
const replyId = thread?.replyTo?.Event ?? thread?.root?.Event;
const replyRelayHints = thread?.replyTo?.Relay ?? thread.root?.Relay;
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
for (const pk of ev.Thread?.PubKeys ?? []) {
for (const pk of thread?.pubKeys ?? []) {
const u = UserCache.get(pk);
const npub = hexToBech32(NostrPrefix.PublicKey, pk);
const shortNpub = npub.substring(0, 12);
@ -243,13 +244,13 @@ export default function Note(props: NoteProps) {
);
}
if (ev.Kind !== EventKind.TextNote) {
if (ev.kind !== EventKind.TextNote) {
return (
<>
<h4>
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.Kind }} />
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.kind }} />
</h4>
<pre>{JSON.stringify(ev.ToObject(), undefined, " ")}</pre>
<pre>{JSON.stringify(ev, undefined, " ")}</pre>
</>
);
}
@ -274,30 +275,30 @@ export default function Note(props: NoteProps) {
}
function content() {
if (!inView) return null;
if (!inView) return undefined;
return (
<>
{options.showHeader && (
<div className="header flex">
<ProfileImage autoWidth={false} pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
<ProfileImage autoWidth={false} pubkey={ev.pubkey} subHeader={replyTag() ?? undefined} />
{(options.showTime || options.showBookmarked) && (
<div className="info">
{options.showBookmarked && (
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark(ev.Id)}>
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark(ev.id)}>
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
</div>
)}
{!options.showBookmarked && <NoteTime from={ev.CreatedAt * 1000} />}
{!options.showBookmarked && <NoteTime from={ev.created_at * 1000} />}
</div>
)}
{options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.Id)}>
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.id)}>
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
</div>
)}
</div>
)}
<div className="body" onClick={e => goToEvent(e, unwrap(ev.Original), true)}>
<div className="body" onClick={e => goToEvent(e, ev, true)}>
{transformBody()}
{translation()}
{options.showReactionsLink && (
@ -330,7 +331,7 @@ export default function Note(props: NoteProps) {
const note = (
<div
className={`${baseClassName}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`}
onClick={e => goToEvent(e, unwrap(ev.Original))}
onClick={e => goToEvent(e, ev)}
ref={ref}>
{content()}
</div>

View File

@ -1,6 +1,7 @@
import "./NoteCreator.css";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { TaggedRawEvent } from "@snort/nostr";
import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher";
@ -8,22 +9,21 @@ import { openFile } from "Util";
import Textarea from "Element/Textarea";
import Modal from "Element/Modal";
import ProfileImage from "Element/ProfileImage";
import { Event as NEvent } from "@snort/nostr";
import useFileUpload from "Upload";
import messages from "./messages";
interface NotePreviewProps {
note: NEvent;
note: TaggedRawEvent;
}
function NotePreview({ note }: NotePreviewProps) {
return (
<div className="note-preview">
<ProfileImage pubkey={note.PubKey} />
<ProfileImage pubkey={note.pubkey} />
<div className="note-preview-body">
{note.Content.slice(0, 136)}
{note.Content.length > 140 && "..."}
{note.content.slice(0, 136)}
{note.content.length > 140 && "..."}
</div>
</div>
);
@ -32,7 +32,7 @@ function NotePreview({ note }: NotePreviewProps) {
export interface NoteCreatorProps {
show: boolean;
setShow: (s: boolean) => void;
replyTo?: NEvent;
replyTo?: TaggedRawEvent;
onSend?: () => void;
autoFocus: boolean;
}

View File

@ -3,7 +3,7 @@ import { useSelector, useDispatch } from "react-redux";
import { useIntl, FormattedMessage } from "react-intl";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useLongPress } from "use-long-press";
import { Event as NEvent, TaggedRawEvent, HexKey, u256, encodeTLV, NostrPrefix } from "@snort/nostr";
import { TaggedRawEvent, HexKey, u256, encodeTLV, NostrPrefix } from "@snort/nostr";
import Icon from "Icons/Icon";
import Spinner from "Icons/Spinner";
@ -85,7 +85,7 @@ export interface NoteFooterProps {
negative: TaggedRawEvent[];
showReactions: boolean;
setShowReactions(b: boolean): void;
ev: NEvent;
ev: TaggedRawEvent;
onTranslated?: (content: Translation) => void;
}
@ -97,7 +97,7 @@ export default function NoteFooter(props: NoteFooterProps) {
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const { mute, block } = useModeration();
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const author = useUserProfile(ev.RootPubKey);
const author = useUserProfile(ev.pubkey);
const publisher = useEventPublisher();
const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false);
@ -105,13 +105,13 @@ export default function NoteFooter(props: NoteFooterProps) {
const walletState = useWallet();
const wallet = walletState.wallet;
const isMine = ev.RootPubKey === login;
const isMine = ev.pubkey === login;
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = ZapCache.has(ev.Id) || zaps.some(a => a.sender === login);
const didZap = ZapCache.has(ev.id) || zaps.some(a => a.sender === login);
const longPress = useLongPress(
e => {
e.stopPropagation();
@ -138,15 +138,15 @@ export default function NoteFooter(props: NoteFooterProps) {
}
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.Id.substring(0, 8) }))) {
const evDelete = await publisher.delete(ev.Id);
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) }))) {
const evDelete = await publisher.delete(ev.id);
publisher.broadcast(evDelete);
}
}
async function repost() {
if (!hasReposted()) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.Id }))) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev);
publisher.broadcast(evRepost);
}
@ -160,7 +160,7 @@ export default function NoteFooter(props: NoteFooterProps) {
if (wallet?.isReady() && lnurl) {
setZapping(true);
try {
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.PubKey, ev.Id);
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id);
fastZapDonate();
} catch (e) {
console.warn("Fast zap failed", e);
@ -202,14 +202,14 @@ export default function NoteFooter(props: NoteFooterProps) {
}
useEffect(() => {
if (prefs.autoZap && !ZapCache.has(ev.Id) && !isMine && !zapping) {
if (prefs.autoZap && !ZapCache.has(ev.id) && !isMine && !zapping) {
const lnurl = author?.lud16 || author?.lud06;
if (wallet?.isReady() && lnurl) {
setZapping(true);
queueMicrotask(async () => {
try {
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.PubKey, ev.Id);
ZapCache.add(ev.Id);
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id);
ZapCache.add(ev.id);
fastZapDonate();
} catch {
// ignored
@ -263,7 +263,7 @@ export default function NoteFooter(props: NoteFooterProps) {
}
async function share() {
const link = encodeTLV(ev.Id, NostrPrefix.Event, ev.Original?.relays);
const link = encodeTLV(ev.id, NostrPrefix.Event, ev.relays);
const url = `${window.location.protocol}//${window.location.host}/e/${link}`;
if ("share" in window.navigator) {
await window.navigator.share({
@ -279,7 +279,7 @@ export default function NoteFooter(props: NoteFooterProps) {
const res = await fetch(`${TranslateHost}/translate`, {
method: "POST",
body: JSON.stringify({
q: ev.Content,
q: ev.content,
source: "auto",
target: lang.split("-")[0],
}),
@ -299,7 +299,7 @@ export default function NoteFooter(props: NoteFooterProps) {
}
async function copyId() {
const link = encodeTLV(ev.Id, NostrPrefix.Event, ev.Original?.relays);
const link = encodeTLV(ev.id, NostrPrefix.Event, ev.relays);
await navigator.clipboard.writeText(link);
}
@ -318,7 +318,7 @@ export default function NoteFooter(props: NoteFooterProps) {
}
async function copyEvent() {
await navigator.clipboard.writeText(JSON.stringify(ev.Original, undefined, " "));
await navigator.clipboard.writeText(JSON.stringify(ev, undefined, " "));
}
function menuItems() {
@ -339,14 +339,14 @@ export default function NoteFooter(props: NoteFooterProps) {
<Icon name="share" />
<FormattedMessage {...messages.Share} />
</MenuItem>
{!pinned.includes(ev.Id) && (
<MenuItem onClick={() => pin(ev.Id)}>
{!pinned.includes(ev.id) && (
<MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!bookmarked.includes(ev.Id) && (
<MenuItem onClick={() => bookmark(ev.Id)}>
{!bookmarked.includes(ev.id) && (
<MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />
</MenuItem>
@ -355,7 +355,7 @@ export default function NoteFooter(props: NoteFooterProps) {
<Icon name="copy" />
<FormattedMessage {...messages.CopyID} />
</MenuItem>
<MenuItem onClick={() => mute(ev.PubKey)}>
<MenuItem onClick={() => mute(ev.pubkey)}>
<Icon name="mute" />
<FormattedMessage {...messages.Mute} />
</MenuItem>
@ -365,7 +365,7 @@ export default function NoteFooter(props: NoteFooterProps) {
<FormattedMessage {...messages.DislikeAction} />
</MenuItem>
)}
<MenuItem onClick={() => block(ev.PubKey)}>
<MenuItem onClick={() => block(ev.pubkey)}>
<Icon name="block" />
<FormattedMessage {...messages.Block} />
</MenuItem>
@ -423,7 +423,7 @@ export default function NoteFooter(props: NoteFooterProps) {
show={tip}
author={author?.pubkey}
target={author?.display_name || author?.name}
note={ev.Id}
note={ev.id}
/>
</div>
<div className="zaps-container">

View File

@ -1,28 +1,26 @@
import "./NoteReaction.css";
import { Link } from "react-router-dom";
import { useMemo } from "react";
import { EventKind, RawEvent, TaggedRawEvent, NostrPrefix } from "@snort/nostr";
import { EventKind, Event as NEvent, NostrPrefix } from "@snort/nostr";
import Note from "Element/Note";
import ProfileImage from "Element/ProfileImage";
import { eventLink, hexToBech32 } from "Util";
import NoteTime from "Element/NoteTime";
import { RawEvent, TaggedRawEvent } from "@snort/nostr";
import useModeration from "Hooks/useModeration";
import { EventExt } from "System/EventExt";
export interface NoteReactionProps {
data?: TaggedRawEvent;
["data-ev"]?: NEvent;
data: TaggedRawEvent;
root?: TaggedRawEvent;
}
export default function NoteReaction(props: NoteReactionProps) {
const { ["data-ev"]: dataEv, data } = props;
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]);
const { data: ev } = props;
const { isMuted } = useModeration();
const refEvent = useMemo(() => {
if (ev) {
const eTags = ev.Tags.filter(a => a.Key === "e");
const eTags = ev.tags.filter(a => a[0] === "e");
if (eTags.length > 0) {
return eTags[0];
}
@ -31,10 +29,10 @@ export default function NoteReaction(props: NoteReactionProps) {
}, [ev]);
if (
ev.Kind !== EventKind.Reaction &&
ev.Kind !== EventKind.Repost &&
(ev.Kind !== EventKind.TextNote ||
ev.Tags.every((a, i) => a.Event !== refEvent?.Event || a.Marker !== "mention" || ev.Content !== `#[${i}]`))
ev.kind !== EventKind.Reaction &&
ev.kind !== EventKind.Repost &&
(ev.kind !== EventKind.TextNote ||
ev.tags.every((a, i) => a[1] !== refEvent?.[1] || a[3] !== "mention" || ev.content !== `#[${i}]`))
) {
return null;
}
@ -43,9 +41,9 @@ export default function NoteReaction(props: NoteReactionProps) {
* Some clients embed the reposted note in the content
*/
function extractRoot() {
if (ev?.Kind === EventKind.Repost && ev.Content.length > 0 && ev.Content !== "#[0]") {
if (ev?.kind === EventKind.Repost && ev.content.length > 0 && ev.content !== "#[0]") {
try {
const r: RawEvent = JSON.parse(ev.Content);
const r: RawEvent = JSON.parse(ev.content);
return r as TaggedRawEvent;
} catch (e) {
console.error("Could not load reposted content", e);
@ -58,23 +56,23 @@ export default function NoteReaction(props: NoteReactionProps) {
const isOpMuted = root && isMuted(root.pubkey);
const shouldNotBeRendered = isOpMuted || root?.kind !== EventKind.TextNote;
const opt = {
showHeader: ev?.Kind === EventKind.Repost || ev?.Kind === EventKind.TextNote,
showHeader: ev?.kind === EventKind.Repost || ev?.kind === EventKind.TextNote,
showFooter: false,
};
return shouldNotBeRendered ? null : (
<div className="reaction">
<div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} />
<ProfileImage pubkey={EventExt.getRootPubKey(ev)} />
<div className="info">
<NoteTime from={ev.CreatedAt * 1000} />
<NoteTime from={ev.created_at * 1000} />
</div>
</div>
{root ? <Note data={root} options={opt} related={[]} /> : null}
{!root && refEvent ? (
<p>
<Link to={eventLink(refEvent.Event ?? "", refEvent.Relay)}>
#{hexToBech32(NostrPrefix.Event, refEvent.Event).substring(0, 12)}
<Link to={eventLink(refEvent[1] ?? "", refEvent[2])}>
#{hexToBech32(NostrPrefix.Event, refEvent[1]).substring(0, 12)}
</Link>
</p>
) : null}

View File

@ -1,6 +1,9 @@
import "./Relay.css";
import { useMemo } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlug,
faSquareCheck,
@ -9,16 +12,15 @@ import {
faPlugCircleXmark,
faGear,
} from "@fortawesome/free-solid-svg-icons";
import useRelayState from "Feed/RelayState";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { setRelays } from "State/Login";
import { RootState } from "State/Store";
import { RelaySettings } from "@snort/nostr";
import useRelayState from "Feed/RelayState";
import { setRelays } from "State/Login";
import { RootState } from "State/Store";
import { System } from "System";
import { getRelayName, unwrap } from "Util";
import messages from "./messages";
import { getRelayName } from "Util";
export interface RelayProps {
addr: string;
@ -29,7 +31,7 @@ export default function Relay(props: RelayProps) {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const relaySettings = allRelaySettings[props.addr];
const relaySettings = unwrap(allRelaySettings[props.addr] ?? System.Sockets.get(props.addr)?.Settings ?? {});
const state = useRelayState(props.addr);
const name = useMemo(() => getRelayName(props.addr), [props.addr]);

View File

@ -2,9 +2,9 @@ import "./SendSats.css";
import React, { useEffect, useMemo, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { HexKey, RawEvent } from "@snort/nostr";
import { formatShort } from "Number";
import { Event, HexKey, Tag } from "@snort/nostr";
import { RootState } from "State/Store";
import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher";
@ -14,9 +14,10 @@ import QrCode from "Element/QrCode";
import Copy from "Element/Copy";
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
import { chunks, debounce } from "Util";
import { useWallet } from "Wallet";
import { EventExt } from "System/EventExt";
import messages from "./messages";
import { useWallet } from "Wallet";
enum ZapType {
PublicZap = 1,
@ -120,7 +121,7 @@ export default function SendSats(props: SendSatsProps) {
async function loadInvoice() {
if (!amount || !handler) return null;
let zap: Event | undefined;
let zap: RawEvent | undefined;
if (author && zapType !== ZapType.NonZap) {
const ev = await publisher.zap(amount * 1000, author, note, comment);
if (ev) {
@ -128,10 +129,10 @@ export default function SendSats(props: SendSatsProps) {
if (zapType === ZapType.AnonZap) {
const randomKey = publisher.newKey();
console.debug("Generated new key for zap: ", randomKey);
ev.PubKey = randomKey.publicKey;
ev.Id = "";
ev.Tags.push(new Tag(["anon"], ev.Tags.length));
await ev.Sign(randomKey.privateKey);
ev.pubkey = randomKey.publicKey;
ev.id = "";
ev.tags.push(["anon"]);
await EventExt.sign(ev, randomKey.privateKey);
}
zap = ev;
}

View File

@ -0,0 +1,14 @@
.sub-debug {
display: block;
position: fixed;
top: 5px;
left: 5px;
opacity: 0.8;
border: 1px solid;
padding: 5px;
font-family: monospace;
font-size: x-small;
background-color: black;
z-index: 999999;
color: white;
}

View File

@ -0,0 +1,104 @@
import "./SubDebug.css";
import { useState } from "react";
import useRelayState from "Feed/RelayState";
import Tabs, { Tab } from "Element/Tabs";
import { System } from "System";
import { unwrap } from "Util";
import useSystemState from "Hooks/useSystemState";
import { RawReqFilter } from "@snort/nostr";
import { useCopy } from "useCopy";
function RelayInfo({ id }: { id: string }) {
const state = useRelayState(id);
return <div key={id}>{state?.connected ? <>{id}</> : <s>{id}</s>}</div>;
}
function Queries() {
const qs = useSystemState();
const { copy } = useCopy();
function countElements(filters: Array<RawReqFilter>) {
let total = 0;
for (const f of filters) {
for (const v of Object.values(f)) {
if (Array.isArray(v)) {
total += v.length;
}
}
}
return total;
}
function queryInfo(q: {
id: string;
filters: Array<RawReqFilter>;
closing: boolean;
subFilters: Array<RawReqFilter>;
}) {
return (
<div key={q.id}>
{q.closing ? <s>{q.id}</s> : <>{q.id}</>}
<br />
<span onClick={() => copy(JSON.stringify(q.filters))} className="pointer">
&nbsp; Filters: {q.filters.length} ({countElements(q.filters)} elements)
</span>
<br />
<span onClick={() => copy(JSON.stringify(q.subFilters))} className="pointer">
&nbsp; SubQueries: {q.subFilters.length} ({countElements(q.subFilters)} elements)
</span>
</div>
);
}
return (
<>
<b>Queries</b>
{qs?.queries.map(v => queryInfo(v))}
</>
);
}
const SubDebug = () => {
const [onTab, setTab] = useState(0);
function connections() {
return (
<>
<b>Connections:</b>
{[...System.Sockets.keys()].map(k => (
<RelayInfo id={k} />
))}
</>
);
}
const tabs: Tab[] = [
{
text: "Connections",
value: 0,
},
{
text: "Queries",
value: 1,
},
];
return (
<div className="sub-debug">
<Tabs tabs={tabs} setTab={v => setTab(v.value)} tab={unwrap(tabs.find(a => a.value === onTab))} />
{(() => {
switch (onTab) {
case 0:
return connections();
case 1:
return <Queries />;
default:
return null;
}
})()}
</div>
);
};
export default SubDebug;

View File

@ -5,6 +5,7 @@ export interface Tab {
text: string;
value: number;
disabled?: boolean;
data?: string;
}
interface TabsProps {

View File

@ -4,7 +4,7 @@ import { Link, useLocation } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { visit, SKIP } from "unist-util-visit";
import * as unist from "unist";
import { HexKey, NostrPrefix, Tag } from "@snort/nostr";
import { HexKey, NostrPrefix } from "@snort/nostr";
import { MentionRegex, InvoiceRegex, HashtagRegex, MagnetRegex } from "Const";
import { eventLink, hexToBech32, magnetURIDecode, splitByUrl, unwrap } from "Util";
@ -18,13 +18,13 @@ export type Fragment = string | React.ReactNode;
export interface TextFragment {
body: React.ReactNode[];
tags: Tag[];
tags: Array<Array<string>>;
}
export interface TextProps {
content: string;
creator: HexKey;
tags: Tag[];
tags: Array<Array<string>>;
}
export default function Text({ content, tags, creator }: TextProps) {
@ -73,27 +73,27 @@ export default function Text({ content, tags, creator }: TextProps) {
const matchTag = match.match(/#\[(\d+)\]/);
if (matchTag && matchTag.length === 2) {
const idx = parseInt(matchTag[1]);
const ref = frag.tags?.find(a => a.Index === idx);
const ref = frag.tags?.[idx];
if (ref) {
switch (ref.Key) {
switch (ref[0]) {
case "p": {
return <Mention pubkey={ref.PubKey ?? ""} relays={ref.Relay} />;
return <Mention pubkey={ref[1] ?? ""} relays={ref[2]} />;
}
case "e": {
const eText = hexToBech32(NostrPrefix.Event, ref.Event).substring(0, 12);
return ref.Event ? (
<Link
to={eventLink(ref.Event, ref.Relay)}
onClick={e => e.stopPropagation()}
state={{ from: location.pathname }}>
#{eText}
</Link>
) : (
""
const eText = hexToBech32(NostrPrefix.Event, ref[1]).substring(0, 12);
return (
ref[1] && (
<Link
to={eventLink(ref[1], ref[2])}
onClick={e => e.stopPropagation()}
state={{ from: location.pathname }}>
#{eText}
</Link>
)
);
}
case "t": {
return <Hashtag tag={ref.Hashtag ?? ""} />;
return <Hashtag tag={ref[1] ?? ""} />;
}
}
}

View File

@ -1,25 +1,18 @@
import "./Thread.css";
import { useMemo, useState, useEffect, ReactNode } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useNavigate, useLocation, Link } from "react-router-dom";
import { useMemo, useState, ReactNode } from "react";
import { useIntl } from "react-intl";
import { useNavigate, useLocation, Link, useParams } from "react-router-dom";
import { TaggedRawEvent, u256, EventKind } from "@snort/nostr";
import { EventExt, Thread as ThreadInfo } from "System/EventExt";
import { TaggedRawEvent, u256, HexKey } from "@snort/nostr";
import { Event as NEvent, EventKind } from "@snort/nostr";
import { eventLink, bech32ToHex, unwrap } from "Util";
import { eventLink, unwrap, getReactions, parseNostrLink, getAllReactions } from "Util";
import BackButton from "Element/BackButton";
import Note from "Element/Note";
import NoteGhost from "Element/NoteGhost";
import Collapsed from "Element/Collapsed";
import messages from "./messages";
import useThreadFeed from "Feed/ThreadFeed";
function getParent(ev: HexKey, chains: Map<HexKey, NEvent[]>): HexKey | undefined {
for (const [k, vs] of chains.entries()) {
const fs = vs.map(a => a.Id);
if (fs.includes(ev)) {
return k;
}
}
}
import messages from "./messages";
interface DividerProps {
variant?: "regular" | "small";
@ -38,26 +31,25 @@ interface SubthreadProps {
isLastSubthread?: boolean;
from: u256;
active: u256;
path: u256[];
notes: NEvent[];
related: TaggedRawEvent[];
chains: Map<u256, NEvent[]>;
notes: readonly TaggedRawEvent[];
related: readonly TaggedRawEvent[];
chains: Map<u256, Array<TaggedRawEvent>>;
onNavigate: (e: u256) => void;
}
const Subthread = ({ active, path, notes, related, chains, onNavigate }: SubthreadProps) => {
const renderSubthread = (a: NEvent, idx: number) => {
const Subthread = ({ active, notes, related, chains, onNavigate }: SubthreadProps) => {
const renderSubthread = (a: TaggedRawEvent, idx: number) => {
const isLastSubthread = idx === notes.length - 1;
const replies = getReplies(a.Id, chains);
const replies = getReplies(a.id, chains);
return (
<>
<div className={`subthread-container ${replies.length > 0 ? "subthread-multi" : ""}`}>
<Divider />
<Note
highlight={active === a.Id}
highlight={active === a.id}
className={`thread-note ${isLastSubthread && replies.length === 0 ? "is-last-note" : ""}`}
data-ev={a}
key={a.Id}
data={a}
key={a.id}
related={related}
/>
<div className="line-container"></div>
@ -66,8 +58,7 @@ const Subthread = ({ active, path, notes, related, chains, onNavigate }: Subthre
<TierTwo
active={active}
isLastSubthread={isLastSubthread}
path={path}
from={a.Id}
from={a.id}
notes={replies}
related={related}
chains={chains}
@ -82,26 +73,16 @@ const Subthread = ({ active, path, notes, related, chains, onNavigate }: Subthre
};
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
note: NEvent;
note: TaggedRawEvent;
isLast: boolean;
}
const ThreadNote = ({
active,
note,
isLast,
path,
isLastSubthread,
from,
related,
chains,
onNavigate,
}: ThreadNoteProps) => {
const ThreadNote = ({ active, note, isLast, isLastSubthread, from, related, chains, onNavigate }: ThreadNoteProps) => {
const { formatMessage } = useIntl();
const replies = getReplies(note.Id, chains);
const activeInReplies = replies.map(r => r.Id).includes(active);
const replies = getReplies(note.id, chains);
const activeInReplies = replies.map(r => r.id).includes(active);
const [collapsed, setCollapsed] = useState(!activeInReplies);
const hasMultipleNotes = replies.length > 0;
const hasMultipleNotes = replies.length > 1;
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes;
const className = `subthread-container ${isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid"}`;
return (
@ -109,19 +90,18 @@ const ThreadNote = ({
<div className={className}>
<Divider variant="small" />
<Note
highlight={active === note.Id}
highlight={active === note.id}
className={`thread-note ${isLastVisibleNote ? "is-last-note" : ""}`}
data-ev={note}
key={note.Id}
data={note}
key={note.id}
related={related}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 &&
(activeInReplies ? (
{replies.length > 0 && (
<Collapsed text={formatMessage(messages.ShowReplies)} collapsed={collapsed} setCollapsed={setCollapsed}>
<TierThree
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
@ -129,32 +109,19 @@ const ThreadNote = ({
chains={chains}
onNavigate={onNavigate}
/>
) : (
<Collapsed text={formatMessage(messages.ShowReplies)} collapsed={collapsed} setCollapsed={setCollapsed}>
<TierThree
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
</Collapsed>
))}
</Collapsed>
)}
</>
);
};
const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const TierTwo = ({ active, isLastSubthread, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
return (
<>
<ThreadNote
active={active}
path={path}
from={from}
onNavigate={onNavigate}
note={first}
@ -164,12 +131,11 @@ const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains,
isLast={rest.length === 0}
/>
{rest.map((r: NEvent, idx: number) => {
{rest.map((r: TaggedRawEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
return (
<ThreadNote
active={active}
path={path}
from={from}
onNavigate={onNavigate}
note={r}
@ -184,10 +150,9 @@ const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains,
);
};
const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const TierThree = ({ active, isLastSubthread, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
const replies = getReplies(first.Id, chains);
const activeInReplies = notes.map(r => r.Id).includes(active) || replies.map(r => r.Id).includes(active);
const replies = getReplies(first.id, chains);
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
const isLast = replies.length === 0 && rest.length === 0;
return (
@ -198,51 +163,42 @@ const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains
}`}>
<Divider variant="small" />
<Note
highlight={active === first.Id}
highlight={active === first.id}
className={`thread-note ${isLastSubthread && isLast ? "is-last-note" : ""}`}
data-ev={first}
key={first.Id}
data={first}
key={first.id}
related={related}
/>
<div className="line-container"></div>
</div>
{path.length <= 1 || !activeInReplies
? replies.length > 0 && (
<div className="show-more-container">
<button className="show-more" type="button" onClick={() => onNavigate(from)}>
<FormattedMessage {...messages.ShowReplies} />
</button>
</div>
)
: replies.length > 0 && (
<TierThree
active={active}
path={path.slice(1)}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
{replies.length > 0 && (
<TierThree
active={active}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
{rest.map((r: NEvent, idx: number) => {
{rest.map((r: TaggedRawEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
const lastNote = isLastSubthread && lastReply;
return (
<div
key={r.Id}
key={r.id}
className={`subthread-container ${lastReply ? "" : "subthread-multi"} ${
lastReply ? "subthread-last" : "subthread-mid"
}`}>
<Divider variant="small" />
<Note
className={`thread-note ${lastNote ? "is-last-note" : ""}`}
highlight={active === r.Id}
data-ev={r}
key={r.Id}
highlight={active === r.id}
data={r}
key={r.id}
related={related}
/>
<div className="line-container"></div>
@ -253,148 +209,131 @@ const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains
);
};
export interface ThreadProps {
notes?: TaggedRawEvent[];
selected?: u256;
}
export default function Thread(props: ThreadProps) {
const notes = props.notes ?? [];
const parsedNotes = notes.map(a => new NEvent(a));
const [path, setPath] = useState<HexKey[]>([]);
const currentRoot = useMemo(() => parsedNotes.find(a => a.Id === props.selected), [notes, props.selected]);
const [navigated, setNavigated] = useState(false);
const navigate = useNavigate();
const isSingleNote = parsedNotes.filter(a => a.Kind === EventKind.TextNote).length === 1;
export default function Thread() {
const params = useParams();
const location = useLocation();
const link = parseNostrLink(params.id ?? "");
const thread = useThreadFeed(unwrap(link));
const [currentId, setCurrentId] = useState(link?.id);
const navigate = useNavigate();
const isSingleNote = thread.data?.filter(a => a.kind === EventKind.TextNote).length === 1;
const { formatMessage } = useIntl();
const urlNoteId = location?.pathname.slice(3);
const urlNoteHex = urlNoteId && bech32ToHex(urlNoteId);
const chains = useMemo(() => {
const chains = new Map<u256, NEvent[]>();
parsedNotes
?.filter(a => a.Kind === EventKind.TextNote)
.sort((a, b) => b.CreatedAt - a.CreatedAt)
.forEach(v => {
const replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
if (replyTo) {
if (!chains.has(replyTo)) {
chains.set(replyTo, [v]);
} else {
unwrap(chains.get(replyTo)).push(v);
const chains = new Map<u256, Array<TaggedRawEvent>>();
if (thread.data) {
thread.data
?.filter(a => a.kind === EventKind.TextNote)
.sort((a, b) => b.created_at - a.created_at)
.forEach(v => {
const thread = EventExt.extractThread(v);
const replyTo = thread?.replyTo?.Event ?? thread?.root?.Event;
if (replyTo) {
if (!chains.has(replyTo)) {
chains.set(replyTo, [v]);
} else {
unwrap(chains.get(replyTo)).push(v);
}
} else if (v.tags.length > 0) {
//console.log("Not replying to anything: ", v);
}
} else if (v.Tags.length > 0) {
console.log("Not replying to anything: ", v);
}
});
});
}
return chains;
}, [notes]);
}, [thread.data]);
// Root is the parent of the current note or the current note if its a root note or the root of the thread
const root = useMemo(() => {
const isRoot = (ne?: NEvent) => ne?.Thread === null;
const currentNote = parsedNotes.find(ne => ne.Id === urlNoteHex);
const currentNote = thread.data?.find(ne => ne.id === currentId) ?? (location.state as TaggedRawEvent);
if (currentNote) {
const currentThread = EventExt.extractThread(currentNote);
const isRoot = (ne?: ThreadInfo) => ne === undefined;
if (isRoot(currentNote)) {
return currentNote;
}
if (isRoot(currentThread)) {
return currentNote;
}
const replyTo = currentThread?.replyTo?.Event ?? currentThread?.root?.Event;
const rootEventId = currentNote?.Thread?.Root?.Event;
// sometimes the root event ID is missing, and we can only take the happy path if the root event ID exists
if (replyTo) {
return thread.data?.find(a => a.id === replyTo);
}
// sometimes the root event ID is missing, and we can only take the happy path if the root event ID exists
if (rootEventId) {
return parsedNotes.find(ne => ne.Id === rootEventId);
}
const possibleRoots = thread.data?.filter(a => {
const thread = EventExt.extractThread(a);
return isRoot(thread);
});
if (possibleRoots) {
// worst case we need to check every possible root to see which one contains the current note as a child
for (const ne of possibleRoots) {
const children = chains.get(ne.id) ?? [];
const possibleRoots = parsedNotes.filter(isRoot);
// worst case we need to check every possible root to see which one contains the current note as a child
for (const ne of possibleRoots) {
const children = chains.get(ne.Id) ?? [];
if (children.find(ne => ne.Id === urlNoteHex)) {
return ne;
if (children.find(ne => ne.id === currentId)) {
return ne;
}
}
}
}
}, [notes, chains, urlNoteHex]);
}, [thread.data, currentId, location]);
useEffect(() => {
if (!root) {
return;
const parent = useMemo(() => {
if (root) {
const currentThread = EventExt.extractThread(root);
return currentThread?.replyTo?.Event ?? currentThread?.root?.Event;
}
}, [root]);
if (navigated) {
return;
}
const brokenChains = Array.from(chains?.keys()).filter(a => !thread.data?.some(b => b.id === a));
if (root.Id === urlNoteHex) {
setPath([root.Id]);
setNavigated(true);
return;
}
const subthreadPath = [];
let parent = getParent(urlNoteHex, chains);
while (parent) {
subthreadPath.unshift(parent);
parent = getParent(parent, chains);
}
setPath(subthreadPath);
setNavigated(true);
}, [root, navigated, urlNoteHex, chains]);
const brokenChains = useMemo(() => {
return Array.from(chains?.keys()).filter(a => !parsedNotes?.some(b => b.Id === a));
}, [chains]);
function renderRoot(note: NEvent) {
function renderRoot(note: TaggedRawEvent) {
const className = `thread-root ${isSingleNote ? "thread-root-single" : ""}`;
if (note) {
return (
<Note
className={className}
key={note.Id}
data-ev={note}
related={notes}
key={note.id}
data={note}
related={getReactions(thread.data, note.id)}
options={{ showReactionsLink: true }}
/>
);
} else {
return <NoteGhost className={className}>Loading thread root.. ({notes?.length} notes loaded)</NoteGhost>;
return <NoteGhost className={className}>Loading thread root.. ({thread.data?.length} notes loaded)</NoteGhost>;
}
}
function onNavigate(to: u256) {
setPath([...path, to]);
}
function renderChain(from: u256): ReactNode {
if (!from || !chains) {
return;
}
const replies = chains.get(from);
if (replies) {
if (replies && currentId) {
return (
<Subthread
active={urlNoteHex}
path={path}
active={currentId}
from={from}
notes={replies}
related={notes}
related={getAllReactions(
thread.data,
replies.map(a => a.id)
)}
chains={chains}
onNavigate={onNavigate}
onNavigate={() => {
//nothing
}}
/>
);
}
}
function goBack() {
if (path.length > 1) {
const newPath = path.slice(0, path.length - 1);
setPath(newPath);
if (parent) {
setCurrentId(parent);
} else {
navigate(location.state?.from ?? "/");
navigate(-1);
}
}
@ -408,31 +347,28 @@ export default function Thread(props: ThreadProps) {
});
return (
<div className="main-content mt10">
<BackButton onClick={goBack} text={path?.length > 1 ? parentText : backText} />
<BackButton onClick={goBack} text={parent ? parentText : backText} />
<div className="thread-container">
{currentRoot && renderRoot(currentRoot)}
{currentRoot && renderChain(currentRoot.Id)}
{currentRoot === root && (
<>
{brokenChains.length > 0 && <h3>Other replies</h3>}
{brokenChains.map(a => {
return (
<div className="mb10">
<NoteGhost className={`thread-note thread-root ghost-root`} key={a}>
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
</NoteGhost>
{renderChain(a)}
</div>
);
})}
</>
)}
{root && renderRoot(root)}
{root && renderChain(root.id)}
{brokenChains.length > 0 && <h3>Other replies</h3>}
{brokenChains.map(a => {
return (
<div className="mb10">
<NoteGhost className={`thread-note thread-root ghost-root`} key={a}>
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
</NoteGhost>
{renderChain(a)}
</div>
);
})}
</div>
</div>
);
}
function getReplies(from: u256, chains?: Map<u256, NEvent[]>): NEvent[] {
function getReplies(from: u256, chains?: Map<u256, Array<TaggedRawEvent>>): Array<TaggedRawEvent> {
if (!from || !chains) {
return [];
}

View File

@ -1,24 +1,20 @@
import "./Timeline.css";
import { FormattedMessage } from "react-intl";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useMemo } from "react";
import { useInView } from "react-intersection-observer";
import { TaggedRawEvent, EventKind, u256 } from "@snort/nostr";
import Icon from "Icons/Icon";
import { dedupeById, dedupeByPubkey, tagFilterOfTextRepost } from "Util";
import { dedupeByPubkey, findTag, tagFilterOfTextRepost, unixNow } from "Util";
import ProfileImage from "Element/ProfileImage";
import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
import { TaggedRawEvent } from "@snort/nostr";
import { EventKind } from "@snort/nostr";
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "Feed/TimelineFeed";
import LoadMore from "Element/LoadMore";
import Zap, { parseZap } from "Element/Zap";
import Note from "Element/Note";
import NoteReaction from "Element/NoteReaction";
import useModeration from "Hooks/useModeration";
import ProfilePreview from "./ProfilePreview";
import ProfilePreview from "Element/ProfilePreview";
import Skeleton from "Element/Skeleton";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "State/Store";
import { setTimeline } from "State/Cache";
export interface TimelineProps {
postsOnly: boolean;
@ -27,64 +23,59 @@ export interface TimelineProps {
ignoreModeration?: boolean;
window?: number;
relay?: string;
now?: number;
}
/**
* A list of notes by pubkeys
*/
export default function Timeline({
subject,
postsOnly = false,
method,
ignoreModeration = false,
window: timeWindow,
relay,
}: TimelineProps) {
const Timeline = (props: TimelineProps) => {
const feedOptions = useMemo(() => {
return {
method: props.method,
window: props.window,
relay: props.relay,
now: props.now,
};
}, [props]);
const feed: TimelineFeed = useTimelineFeed(props.subject, feedOptions);
const { muted, isMuted } = useModeration();
const dispatch = useDispatch();
const cache = useSelector((s: RootState) => s.cache.timeline);
const feed = useTimelineFeed(subject, {
method,
window: timeWindow,
relay,
});
const { ref, inView } = useInView();
const filterPosts = useCallback(
(nts: TaggedRawEvent[]) => {
(nts: readonly TaggedRawEvent[]) => {
return [...nts]
.sort((a, b) => b.created_at - a.created_at)
?.filter(a => (postsOnly ? !a.tags.some(b => b[0] === "e") : true))
.filter(a => ignoreModeration || !isMuted(a.pubkey));
?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : a.tags.some(b => b[0] === "e")))
.filter(a => props.ignoreModeration || !isMuted(a.pubkey));
},
[postsOnly, muted, ignoreModeration]
[props.postsOnly, muted, props.ignoreModeration]
);
const mainFeed = useMemo(() => {
return filterPosts(cache.main);
}, [cache, filterPosts]);
return filterPosts(feed.main ?? []);
}, [feed, filterPosts]);
const latestFeed = useMemo(() => {
return filterPosts(cache.latest).filter(a => !mainFeed.some(b => b.id === a.id));
}, [cache, filterPosts]);
return filterPosts(feed.latest ?? []).filter(a => !mainFeed.some(b => b.id === a.id));
}, [feed, filterPosts]);
const relatedFeed = useCallback(
(id: u256) => {
return (feed.related ?? []).filter(a => findTag(a, "e") === id);
},
[feed.related]
);
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]);
useEffect(() => {
const key = `${subject.type}-${subject.discriminator}`;
const newFeed = key !== cache.key;
dispatch(
setTimeline({
key: key,
main: dedupeById([...(newFeed ? [] : cache.main), ...feed.main.notes]),
latest: [...feed.latest.notes],
related: dedupeById([...(newFeed ? [] : cache.related), ...feed.related.notes]),
parent: dedupeById([...(newFeed ? [] : cache.parent), ...feed.parent.notes]),
})
);
}, [feed.main, feed.latest, feed.related, feed.parent]);
function eventElement(e: TaggedRawEvent) {
switch (e.kind) {
case EventKind.SetMetadata: {
@ -93,9 +84,9 @@ export default function Timeline({
case EventKind.TextNote: {
const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1);
if (eRef) {
return <NoteReaction data={e} key={e.id} root={cache.parent.find(a => a.id === eRef)} />;
return <NoteReaction data={e} key={e.id} root={findRelated(eRef)} />;
}
return <Note key={e.id} data={e} related={cache.related} ignoreModeration={ignoreModeration} />;
return <Note key={e.id} data={e} related={relatedFeed(e.id)} ignoreModeration={props.ignoreModeration} />;
}
case EventKind.ZapReceipt: {
const zap = parseZap(e);
@ -103,8 +94,8 @@ export default function Timeline({
}
case EventKind.Reaction:
case EventKind.Repost: {
const eRef = e.tags.find(a => a[0] === "e")?.at(1);
return <NoteReaction data={e} key={e.id} root={cache.parent.find(a => a.id === eRef)} />;
const eRef = findTag(e, "e");
return <NoteReaction data={e} key={e.id} root={findRelated(eRef)} />;
}
}
}
@ -115,6 +106,7 @@ export default function Timeline({
window.scrollTo(0, 0);
}
}
return (
<div className="main-content">
{latestFeed.length > 0 && (
@ -144,11 +136,12 @@ export default function Timeline({
</>
)}
{mainFeed.map(eventElement)}
<LoadMore onLoadMore={feed.loadMore} shouldLoadMore={feed.main.end}>
<LoadMore onLoadMore={feed.loadMore} shouldLoadMore={!feed.loading}>
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
</LoadMore>
</div>
);
}
};
export default Timeline;

View File

@ -1,7 +1,10 @@
import { useMemo } from "react";
import { TaggedRawEvent, EventKind, HexKey, Lists, Subscriptions } from "@snort/nostr";
import useSubscription from "Feed/Subscription";
import { EventKind, HexKey, Lists } from "@snort/nostr";
import { unwrap, findTag, chunks } from "Util";
import { RequestBuilder } from "System";
import { FlatNoteStore, ReplaceableNoteStore } from "System/NoteCollection";
import useRequestBuilder from "Hooks/useRequestBuilder";
type BadgeAwards = {
pubkeys: string[];
@ -11,22 +14,17 @@ type BadgeAwards = {
export default function useProfileBadges(pubkey?: HexKey) {
const sub = useMemo(() => {
if (!pubkey) return null;
const s = new Subscriptions();
s.Id = `badges:${pubkey.slice(0, 12)}`;
s.Kinds = new Set([EventKind.ProfileBadges]);
s.DTags = new Set([Lists.Badges]);
s.Authors = new Set([pubkey]);
return s;
const b = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`);
b.withFilter().kinds([EventKind.ProfileBadges]).tag("d", [Lists.Badges]).authors([pubkey]);
return b;
}, [pubkey]);
const profileBadges = useSubscription(sub, { leaveOpen: false, cache: false });
const profileBadges = useRequestBuilder<ReplaceableNoteStore>(ReplaceableNoteStore, sub);
const profile = useMemo(() => {
const sorted = [...profileBadges.store.notes];
sorted.sort((a, b) => b.created_at - a.created_at);
const last = sorted[0];
if (last) {
if (profileBadges.data) {
return chunks(
last.tags.filter(t => t[0] === "a" || t[0] === "e"),
profileBadges.data.tags.filter(t => t[0] === "a" || t[0] === "e"),
2
).reduce((acc, [a, e]) => {
return {
@ -36,7 +34,7 @@ export default function useProfileBadges(pubkey?: HexKey) {
}, {});
}
return {};
}, [pubkey, profileBadges.store]);
}, [profileBadges]);
const { ds, pubkeys } = useMemo(() => {
return Object.values(profile).reduce(
@ -55,48 +53,37 @@ export default function useProfileBadges(pubkey?: HexKey) {
const awardsSub = useMemo(() => {
const ids = Object.keys(profile);
if (!pubkey || ids.length === 0) return null;
const s = new Subscriptions();
s.Id = `profile_awards:${pubkey.slice(0, 12)}`;
s.Kinds = new Set([EventKind.BadgeAward]);
s.Ids = new Set(ids);
return s;
}, [pubkey, profileBadges.store]);
const b = new RequestBuilder(`profile_awards:${pubkey.slice(0, 12)}`);
b.withFilter().kinds([EventKind.BadgeAward]).ids(ids);
b.withFilter().kinds([EventKind.Badge]).tag("d", ds).authors(pubkeys);
return b;
}, [profile, ds]);
const awards = useSubscription(awardsSub).store.notes;
const badgesSub = useMemo(() => {
if (!pubkey || pubkeys.length === 0) return null;
const s = new Subscriptions();
s.Id = `profile_badges:${pubkey.slice(0, 12)}`;
s.Kinds = new Set([EventKind.Badge]);
s.DTags = new Set(ds);
s.Authors = new Set(pubkeys);
return s;
}, [pubkey, profile]);
const badges = useSubscription(badgesSub, { leaveOpen: false, cache: false }).store.notes;
const awards = useRequestBuilder<FlatNoteStore>(FlatNoteStore, awardsSub);
const result = useMemo(() => {
return awards
.map((award: TaggedRawEvent) => {
const [, pubkey, d] =
award.tags
.find(t => t[0] === "a")
?.at(1)
?.split(":") ?? [];
const badge = badges.find(b => b.pubkey === pubkey && findTag(b, "d") === d);
if (awards.data) {
return awards.data
.map((award, _, arr) => {
const [, pubkey, d] =
award.tags
.find(t => t[0] === "a")
?.at(1)
?.split(":") ?? [];
const badge = arr.find(b => b.pubkey === pubkey && findTag(b, "d") === d);
return {
award,
badge,
};
})
.filter(
({ award, badge }) =>
badge && award.pubkey === badge.pubkey && award.tags.find(t => t[0] === "p" && t[1] === pubkey)
)
.map(({ badge }) => unwrap(badge));
}, [pubkey, awards, badges]);
return {
award,
badge,
};
})
.filter(
({ award, badge }) =>
badge && award.pubkey === badge.pubkey && award.tags.find(t => t[0] === "p" && t[1] === pubkey)
)
.map(({ badge }) => unwrap(badge));
}
}, [pubkey, awards]);
return result;
return result ?? [];
}

View File

@ -1,8 +1,8 @@
import { useSelector } from "react-redux";
import { HexKey, Lists } from "@snort/nostr";
import { RootState } from "State/Store";
import { HexKey, Lists } from "@snort/nostr";
import useNotelistSubscription from "Feed/useNotelistSubscription";
import useNotelistSubscription from "Hooks/useNotelistSubscription";
export default function useBookmarkFeed(pubkey?: HexKey) {
const { bookmarked } = useSelector((s: RootState) => s.login);

View File

@ -1,14 +1,13 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import * as secp from "@noble/secp256k1";
import { EventKind, RelaySettings, TaggedRawEvent, HexKey, RawEvent, u256, UserMetadata, Lists } from "@snort/nostr";
import { TaggedRawEvent } from "@snort/nostr";
import { EventKind, Tag, Event as NEvent, RelaySettings } from "@snort/nostr";
import { RootState } from "State/Store";
import { HexKey, RawEvent, u256, UserMetadata, Lists } from "@snort/nostr";
import { bech32ToHex, delay, unwrap } from "Util";
import { DefaultRelays, HashtagRegex } from "Const";
import { System } from "System";
import { useMemo } from "react";
import { EventExt } from "System/EventExt";
declare global {
interface Window {
@ -33,26 +32,27 @@ export default function useEventPublisher() {
const relays = useSelector((s: RootState) => s.login.relays);
const hasNip07 = "nostr" in window;
async function signEvent(ev: NEvent): Promise<NEvent> {
async function signEvent(ev: RawEvent): Promise<RawEvent> {
if (hasNip07 && !privKey) {
ev.Id = ev.CreateId();
const tmpEv = (await barrierNip07(() => window.nostr.signEvent(ev.ToObject()))) as RawEvent;
return new NEvent(tmpEv as TaggedRawEvent);
ev.id = await EventExt.createId(ev);
const tmpEv = (await barrierNip07(() => window.nostr.signEvent(ev))) as RawEvent;
ev.sig = tmpEv.sig;
return ev;
} else if (privKey) {
await ev.Sign(privKey);
await EventExt.sign(ev, privKey);
} else {
console.warn("Count not sign event, no private keys available");
}
return ev;
}
function processContent(ev: NEvent, msg: string) {
function processContent(ev: RawEvent, msg: string) {
const replaceNpub = (match: string) => {
const npub = match.slice(1);
try {
const hex = bech32ToHex(npub);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["p", hex], idx));
const idx = ev.tags.length;
ev.tags.push(["p", hex]);
return `#[${idx}]`;
} catch (error) {
return match;
@ -62,8 +62,8 @@ export default function useEventPublisher() {
const noteId = match.slice(1);
try {
const hex = bech32ToHex(noteId);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["e", hex, "", "mention"], idx));
const idx = ev.tags.length;
ev.tags.push(["e", hex, "", "mention"]);
return `#[${idx}]`;
} catch (error) {
return match;
@ -71,29 +71,26 @@ export default function useEventPublisher() {
};
const replaceHashtag = (match: string) => {
const tag = match.slice(1);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx));
ev.tags.push(["t", tag.toLowerCase()]);
return match;
};
const content = msg
.replace(/@npub[a-z0-9]+/g, replaceNpub)
.replace(/@note1[acdefghjklmnpqrstuvwxyz023456789]{58}/g, replaceNoteId)
.replace(HashtagRegex, replaceHashtag);
ev.Content = content;
ev.content = content;
}
const ret = {
nip42Auth: async (challenge: string, relay: string) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Auth;
ev.Content = "";
ev.Tags.push(new Tag(["relay", relay], 0));
ev.Tags.push(new Tag(["challenge", challenge], 1));
const ev = EventExt.forPubKey(pubKey, EventKind.Auth);
ev.tags.push(["relay", relay]);
ev.tags.push(["challenge", challenge]);
return await signEvent(ev);
}
},
broadcast: (ev: NEvent | undefined) => {
broadcast: (ev: RawEvent | undefined) => {
if (ev) {
console.debug("Sending event: ", ev);
System.BroadcastEvent(ev);
@ -104,7 +101,7 @@ export default function useEventPublisher() {
* If a user removes all the DefaultRelays from their relay list and saves that relay list,
* When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state
*/
broadcastForBootstrap: (ev: NEvent | undefined) => {
broadcastForBootstrap: (ev: RawEvent | undefined) => {
if (ev) {
for (const [k] of DefaultRelays) {
System.WriteOnceToRelay(k, ev);
@ -114,7 +111,7 @@ export default function useEventPublisher() {
/**
* Write event to all given relays.
*/
broadcastAll: (ev: NEvent | undefined, relays: string[]) => {
broadcastAll: (ev: RawEvent | undefined, relays: string[]) => {
if (ev) {
for (const k of relays) {
System.WriteOnceToRelay(k, ev);
@ -123,11 +120,10 @@ export default function useEventPublisher() {
},
muted: async (keys: HexKey[], priv: HexKey[]) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.PubkeyLists;
ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length));
const ev = EventExt.forPubKey(pubKey, EventKind.PubkeyLists);
ev.tags.push(["d", Lists.Muted]);
keys.forEach(p => {
ev.Tags.push(new Tag(["p", p], ev.Tags.length));
ev.tags.push(["p", p]);
});
let content = "";
if (priv.length > 0) {
@ -136,76 +132,67 @@ export default function useEventPublisher() {
if (hasNip07 && !privKey) {
content = await barrierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext));
} else if (privKey) {
content = await ev.EncryptData(plaintext, pubKey, privKey);
content = await EventExt.encryptData(plaintext, pubKey, privKey);
}
}
ev.Content = content;
ev.content = content;
return await signEvent(ev);
}
},
pinned: async (notes: HexKey[]) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.NoteLists;
ev.Tags.push(new Tag(["d", Lists.Pinned], ev.Tags.length));
const ev = EventExt.forPubKey(pubKey, EventKind.NoteLists);
ev.tags.push(["d", Lists.Pinned]);
notes.forEach(n => {
ev.Tags.push(new Tag(["e", n], ev.Tags.length));
ev.tags.push(["e", n]);
});
ev.Content = "";
return await signEvent(ev);
}
},
bookmarked: async (notes: HexKey[]) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.NoteLists;
ev.Tags.push(new Tag(["d", Lists.Bookmarked], ev.Tags.length));
const ev = EventExt.forPubKey(pubKey, EventKind.NoteLists);
ev.tags.push(["d", Lists.Bookmarked]);
notes.forEach(n => {
ev.Tags.push(new Tag(["e", n], ev.Tags.length));
ev.tags.push(["e", n]);
});
ev.Content = "";
return await signEvent(ev);
}
},
tags: async (tags: string[]) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TagLists;
ev.Tags.push(new Tag(["d", Lists.Followed], ev.Tags.length));
const ev = EventExt.forPubKey(pubKey, EventKind.TagLists);
ev.tags.push(["d", Lists.Followed]);
tags.forEach(t => {
ev.Tags.push(new Tag(["t", t], ev.Tags.length));
ev.tags.push(["t", t]);
});
ev.Content = "";
return await signEvent(ev);
}
},
metadata: async (obj: UserMetadata) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.SetMetadata;
ev.Content = JSON.stringify(obj);
const ev = EventExt.forPubKey(pubKey, EventKind.SetMetadata);
ev.content = JSON.stringify(obj);
return await signEvent(ev);
}
},
note: async (msg: string) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
const ev = EventExt.forPubKey(pubKey, EventKind.TextNote);
processContent(ev, msg);
return await signEvent(ev);
}
},
zap: async (amount: number, author: HexKey, note?: HexKey, msg?: string) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ZapRequest;
const ev = EventExt.forPubKey(pubKey, EventKind.ZapRequest);
if (note) {
ev.Tags.push(new Tag(["e", note], ev.Tags.length));
ev.tags.push(["e", note]);
}
ev.Tags.push(new Tag(["p", author], ev.Tags.length));
ev.tags.push(["p", author]);
const relayTag = ["relays", ...Object.keys(relays).map(a => a.trim())];
ev.Tags.push(new Tag(relayTag, ev.Tags.length));
ev.Tags.push(new Tag(["amount", amount.toString()], ev.Tags.length));
ev.tags.push(relayTag);
ev.tags.push(["amount", amount.toString()]);
processContent(ev, msg || "");
return await signEvent(ev);
}
@ -213,57 +200,54 @@ export default function useEventPublisher() {
/**
* Reply to a note
*/
reply: async (replyTo: NEvent, msg: string) => {
reply: async (replyTo: TaggedRawEvent, msg: string) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
const ev = EventExt.forPubKey(pubKey, EventKind.TextNote);
const thread = replyTo.Thread;
const thread = EventExt.extractThread(ev);
if (thread) {
if (thread.Root || thread.ReplyTo) {
ev.Tags.push(new Tag(["e", thread.Root?.Event ?? thread.ReplyTo?.Event ?? "", "", "root"], ev.Tags.length));
if (thread.root || thread.replyTo) {
ev.tags.push(["e", thread.root?.Event ?? thread.replyTo?.Event ?? "", "", "root"]);
}
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length));
ev.tags.push(["e", replyTo.id, replyTo.relays[0] ?? "", "reply"]);
// dont tag self in replies
if (replyTo.PubKey !== pubKey) {
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
if (replyTo.pubkey !== pubKey) {
ev.tags.push(["p", replyTo.pubkey]);
}
for (const pk of thread.PubKeys) {
for (const pk of thread.pubKeys) {
if (pk === pubKey) {
continue; // dont tag self in replies
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
ev.tags.push(["p", pk]);
}
} else {
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0));
ev.tags.push(["e", replyTo.id, "", "reply"]);
// dont tag self in replies
if (replyTo.PubKey !== pubKey) {
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
if (replyTo.pubkey !== pubKey) {
ev.tags.push(["p", replyTo.pubkey]);
}
}
processContent(ev, msg);
return await signEvent(ev);
}
},
react: async (evRef: NEvent, content = "+") => {
react: async (evRef: RawEvent, content = "+") => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Reaction;
ev.Content = content;
ev.Tags.push(new Tag(["e", evRef.Id], 0));
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
const ev = EventExt.forPubKey(pubKey, EventKind.Reaction);
ev.content = content;
ev.tags.push(["e", evRef.id]);
ev.tags.push(["p", evRef.pubkey]);
return await signEvent(ev);
}
},
saveRelays: async () => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
ev.content = JSON.stringify(relays);
for (const pk of follows) {
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
ev.tags.push(["p", pk]);
}
return await signEvent(ev);
@ -271,9 +255,7 @@ export default function useEventPublisher() {
},
saveRelaysSettings: async () => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Relays;
ev.Content = "";
const ev = EventExt.forPubKey(pubKey, EventKind.Relays);
for (const [url, settings] of Object.entries(relays)) {
const rTag = ["r", url];
if (settings.read && !settings.write) {
@ -282,16 +264,15 @@ export default function useEventPublisher() {
if (settings.write && !settings.read) {
rTag.push("write");
}
ev.Tags.push(new Tag(rTag, ev.Tags.length));
ev.tags.push(rTag);
}
return await signEvent(ev);
}
},
addFollow: async (pkAdd: HexKey | HexKey[], newRelays?: Record<string, RelaySettings>) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(newRelays ?? relays);
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
ev.content = JSON.stringify(newRelays ?? relays);
const temp = new Set(follows);
if (Array.isArray(pkAdd)) {
pkAdd.forEach(a => temp.add(a));
@ -302,7 +283,7 @@ export default function useEventPublisher() {
if (pk.length !== 64) {
continue;
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
ev.tags.push(["p", pk.toLowerCase()]);
}
return await signEvent(ev);
@ -310,14 +291,13 @@ export default function useEventPublisher() {
},
removeFollow: async (pkRemove: HexKey) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
ev.content = JSON.stringify(relays);
for (const pk of follows) {
if (pk === pkRemove || pk.length !== 64) {
continue;
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
ev.tags.push(["p", pk]);
}
return await signEvent(ev);
@ -328,39 +308,33 @@ export default function useEventPublisher() {
*/
delete: async (id: u256) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Deletion;
ev.Content = "";
ev.Tags.push(new Tag(["e", id], 0));
const ev = EventExt.forPubKey(pubKey, EventKind.Deletion);
ev.tags.push(["e", id]);
return await signEvent(ev);
}
},
/**
* Repost a note (NIP-18)
*/
repost: async (note: NEvent) => {
repost: async (note: TaggedRawEvent) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Repost;
ev.Content = JSON.stringify(note.Original);
ev.Tags.push(new Tag(["e", note.Id], 0));
ev.Tags.push(new Tag(["p", note.PubKey], 1));
const ev = EventExt.forPubKey(pubKey, EventKind.Repost);
ev.tags.push(["e", note.id, ""]);
ev.tags.push(["p", note.pubkey]);
return await signEvent(ev);
}
},
decryptDm: async (note: NEvent): Promise<string | undefined> => {
decryptDm: async (note: RawEvent): Promise<string | undefined> => {
if (pubKey) {
if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === pubKey)) {
if (note.pubkey !== pubKey && !note.tags.some(a => a[1] === pubKey)) {
return "<CANT DECRYPT>";
}
try {
const otherPubKey =
note.PubKey === pubKey ? unwrap(note.Tags.filter(a => a.Key === "p")[0].PubKey) : note.PubKey;
const otherPubKey = note.pubkey === pubKey ? unwrap(note.tags.filter(a => a[0] === "p")[0][1]) : note.pubkey;
if (hasNip07 && !privKey) {
return await barrierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.Content));
return await barrierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.content));
} else if (privKey) {
await note.DecryptDm(privKey, otherPubKey);
return note.Content;
return await EventExt.decryptDm(note.content, privKey, otherPubKey);
}
} catch (e) {
console.error("Decryption failed", e);
@ -370,18 +344,17 @@ export default function useEventPublisher() {
},
sendDm: async (content: string, to: HexKey) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.DirectMessage;
ev.Content = content;
ev.Tags.push(new Tag(["p", to], 0));
const ev = EventExt.forPubKey(pubKey, EventKind.DirectMessage);
ev.content = content;
ev.tags.push(["p", to]);
try {
if (hasNip07 && !privKey) {
const cx: string = await barrierNip07(() => window.nostr.nip04.encrypt(to, content));
ev.Content = cx;
ev.content = cx;
return await signEvent(ev);
} else if (privKey) {
await ev.EncryptDmForPubkey(to, privKey);
ev.content = await EventExt.encryptData(content, to, privKey);
return await signEvent(ev);
}
} catch (e) {
@ -399,9 +372,8 @@ export default function useEventPublisher() {
},
generic: async (content: string, kind: EventKind) => {
if (pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = kind;
ev.Content = content;
const ev = EventExt.forPubKey(pubKey, kind);
ev.content = content;
return await signEvent(ev);
}
},

View File

@ -1,23 +1,21 @@
import { useMemo } from "react";
import { HexKey } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import useSubscription from "Feed/Subscription";
import { HexKey, EventKind } from "@snort/nostr";
import { PubkeyReplaceableNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
export default function useFollowersFeed(pubkey?: HexKey) {
const sub = useMemo(() => {
if (!pubkey) return null;
const x = new Subscriptions();
x.Id = `followers:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ContactList]);
x.PTags = new Set([pubkey]);
return x;
const b = new RequestBuilder(`followers:${pubkey.slice(0, 12)}`);
b.withFilter().kinds([EventKind.ContactList]).tag("p", [pubkey]);
return b;
}, [pubkey]);
const followersFeed = useSubscription(sub, { leaveOpen: false, cache: true });
const followersFeed = useRequestBuilder<PubkeyReplaceableNoteStore>(PubkeyReplaceableNoteStore, sub);
const followers = useMemo(() => {
const contactLists = followersFeed?.store.notes.filter(
const contactLists = followersFeed.data?.filter(
a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey)
);
return [...new Set(contactLists?.map(a => a.pubkey))];

View File

@ -1,9 +1,10 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { HexKey, TaggedRawEvent, EventKind, Subscriptions } from "@snort/nostr";
import { HexKey, TaggedRawEvent, EventKind } from "@snort/nostr";
import useSubscription from "Feed/Subscription";
import { RootState } from "State/Store";
import { PubkeyReplaceableNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
export default function useFollowsFeed(pubkey?: HexKey) {
const { publicKey, follows } = useSelector((s: RootState) => s.login);
@ -11,24 +12,22 @@ export default function useFollowsFeed(pubkey?: HexKey) {
const sub = useMemo(() => {
if (isMe || !pubkey) return null;
const x = new Subscriptions();
x.Id = `follows:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ContactList]);
x.Authors = new Set([pubkey]);
return x;
const b = new RequestBuilder(`follows:${pubkey.slice(0, 12)}`);
b.withFilter().kinds([EventKind.ContactList]).authors([pubkey]);
return b;
}, [isMe, pubkey]);
const contactFeed = useSubscription(sub, { leaveOpen: false, cache: true });
const contactFeed = useRequestBuilder<PubkeyReplaceableNoteStore>(PubkeyReplaceableNoteStore, sub);
return useMemo(() => {
if (isMe) {
return follows;
}
return getFollowing(contactFeed.store.notes ?? [], pubkey);
}, [contactFeed.store, follows, pubkey]);
return getFollowing(contactFeed.data ?? [], pubkey);
}, [contactFeed, follows, pubkey]);
}
export function getFollowing(notes: TaggedRawEvent[], pubkey?: HexKey) {
export function getFollowing(notes: readonly TaggedRawEvent[], pubkey?: HexKey) {
const contactLists = notes.filter(a => a.kind === EventKind.ContactList && a.pubkey === pubkey);
const pTags = contactLists?.map(a => a.tags.filter(b => b[0] === "p").map(c => c[1]));
return [...new Set(pTags?.flat())];

View File

@ -1,10 +1,10 @@
import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit";
import { TaggedRawEvent, HexKey, Lists, EventKind } from "@snort/nostr";
import { getNewest } from "Util";
import { getNewest, getNewestEventTagsByKey, unwrap } from "Util";
import { makeNotification } from "Notifications";
import { TaggedRawEvent, HexKey, Lists } from "@snort/nostr";
import { Event, EventKind, Subscriptions } from "@snort/nostr";
import {
addDirectMessage,
setFollows,
@ -18,11 +18,12 @@ import {
setLatestNotifications,
} from "State/Login";
import { RootState } from "State/Store";
import useSubscription from "Feed/Subscription";
import { barrierNip07 } from "Feed/EventPublisher";
import { getMutedKeys } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit";
import { FlatNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
import { EventExt } from "System/EventExt";
/**
* Managed loading data for the current logged in user
@ -37,143 +38,75 @@ export default function useLoginFeed() {
} = useSelector((s: RootState) => s.login);
const { isMuted } = useModeration();
const subMetadata = useMemo(() => {
const subLogin = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = `login:meta`;
sub.Authors = new Set([pubKey]);
sub.Kinds = new Set([EventKind.ContactList]);
sub.Limit = 2;
return sub;
}, [pubKey]);
const subNotification = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = "login:notifications";
// todo: add zaps
sub.Kinds = new Set([EventKind.TextNote]);
sub.PTags = new Set([pubKey]);
sub.Limit = 1;
return sub;
}, [pubKey]);
const subMuted = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = "login:muted";
sub.Kinds = new Set([EventKind.PubkeyLists]);
sub.Authors = new Set([pubKey]);
sub.DTags = new Set([Lists.Muted]);
sub.Limit = 1;
return sub;
}, [pubKey]);
const subTags = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = "login:tags";
sub.Kinds = new Set([EventKind.TagLists]);
sub.Authors = new Set([pubKey]);
sub.DTags = new Set([Lists.Followed]);
sub.Limit = 1;
return sub;
}, [pubKey]);
const subPinned = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = "login:pinned";
sub.Kinds = new Set([EventKind.NoteLists]);
sub.Authors = new Set([pubKey]);
sub.DTags = new Set([Lists.Pinned]);
sub.Limit = 1;
return sub;
}, [pubKey]);
const subBookmarks = useMemo(() => {
if (!pubKey) return null;
const sub = new Subscriptions();
sub.Id = "login:bookmarks";
sub.Kinds = new Set([EventKind.NoteLists]);
sub.Authors = new Set([pubKey]);
sub.DTags = new Set([Lists.Bookmarked]);
sub.Limit = 1;
return sub;
}, [pubKey]);
const subDms = useMemo(() => {
if (!pubKey) return null;
const dms = new Subscriptions();
dms.Id = "login:dms";
dms.Kinds = new Set([EventKind.DirectMessage]);
dms.PTags = new Set([pubKey]);
const dmsFromME = new Subscriptions();
dmsFromME.Authors = new Set([pubKey]);
dmsFromME.Kinds = new Set([EventKind.DirectMessage]);
dms.AddSubscription(dmsFromME);
return dms;
}, [pubKey]);
const metadataFeed = useSubscription(subMetadata, {
leaveOpen: true,
cache: true,
});
const notificationFeed = useSubscription(subNotification, {
leaveOpen: true,
cache: true,
});
const dmsFeed = useSubscription(subDms, { leaveOpen: true, cache: true });
const mutedFeed = useSubscription(subMuted, { leaveOpen: true, cache: true });
const pinnedFeed = useSubscription(subPinned, { leaveOpen: true, cache: true });
const tagsFeed = useSubscription(subTags, { leaveOpen: true, cache: true });
const bookmarkFeed = useSubscription(subBookmarks, { leaveOpen: true, cache: true });
useEffect(() => {
const contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList);
for (const cl of contactList) {
if (cl.content !== "" && cl.content !== "{}") {
const relays = JSON.parse(cl.content);
dispatch(setRelays({ relays, createdAt: cl.created_at }));
}
const pTags = cl.tags.filter(a => a[0] === "p").map(a => a[1]);
dispatch(setFollows({ keys: pTags, createdAt: cl.created_at }));
}
}, [dispatch, metadataFeed.store]);
useEffect(() => {
const replies = notificationFeed.store.notes.filter(
a => a.kind === EventKind.TextNote && !isMuted(a.pubkey) && a.created_at > readNotifications
);
replies.forEach(nx => {
dispatch(setLatestNotifications(nx.created_at));
makeNotification(nx).then(notification => {
if (notification) {
(dispatch as ThunkDispatch<RootState, undefined, AnyAction>)(sendNotification(notification));
}
});
const b = new RequestBuilder("login");
b.withOptions({
leaveOpen: true,
});
}, [dispatch, notificationFeed.store, readNotifications]);
b.withFilter().authors([pubKey]).kinds([EventKind.ContactList, EventKind.DirectMessage]);
b.withFilter().kinds([EventKind.TextNote]).tag("p", [pubKey]).limit(1);
b.withFilter().kinds([EventKind.DirectMessage]).tag("p", [pubKey]);
return b;
}, [pubKey]);
const subLists = useMemo(() => {
if (!pubKey) return null;
const b = new RequestBuilder("login:lists");
b.withOptions({
leaveOpen: true,
});
b.withFilter()
.authors([pubKey])
.kinds([EventKind.PubkeyLists])
.tag("d", [Lists.Muted, Lists.Followed, Lists.Pinned, Lists.Bookmarked]);
return b;
}, [pubKey]);
const loginFeed = useRequestBuilder<FlatNoteStore>(FlatNoteStore, subLogin);
// update relays and follow lists
useEffect(() => {
const muted = getMutedKeys(mutedFeed.store.notes);
if (loginFeed.data) {
const contactList = getNewest(loginFeed.data.filter(a => a.kind === EventKind.ContactList));
if (contactList) {
if (contactList.content !== "" && contactList.content !== "{}") {
const relays = JSON.parse(contactList.content);
dispatch(setRelays({ relays, createdAt: contactList.created_at }));
}
const pTags = contactList.tags.filter(a => a[0] === "p").map(a => a[1]);
dispatch(setFollows({ keys: pTags, createdAt: contactList.created_at }));
}
const dms = loginFeed.data.filter(a => a.kind === EventKind.DirectMessage);
dispatch(addDirectMessage(dms));
}
}, [dispatch, loginFeed]);
// send out notifications
useEffect(() => {
if (loginFeed.data) {
const replies = loginFeed.data.filter(
a => a.kind === EventKind.TextNote && !isMuted(a.pubkey) && a.created_at > readNotifications
);
replies.forEach(nx => {
dispatch(setLatestNotifications(nx.created_at));
makeNotification(nx).then(notification => {
if (notification) {
(dispatch as ThunkDispatch<RootState, undefined, AnyAction>)(sendNotification(notification));
}
});
});
}
}, [dispatch, loginFeed, readNotifications]);
function handleMutedFeed(mutedFeed: TaggedRawEvent[]) {
const muted = getMutedKeys(mutedFeed);
dispatch(setMuted(muted));
const newest = getNewest(mutedFeed.store.notes);
const newest = getNewest(mutedFeed);
if (newest && newest.content.length > 0 && pubKey && newest.created_at > latestMuted) {
decryptBlocked(newest, pubKey, privKey)
.then(plaintext => {
@ -192,57 +125,64 @@ export default function useLoginFeed() {
})
.catch(error => console.warn(error));
}
}, [dispatch, mutedFeed.store]);
}
useEffect(() => {
const newest = getNewest(pinnedFeed.store.notes);
function handlePinnedFeed(pinnedFeed: TaggedRawEvent[]) {
const newest = getNewestEventTagsByKey(pinnedFeed, "e");
if (newest) {
const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === "e").map(p => p[1]);
dispatch(
setPinned({
keys,
createdAt: newest.created_at,
})
);
dispatch(setPinned(newest));
}
}, [dispatch, pinnedFeed.store]);
}
useEffect(() => {
const newest = getNewest(tagsFeed.store.notes);
function handleTagFeed(tagFeed: TaggedRawEvent[]) {
const newest = getNewestEventTagsByKey(tagFeed, "t");
if (newest) {
const tags = newest.tags.filter(p => p && p.length === 2 && p[0] === "t").map(p => p[1]);
dispatch(
setTags({
tags,
createdAt: newest.created_at,
tags: newest.keys,
createdAt: newest.createdAt,
})
);
}
}, [dispatch, tagsFeed.store]);
}
useEffect(() => {
const newest = getNewest(bookmarkFeed.store.notes);
function handleBookmarkFeed(bookmarkFeed: TaggedRawEvent[]) {
const newest = getNewestEventTagsByKey(bookmarkFeed, "e");
if (newest) {
const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === "e").map(p => p[1]);
dispatch(
setBookmarked({
keys,
createdAt: newest.created_at,
})
);
dispatch(setBookmarked(newest));
}
}, [dispatch, bookmarkFeed.store]);
}
const listsFeed = useRequestBuilder<FlatNoteStore>(FlatNoteStore, subLists);
useEffect(() => {
const dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage);
dispatch(addDirectMessage(dms));
}, [dispatch, dmsFeed.store]);
if (listsFeed.data) {
const getList = (evs: readonly TaggedRawEvent[], list: Lists) =>
evs.filter(a => unwrap(a.tags.find(b => b[0] === "d"))[1] === list);
const mutedFeed = getList(listsFeed.data, Lists.Muted);
handleMutedFeed(mutedFeed);
const pinnedFeed = getList(listsFeed.data, Lists.Pinned);
handlePinnedFeed(pinnedFeed);
const tagsFeed = getList(listsFeed.data, Lists.Followed);
handleTagFeed(tagsFeed);
const bookmarkFeed = getList(listsFeed.data, Lists.Bookmarked);
handleBookmarkFeed(bookmarkFeed);
}
}, [dispatch, listsFeed]);
/*const fRelays = useRelaysFeedFollows(follows);
useEffect(() => {
FollowsRelays.bulkSet(fRelays).catch(console.error);
}, [dispatch, fRelays]);*/
}
async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) {
const ev = new Event(raw);
if (pubKey && privKey) {
return await ev.DecryptData(raw.content, privKey, pubKey);
return await EventExt.decryptData(raw.content, privKey, pubKey);
} else {
return await barrierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content));
}

View File

@ -2,10 +2,11 @@ import { useMemo } from "react";
import { useSelector } from "react-redux";
import { getNewest } from "Util";
import { HexKey, TaggedRawEvent, Lists } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import useSubscription, { NoteStore } from "Feed/Subscription";
import { HexKey, TaggedRawEvent, Lists, EventKind } from "@snort/nostr";
import { RootState } from "State/Store";
import { ParameterizedReplaceableNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
export default function useMutedFeed(pubkey?: HexKey) {
const { publicKey, muted } = useSelector((s: RootState) => s.login);
@ -13,23 +14,19 @@ export default function useMutedFeed(pubkey?: HexKey) {
const sub = useMemo(() => {
if (isMe || !pubkey) return null;
const sub = new Subscriptions();
sub.Id = `muted:${pubkey.slice(0, 12)}`;
sub.Kinds = new Set([EventKind.PubkeyLists]);
sub.Authors = new Set([pubkey]);
sub.DTags = new Set([Lists.Muted]);
sub.Limit = 1;
return sub;
const b = new RequestBuilder(`muted:${pubkey.slice(0, 12)}`);
b.withFilter().authors([pubkey]).kinds([EventKind.PubkeyLists]).tag("d", [Lists.Muted]);
return b;
}, [pubkey]);
const mutedFeed = useSubscription(sub, { leaveOpen: false, cache: true });
const mutedFeed = useRequestBuilder<ParameterizedReplaceableNoteStore>(ParameterizedReplaceableNoteStore, sub);
const mutedList = useMemo(() => {
if (pubkey) {
return getMuted(mutedFeed.store, pubkey);
if (pubkey && mutedFeed.data) {
return getMuted(mutedFeed.data, pubkey);
}
return [];
}, [mutedFeed.store, pubkey]);
}, [mutedFeed, pubkey]);
return isMe ? muted : mutedList;
}
@ -50,7 +47,7 @@ export function getMutedKeys(rawNotes: TaggedRawEvent[]): {
return { createdAt: 0, keys: [] };
}
export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] {
const lists = feed?.notes.filter(a => a.kind === EventKind.PubkeyLists && a.pubkey === pubkey);
export function getMuted(feed: readonly TaggedRawEvent[], pubkey: HexKey): HexKey[] {
const lists = feed.filter(a => a.kind === EventKind.PubkeyLists && a.pubkey === pubkey);
return getMutedKeys(lists).keys;
}

View File

@ -2,7 +2,7 @@ import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { HexKey, Lists } from "@snort/nostr";
import useNotelistSubscription from "Feed/useNotelistSubscription";
import useNotelistSubscription from "Hooks/useNotelistSubscription";
export default function usePinnedFeed(pubkey?: HexKey) {
const { pinned } = useSelector((s: RootState) => s.login);

View File

@ -1,28 +1,26 @@
import { useMemo } from "react";
import { HexKey, FullRelaySettings } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import useSubscription from "./Subscription";
import { HexKey, FullRelaySettings, EventKind } from "@snort/nostr";
import { RequestBuilder } from "System";
import { ReplaceableNoteStore } from "System/NoteCollection";
import useRequestBuilder from "Hooks/useRequestBuilder";
export default function useRelaysFeed(pubkey?: HexKey) {
const sub = useMemo(() => {
if (!pubkey) return null;
const x = new Subscriptions();
x.Id = `relays:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ContactList]);
x.Authors = new Set([pubkey]);
x.Limit = 1;
return x;
const b = new RequestBuilder(`relays:${pubkey.slice(0, 12)}`);
b.withFilter().authors([pubkey]).kinds([EventKind.ContactList]);
return b;
}, [pubkey]);
const relays = useSubscription(sub, { leaveOpen: false, cache: false });
const eventContent = relays.store.notes[0]?.content;
const relays = useRequestBuilder<ReplaceableNoteStore>(ReplaceableNoteStore, sub);
if (!eventContent) {
if (!relays.data?.content) {
return [] as FullRelaySettings[];
}
try {
return Object.entries(JSON.parse(eventContent)).map(([url, settings]) => ({
return Object.entries(JSON.parse(relays.data.content)).map(([url, settings]) => ({
url,
settings,
})) as FullRelaySettings[];

View File

@ -0,0 +1,73 @@
import { useMemo } from "react";
import { HexKey, FullRelaySettings, TaggedRawEvent, RelaySettings, EventKind } from "@snort/nostr";
import { sanitizeRelayUrl } from "Util";
import { PubkeyReplaceableNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
type UserRelayMap = Record<HexKey, Array<FullRelaySettings>>;
export default function useRelaysFeedFollows(pubkeys: HexKey[]): UserRelayMap {
const sub = useMemo(() => {
const b = new RequestBuilder(`relays:follows`);
b.withFilter().authors(pubkeys).kinds([EventKind.Relays, EventKind.ContactList]);
return b;
}, [pubkeys]);
function mapFromRelays(notes: Array<TaggedRawEvent>): UserRelayMap {
return Object.fromEntries(
notes.map(ev => {
return [
ev.pubkey,
ev.tags
.map(a => {
return {
url: sanitizeRelayUrl(a[1]),
settings: {
read: a[2] === "read" || a[2] === undefined,
write: a[2] === "write" || a[2] === undefined,
},
} as FullRelaySettings;
})
.filter(a => a.url !== undefined),
];
})
);
}
function mapFromContactList(notes: Array<TaggedRawEvent>): UserRelayMap {
return Object.fromEntries(
notes.map(ev => {
if (ev.content !== "" && ev.content !== "{}" && ev.content.startsWith("{") && ev.content.endsWith("}")) {
try {
const relays: Record<string, RelaySettings> = JSON.parse(ev.content);
return [
ev.pubkey,
Object.entries(relays)
.map(([k, v]) => {
return {
url: sanitizeRelayUrl(k),
settings: v,
} as FullRelaySettings;
})
.filter(a => a.url !== undefined),
];
} catch {
// ignored
}
}
return [ev.pubkey, []];
})
);
}
const relays = useRequestBuilder<PubkeyReplaceableNoteStore>(PubkeyReplaceableNoteStore, sub);
const notesRelays = relays.data?.filter(a => a.kind === EventKind.Relays) ?? [];
const notesContactLists = relays.data?.filter(a => a.kind === EventKind.ContactList) ?? [];
return useMemo(() => {
return {
...mapFromContactList(notesContactLists),
...mapFromRelays(notesRelays),
} as UserRelayMap;
}, [relays]);
}

View File

@ -1,152 +0,0 @@
import { useEffect, useMemo, useReducer, useState } from "react";
import { TaggedRawEvent } from "@snort/nostr";
import { Subscriptions } from "@snort/nostr";
import { System } from "System";
import { debounce } from "Util";
export type NoteStore = {
notes: Array<TaggedRawEvent>;
end: boolean;
};
export type UseSubscriptionOptions = {
leaveOpen: boolean;
cache: boolean;
relay?: string;
};
interface ReducerArg {
type: "END" | "EVENT" | "CLEAR";
ev?: TaggedRawEvent | TaggedRawEvent[];
end?: boolean;
}
function notesReducer(state: NoteStore, arg: ReducerArg) {
if (arg.type === "END") {
return {
notes: state.notes,
end: arg.end ?? true,
} as NoteStore;
}
if (arg.type === "CLEAR") {
return {
notes: [],
end: state.end,
} as NoteStore;
}
let evs = arg.ev;
if (!(evs instanceof Array)) {
evs = evs === undefined ? [] : [evs];
}
const existingIds = new Set(state.notes.map(a => a.id));
evs = evs.filter(a => !existingIds.has(a.id));
if (evs.length === 0) {
return state;
}
return {
notes: [...state.notes, ...evs],
} as NoteStore;
}
const initStore: NoteStore = {
notes: [],
end: false,
};
export interface UseSubscriptionState {
store: NoteStore;
clear: () => void;
append: (notes: TaggedRawEvent[]) => void;
}
/**
* Wait time before returning changed state
*/
const DebounceMs = 200;
/**
*
* @param {Subscriptions} sub
* @param {any} opt
* @returns
*/
export default function useSubscription(
sub: Subscriptions | null,
options?: UseSubscriptionOptions
): UseSubscriptionState {
const [state, dispatch] = useReducer(notesReducer, initStore);
const [debounceOutput, setDebounceOutput] = useState<number>(0);
const [subDebounce, setSubDebounced] = useState<Subscriptions>();
useEffect(() => {
if (sub) {
return debounce(DebounceMs, () => {
setSubDebounced(sub);
});
}
}, [sub, options]);
useEffect(() => {
if (subDebounce) {
dispatch({
type: "END",
end: false,
});
subDebounce.OnEvent = e => {
dispatch({
type: "EVENT",
ev: e,
});
};
subDebounce.OnEnd = c => {
if (!(options?.leaveOpen ?? false)) {
c.RemoveSubscription(subDebounce.Id);
if (subDebounce.IsFinished()) {
System.RemoveSubscription(subDebounce.Id);
}
}
dispatch({
type: "END",
end: true,
});
};
const subObj = subDebounce.ToObject();
console.debug("Adding sub: ", subObj);
if (options?.relay) {
System.AddSubscriptionToRelay(subDebounce, options.relay);
} else {
System.AddSubscription(subDebounce);
}
return () => {
console.debug("Removing sub: ", subObj);
subDebounce.OnEvent = () => undefined;
System.RemoveSubscription(subDebounce.Id);
};
}
}, [subDebounce]);
useEffect(() => {
return debounce(DebounceMs, () => {
setDebounceOutput(s => (s += 1));
});
}, [state]);
const stateDebounced = useMemo(() => state, [debounceOutput]);
return {
store: stateDebounced,
clear: () => {
dispatch({ type: "CLEAR" });
},
append: (n: TaggedRawEvent[]) => {
dispatch({
type: "EVENT",
ev: n,
});
},
};
}

View File

@ -1,63 +1,51 @@
import { useEffect, useMemo, useState } from "react";
import { u256 } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import useSubscription from "Feed/Subscription";
import { useSelector } from "react-redux";
import { u256, EventKind } from "@snort/nostr";
import { RootState } from "State/Store";
import { UserPreferences } from "State/Login";
import { debounce, NostrLink } from "Util";
import { appendDedupe, debounce, NostrLink } from "Util";
import { FlatNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
export default function useThreadFeed(link: NostrLink) {
const [trackingEvents, setTrackingEvent] = useState<u256[]>([link.id]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
function addId(id: u256[]) {
setTrackingEvent(s => {
const orig = new Set(s);
if (id.some(a => !orig.has(a))) {
const tmp = new Set([...s, ...id]);
return Array.from(tmp);
} else {
return s;
}
});
}
const sub = useMemo(() => {
const thisSub = new Subscriptions();
thisSub.Id = `thread:${link.id.substring(0, 8)}`;
thisSub.Ids = new Set(trackingEvents);
const sub = new RequestBuilder(`thread:${link.id.substring(0, 8)}`);
sub.withOptions({
leaveOpen: true,
});
sub.withFilter().ids(trackingEvents);
sub
.withFilter()
.kinds(
pref.enableReactions
? [EventKind.Reaction, EventKind.TextNote, EventKind.Repost, EventKind.ZapReceipt]
: [EventKind.TextNote, EventKind.ZapReceipt]
)
.tag("e", trackingEvents);
// get replies to this event
const subRelated = new Subscriptions();
subRelated.Kinds = new Set(
pref.enableReactions
? [EventKind.Reaction, EventKind.TextNote, EventKind.Repost, EventKind.ZapReceipt]
: [EventKind.TextNote, EventKind.ZapReceipt]
);
subRelated.ETags = thisSub.Ids;
thisSub.AddSubscription(subRelated);
return thisSub;
return sub;
}, [trackingEvents, pref, link.id]);
const main = useSubscription(sub, { leaveOpen: true, cache: true });
const store = useRequestBuilder<FlatNoteStore>(FlatNoteStore, sub);
useEffect(() => {
if (main.store) {
return debounce(200, () => {
const mainNotes = main.store.notes.filter(a => a.kind === EventKind.TextNote);
if (store.data) {
return debounce(500, () => {
const mainNotes = store.data?.filter(a => a.kind === EventKind.TextNote) ?? [];
const eTags = mainNotes
.filter(a => a.kind === EventKind.TextNote)
.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1]))
.flat();
const ids = mainNotes.map(a => a.id);
const allEvents = new Set([...eTags, ...ids]);
addId(Array.from(allEvents));
const eTagsMissing = eTags.filter(a => !mainNotes.some(b => b.id === a));
setTrackingEvent(s => appendDedupe(s, eTagsMissing));
});
}
}, [main.store]);
}, [store]);
return main.store;
return store;
}

View File

@ -1,16 +1,19 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { u256 } from "@snort/nostr";
import { EventKind, Subscriptions } from "@snort/nostr";
import { unixNow, unwrap, tagFilterOfTextRepost } from "Util";
import useSubscription from "Feed/Subscription";
import { useSelector } from "react-redux";
import { EventKind, u256 } from "@snort/nostr";
import { unixNow, unwrap, tagFilterOfTextRepost } from "Util";
import { RootState } from "State/Store";
import { UserPreferences } from "State/Login";
import { FlatNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
import useTimelineWindow from "Hooks/useTimelineWindow";
export interface TimelineFeedOptions {
method: "TIME_RANGE" | "LIMIT_UNTIL";
window?: number;
relay?: string;
now?: number;
}
export interface TimelineSubject {
@ -19,142 +22,141 @@ export interface TimelineSubject {
items: string[];
}
export type TimelineFeed = ReturnType<typeof useTimelineFeed>;
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
const now = unixNow();
const [window] = useState<number>(options.window ?? 60 * 60);
const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window);
const { now, since, until, older, setUntil } = useTimelineWindow({
window: options.window,
now: options.now ?? unixNow(),
});
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const createSub = useCallback(() => {
const createBuilder = useCallback(() => {
if (subject.type !== "global" && subject.items.length === 0) {
return null;
}
const sub = new Subscriptions();
sub.Id = `timeline:${subject.type}:${subject.discriminator}`;
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
const b = new RequestBuilder(`timeline:${subject.type}:${subject.discriminator}`);
const f = b.withFilter().kinds([EventKind.TextNote, EventKind.Repost]);
if (options.relay) {
b.withOptions({
leaveOpen: false,
relays: [options.relay],
});
}
switch (subject.type) {
case "pubkey": {
sub.Authors = new Set(subject.items);
f.authors(subject.items);
break;
}
case "hashtag": {
sub.HashTags = new Set(subject.items);
f.tag("t", subject.items);
break;
}
case "ptag": {
sub.PTags = new Set(subject.items);
f.tag("p", subject.items);
break;
}
case "keyword": {
sub.Kinds.add(EventKind.SetMetadata);
sub.Search = subject.items[0];
f.search(subject.items[0]);
break;
}
}
return sub;
}, [subject.type, subject.items, subject.discriminator, options.relay]);
return {
builder: b,
filter: f,
};
}, [subject.type, subject.items, subject.discriminator]);
const sub = useMemo(() => {
const sub = createSub();
if (sub) {
const rb = createBuilder();
if (rb) {
if (options.method === "LIMIT_UNTIL") {
sub.Until = until;
sub.Limit = 10;
rb.filter.until(until).limit(10);
} else {
sub.Since = since;
sub.Until = until;
rb.filter.since(since).until(until);
if (since === undefined) {
sub.Limit = 50;
rb.filter.limit(50);
}
}
if (pref.autoShowLatest) {
// copy properties of main sub but with limit 0
// this will put latest directly into main feed
const latestSub = new Subscriptions();
latestSub.Authors = sub.Authors;
latestSub.HashTags = sub.HashTags;
latestSub.PTags = sub.PTags;
latestSub.Kinds = sub.Kinds;
latestSub.Search = sub.Search;
latestSub.Limit = 1;
latestSub.Since = Math.floor(new Date().getTime() / 1000);
sub.AddSubscription(latestSub);
rb.builder
.withOptions({
leaveOpen: true,
})
.withFilter()
.authors(rb.filter.filter.authors)
.kinds(rb.filter.filter.kinds)
.tag("p", rb.filter.filter["#p"])
.tag("t", rb.filter.filter["#t"])
.search(rb.filter.filter.search)
.limit(1)
.since(now);
}
}
return sub;
}, [until, since, options.method, pref, createSub]);
return rb?.builder ?? null;
}, [until, since, options.method, pref, createBuilder]);
const main = useSubscription(sub, { leaveOpen: true, cache: subject.type !== "global", relay: options.relay });
const main = useRequestBuilder<FlatNoteStore>(FlatNoteStore, sub);
const subRealtime = useMemo(() => {
const subLatest = createSub();
if (subLatest && !pref.autoShowLatest) {
subLatest.Id = `${subLatest.Id}:latest`;
subLatest.Limit = 1;
subLatest.Since = Math.floor(new Date().getTime() / 1000);
const rb = createBuilder();
if (rb && !pref.autoShowLatest) {
rb.builder.withOptions({
leaveOpen: true,
});
rb.builder.id = `${rb.builder.id}:latest`;
rb.filter.limit(1).since(now);
}
return subLatest;
}, [pref, createSub]);
return rb?.builder ?? null;
}, [pref.autoShowLatest, createBuilder]);
const latest = useSubscription(subRealtime, {
leaveOpen: true,
cache: false,
relay: options.relay,
});
const latest = useRequestBuilder<FlatNoteStore>(FlatNoteStore, subRealtime);
useEffect(() => {
// clear store if chaning relays
main.clear();
latest.clear();
// clear store if changing relays
main.store.clear();
latest.store.clear();
}, [options.relay]);
const subNext = useMemo(() => {
let sub: Subscriptions | undefined;
const rb = new RequestBuilder(`timeline-related:${subject.type}`);
if (trackingEvents.length > 0) {
sub = new Subscriptions();
sub.Id = `timeline-related:${subject.type}`;
sub.Kinds = new Set(
pref.enableReactions ? [EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt] : [EventKind.ZapReceipt]
);
sub.ETags = new Set(trackingEvents);
rb.withFilter()
.kinds(
pref.enableReactions ? [EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt] : [EventKind.ZapReceipt]
)
.tag("e", trackingEvents);
}
return sub ?? null;
if (trackingParentEvents.length > 0) {
rb.withFilter().ids(trackingParentEvents);
}
return rb.numFilters > 0 ? rb : null;
}, [trackingEvents, pref, subject.type]);
const others = useSubscription(subNext, { leaveOpen: true, cache: subject.type !== "global", relay: options.relay });
const subParents = useMemo(() => {
if (trackingParentEvents.length > 0) {
const parents = new Subscriptions();
parents.Id = `timeline-parent:${subject.type}`;
parents.Ids = new Set(trackingParentEvents);
return parents;
}
return null;
}, [trackingParentEvents, subject.type]);
const parent = useSubscription(subParents, { leaveOpen: false, cache: false, relay: options.relay });
const related = useRequestBuilder<FlatNoteStore>(FlatNoteStore, subNext);
useEffect(() => {
if (main.store.notes.length > 0) {
if (main.data && main.data.length > 0) {
setTrackingEvent(s => {
const ids = main.store.notes.map(a => a.id);
const ids = (main.data ?? []).map(a => a.id);
if (ids.some(a => !s.includes(a))) {
return Array.from(new Set([...s, ...ids]));
}
return s;
});
const repostsByKind6 = main.store.notes
const repostsByKind6 = main.data
.filter(a => a.kind === EventKind.Repost && a.content === "")
.map(a => a.tags.find(b => b[0] === "e"))
.filter(a => a)
.map(a => unwrap(a)[1]);
const repostsByKind1 = main.store.notes
const repostsByKind1 = main.data
.filter(
a => (a.kind === EventKind.Repost || a.kind === EventKind.TextNote) && a.tags.some(tagFilterOfTextRepost(a))
)
@ -172,26 +174,29 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
});
}
}
}, [main.store]);
}, [main]);
return {
main: main.store,
related: others.store,
latest: latest.store,
parent: parent.store,
main: main.data,
related: related.data,
latest: latest.data,
loading: main.store.loading,
loadMore: () => {
console.debug("Timeline load more!");
if (options.method === "LIMIT_UNTIL") {
const oldest = main.store.notes.reduce((acc, v) => (acc = v.created_at < acc ? v.created_at : acc), unixNow());
setUntil(oldest);
} else {
setUntil(s => s - window);
setSince(s => s - window);
if (main.data) {
console.debug("Timeline load more!");
if (options.method === "LIMIT_UNTIL") {
const oldest = main.data.reduce((acc, v) => (acc = v.created_at < acc ? v.created_at : acc), unixNow());
setUntil(oldest);
} else {
older();
}
}
},
showLatest: () => {
main.append(latest.store.notes);
latest.clear();
if (latest.data) {
main.store.add(latest.data);
latest.store.clear();
}
},
};
}

View File

@ -1,26 +1,29 @@
import { useMemo } from "react";
import { HexKey, EventKind, Subscriptions } from "@snort/nostr";
import { HexKey, EventKind } from "@snort/nostr";
import { parseZap } from "Element/Zap";
import useSubscription from "./Subscription";
import { FlatNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
export default function useZapsFeed(pubkey?: HexKey) {
const sub = useMemo(() => {
if (!pubkey) return null;
const x = new Subscriptions();
x.Id = `zaps:${pubkey.slice(0, 12)}`;
x.Kinds = new Set([EventKind.ZapReceipt]);
x.PTags = new Set([pubkey]);
return x;
const b = new RequestBuilder(`zaps:${pubkey.slice(0, 12)}`);
b.withFilter().tag("p", [pubkey]).kinds([EventKind.ZapReceipt]);
return b;
}, [pubkey]);
const zapsFeed = useSubscription(sub, { leaveOpen: false, cache: true });
const zapsFeed = useRequestBuilder<FlatNoteStore>(FlatNoteStore, sub);
const zaps = useMemo(() => {
const profileZaps = zapsFeed.store.notes
.map(parseZap)
.filter(z => z.valid && z.receiver === pubkey && z.sender !== pubkey && !z.event);
profileZaps.sort((a, b) => b.amount - a.amount);
return profileZaps;
if (zapsFeed.data) {
const profileZaps = zapsFeed.data
.map(parseZap)
.filter(z => z.valid && z.receiver === pubkey && z.sender !== pubkey && !z.event);
profileZaps.sort((a, b) => b.amount - a.amount);
return profileZaps;
}
return [];
}, [zapsFeed]);
return zaps;

View File

@ -1,62 +0,0 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { getNewest } from "Util";
import { HexKey, Lists, EventKind, Subscriptions } from "@snort/nostr";
import useSubscription from "Feed/Subscription";
import { RootState } from "State/Store";
export default function useNotelistSubscription(pubkey: HexKey | undefined, l: Lists, defaultIds: HexKey[]) {
const { preferences, publicKey } = useSelector((s: RootState) => s.login);
const isMe = publicKey === pubkey;
const sub = useMemo(() => {
if (isMe || !pubkey) return null;
const sub = new Subscriptions();
sub.Id = `note-list-${l}:${pubkey.slice(0, 12)}`;
sub.Kinds = new Set([EventKind.NoteLists]);
sub.Authors = new Set([pubkey]);
sub.DTags = new Set([l]);
sub.Limit = 1;
return sub;
}, [pubkey]);
const { store } = useSubscription(sub, { leaveOpen: true, cache: true });
const etags = useMemo(() => {
if (isMe) return defaultIds;
const newest = getNewest(store.notes);
if (newest) {
const { tags } = newest;
return tags.filter(t => t[0] === "e").map(t => t[1]);
}
return [];
}, [store.notes, isMe, defaultIds]);
const esub = useMemo(() => {
if (!pubkey) return null;
const s = new Subscriptions();
s.Id = `${l}-notes:${pubkey.slice(0, 12)}`;
s.Kinds = new Set([EventKind.TextNote]);
s.Ids = new Set(etags);
return s;
}, [etags, pubkey]);
const subRelated = useMemo(() => {
let sub: Subscriptions | undefined;
if (etags.length > 0 && preferences.enableReactions) {
sub = new Subscriptions();
sub.Id = `${l}-related`;
sub.Kinds = new Set([EventKind.Reaction, EventKind.Repost, EventKind.Deletion, EventKind.ZapReceipt]);
sub.ETags = new Set(etags);
}
return sub ?? null;
}, [etags, preferences]);
const mainSub = useSubscription(esub, { leaveOpen: true, cache: true });
const relatedSub = useSubscription(subRelated, { leaveOpen: true, cache: true });
const notes = mainSub.store.notes.filter(e => etags.includes(e.id));
const related = relatedSub.store.notes;
return { notes, related };
}

View File

@ -0,0 +1,46 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { HexKey, Lists, EventKind } from "@snort/nostr";
import { RootState } from "State/Store";
import { FlatNoteStore, ParameterizedReplaceableNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
export default function useNotelistSubscription(pubkey: HexKey | undefined, l: Lists, defaultIds: HexKey[]) {
const { preferences, publicKey } = useSelector((s: RootState) => s.login);
const isMe = publicKey === pubkey;
const sub = useMemo(() => {
if (isMe || !pubkey) return null;
const rb = new RequestBuilder(`note-list-${l}:${pubkey.slice(0, 12)}`);
rb.withFilter().kinds([EventKind.NoteLists]).authors([pubkey]).tag("d", [l]).limit(1);
return rb;
}, [pubkey]);
const listStore = useRequestBuilder<ParameterizedReplaceableNoteStore>(ParameterizedReplaceableNoteStore, sub);
const etags = useMemo(() => {
if (isMe) return defaultIds;
// there should only be a single event here because we only load 1 pubkey
if (listStore.data && listStore.data.length > 0) {
return listStore.data[0].tags.filter(a => a[0] === "e").map(a => a[1]);
}
return [];
}, [listStore.data, isMe, defaultIds]);
const esub = useMemo(() => {
if (!pubkey || etags.length === 0) return null;
const s = new RequestBuilder(`${l}-notes:${pubkey.slice(0, 12)}`);
s.withFilter().kinds([EventKind.TextNote]).ids(etags);
if (etags.length > 0 && preferences.enableReactions) {
s.withFilter()
.kinds([EventKind.Reaction, EventKind.Repost, EventKind.Deletion, EventKind.ZapReceipt])
.tag("e", etags);
}
return s;
}, [etags, pubkey, preferences]);
const store = useRequestBuilder<FlatNoteStore>(FlatNoteStore, esub);
return store.data ?? [];
}

View File

@ -0,0 +1,65 @@
import { HexKey } from "@snort/nostr";
import { useMemo } from "react";
import { FollowsRelays } from "State/Relays";
import { unwrap } from "Util";
export type RelayPicker = ReturnType<typeof useRelaysForFollows>;
/**
* Number of relays to pick per pubkey
*/
const PickNRelays = 2;
export default function useRelaysForFollows(keys: Array<HexKey>) {
return useMemo(() => {
if (keys.length === 0) {
return {};
}
const allRelays = keys.map(a => {
return {
key: a,
relays: FollowsRelays.snapshot.get(a),
};
});
const missing = allRelays.filter(a => a.relays === undefined);
const hasRelays = allRelays.filter(a => a.relays !== undefined);
const relayUserMap = hasRelays.reduce((acc, v) => {
for (const r of unwrap(v.relays)) {
if (!acc.has(r.url)) {
acc.set(r.url, new Set([v.key]));
} else {
unwrap(acc.get(r.url)).add(v.key);
}
}
return acc;
}, new Map<string, Set<HexKey>>());
const topRelays = [...relayUserMap.entries()].sort(([, v], [, v1]) => v1.size - v.size);
// <relay, key[]> - count keys per relay
// <key, relay[]> - pick n top relays
// <relay, key[]> - map keys per relay (for subscription filter)
const userPickedRelays = keys.map(k => {
// pick top 3 relays for this key
const relaysForKey = topRelays
.filter(([, v]) => v.has(k))
.slice(0, PickNRelays)
.map(([k]) => k);
return { k, relaysForKey };
});
const pickedRelays = new Set(userPickedRelays.map(a => a.relaysForKey).flat());
const picked = Object.fromEntries(
[...pickedRelays].map(a => {
const keysOnPickedRelay = new Set(userPickedRelays.filter(b => b.relaysForKey.includes(a)).map(b => b.k));
return [a, [...keysOnPickedRelay]];
})
);
picked[""] = missing.map(a => a.key);
console.debug(picked);
return picked;
}, [keys]);
}

View File

@ -0,0 +1,50 @@
import { useSyncExternalStore } from "react";
import { RequestBuilder, System } from "System";
import { FlatNoteStore, NoteStore, StoreSnapshot } from "System/NoteCollection";
import { unwrap } from "Util";
const useRequestBuilder = <TStore extends NoteStore, TSnapshot = ReturnType<TStore["getSnapshotData"]>>(
type: { new (): TStore },
rb: RequestBuilder | null,
debounced?: number
) => {
const subscribe = (onChanged: () => void) => {
const store = System.Query<TStore>(type, rb);
let t: ReturnType<typeof setTimeout> | undefined;
const release = store.hook(() => {
if (!t) {
t = setTimeout(() => {
clearTimeout(t);
t = undefined;
onChanged();
}, debounced ?? 500);
}
});
return () => {
if (rb?.id) {
System.CancelQuery(rb.id);
}
release();
};
};
const emptyStore = {
data: undefined,
store: new FlatNoteStore(),
} as StoreSnapshot<TSnapshot>;
const getState = (): StoreSnapshot<TSnapshot> => {
if (rb?.id) {
const feed = System.GetFeed(rb.id);
if (feed) {
return unwrap(feed).snapshot as StoreSnapshot<TSnapshot>;
}
}
return emptyStore;
};
return useSyncExternalStore<StoreSnapshot<TSnapshot>>(
v => subscribe(v),
() => getState()
);
};
export default useRequestBuilder;

View File

@ -0,0 +1,9 @@
import { useSyncExternalStore } from "react";
import { System, SystemSnapshot } from "System";
export default function useSystemState() {
return useSyncExternalStore<SystemSnapshot>(
cb => System.hook(cb),
() => System.getSnapshot()
);
}

View File

@ -0,0 +1,18 @@
import { useState } from "react";
export default function useTimelineWindow(opt: { window?: number; now: number }) {
const [window] = useState(opt.window ?? 60 * 60 * 2);
const [until, setUntil] = useState(opt.now);
const [since, setSince] = useState(opt.now - window);
return {
now: opt.now,
since,
until,
setUntil,
older: () => {
setUntil(s => s - window);
setSince(s => s - window);
},
};
}

View File

@ -1,8 +1,9 @@
import { useEffect, useSyncExternalStore } from "react";
import { MetadataCache } from "State/Users";
import { HexKey } from "@snort/nostr";
import { System } from "System";
import { MetadataCache } from "State/Users";
import { UserCache } from "State/Users/UserCache";
import { ProfileLoader } from "System/ProfileCache";
export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined {
const user = useSyncExternalStore<MetadataCache | undefined>(
@ -12,8 +13,8 @@ export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined {
useEffect(() => {
if (pubKey) {
System.TrackMetadata(pubKey);
return () => System.UntrackMetadata(pubKey);
ProfileLoader.TrackMetadata(pubKey);
return () => ProfileLoader.UntrackMetadata(pubKey);
}
}, [pubKey]);

View File

@ -1,4 +1,4 @@
import { Event, HexKey } from "@snort/nostr";
import { HexKey, RawEvent } from "@snort/nostr";
import { EmailRegex } from "Const";
import { bech32ToText, unwrap } from "Util";
@ -63,7 +63,7 @@ export class LNURL {
* @param zap
* @returns
*/
async getInvoice(amount: number, comment?: string, zap?: Event) {
async getInvoice(amount: number, comment?: string, zap?: RawEvent) {
const callback = new URL(unwrap(this.#service?.callback));
const query = new Map<string, string>();
@ -81,7 +81,7 @@ export class LNURL {
query.set("comment", comment);
}
if (this.#service?.nostrPubkey && zap) {
query.set("nostr", JSON.stringify(zap.ToObject()));
query.set("nostr", JSON.stringify(zap));
}
const baseUrl = `${callback.protocol}//${callback.host}${callback.pathname}`;

View File

@ -1,17 +0,0 @@
import { useParams } from "react-router-dom";
import Thread from "Element/Thread";
import useThreadFeed from "Feed/ThreadFeed";
import { parseNostrLink, unwrap } from "Util";
export default function EventPage() {
const params = useParams();
const link = parseNostrLink(params.id ?? "");
const thread = useThreadFeed(unwrap(link));
if (link) {
return <Thread key={link.id} notes={thread.notes} selected={link.id} />;
} else {
return <b>{params.id}</b>;
}
}

View File

@ -2,8 +2,12 @@ import "./Layout.css";
import { useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { randomSample } from "Util";
import { RelaySettings } from "@snort/nostr";
import messages from "./messages";
import { bech32ToHex, randomSample } from "Util";
import Icon from "Icons/Icon";
import { RootState } from "State/Store";
import { init, setRelays } from "State/Login";
@ -11,34 +15,21 @@ import { System } from "System";
import ProfileImage from "Element/ProfileImage";
import useLoginFeed from "Feed/LoginFeed";
import { totalUnread } from "Pages/MessagesPage";
import { SearchRelays, SnortPubKey } from "Const";
import useEventPublisher from "Feed/EventPublisher";
import useModeration from "Hooks/useModeration";
import { bech32ToHex } from "Util";
import { NoteCreator } from "Element/NoteCreator";
import { RelaySettings } from "@snort/nostr";
import { FormattedMessage } from "react-intl";
import messages from "./messages";
import { db } from "Db";
import { UserCache } from "State/Users/UserCache";
import { FollowsRelays } from "State/Relays";
import useEventPublisher from "Feed/EventPublisher";
import { SnortPubKey } from "Const";
import SubDebug from "Element/SubDebug";
export default function Layout() {
const location = useLocation();
const [show, setShow] = useState(false);
const dispatch = useDispatch();
const navigate = useNavigate();
const {
loggedOut,
publicKey,
relays,
latestNotification,
readNotifications,
dms,
preferences,
newUserKey,
dmInteraction,
} = useSelector((s: RootState) => s.login);
const { isMuted } = useModeration();
const { loggedOut, publicKey, relays, preferences, newUserKey } = useSelector((s: RootState) => s.login);
const [pageClass, setPageClass] = useState("page");
const pub = useEventPublisher();
useLoginFeed();
@ -61,35 +52,22 @@ export default function Layout() {
}
}, [location]);
const hasNotifications = useMemo(
() => latestNotification > readNotifications,
[latestNotification, readNotifications]
);
const unreadDms = useMemo(
() =>
publicKey
? totalUnread(
dms.filter(a => !isMuted(a.pubkey)),
publicKey
)
: 0,
[dms, publicKey, dmInteraction]
);
useEffect(() => {
System.HandleAuth = pub.nip42Auth;
}, [pub]);
useEffect(() => {
if (relays) {
for (const [k, v] of Object.entries(relays)) {
System.ConnectToRelay(k, v);
}
for (const [k] of System.Sockets) {
if (!relays[k] && !SearchRelays.has(k)) {
System.DisconnectRelay(k);
(async () => {
for (const [k, v] of Object.entries(relays)) {
await System.ConnectToRelay(k, v);
}
}
for (const [k, c] of System.Sockets) {
if (!relays[k] && !c.Ephemeral) {
System.DisconnectRelay(k);
}
}
})();
}
}, [relays]);
@ -124,6 +102,7 @@ export default function Layout() {
db.ready = a;
if (a) {
await UserCache.preload();
await FollowsRelays.preload();
}
console.debug(`Using db: ${a ? "IndexedDB" : "In-Memory"}`);
dispatch(init());
@ -173,44 +152,6 @@ export default function Layout() {
}
}, [newUserKey]);
async function goToNotifications(e: React.MouseEvent) {
e.stopPropagation();
// request permissions to send notifications
if ("Notification" in window) {
try {
if (Notification.permission !== "granted") {
const res = await Notification.requestPermission();
console.debug(res);
}
} catch (e) {
console.error(e);
}
}
navigate("/notifications");
}
function accountHeader() {
return (
<div className="header-actions">
<div className="btn btn-rnd" onClick={() => navigate("/wallet")}>
<Icon name="bitcoin" />
</div>
<div className="btn btn-rnd" onClick={() => navigate("/search")}>
<Icon name="search" size={20} />
</div>
<div className="btn btn-rnd" onClick={() => navigate("/messages")}>
<Icon name="envelope" size={20} />
{unreadDms > 0 && <span className="has-unread"></span>}
</div>
<div className="btn btn-rnd" onClick={goToNotifications}>
<Icon name="bell" size={20} />
{hasNotifications && <span className="has-unread"></span>}
</div>
<ProfileImage pubkey={publicKey || ""} showUsername={false} />
</div>
);
}
if (typeof loggedOut !== "boolean") {
return null;
}
@ -223,7 +164,7 @@ export default function Layout() {
</div>
<div>
{publicKey ? (
accountHeader()
<AccountHeader />
) : (
<button type="button" onClick={() => navigate("/login")}>
<FormattedMessage {...messages.Login} />
@ -242,6 +183,65 @@ export default function Layout() {
<NoteCreator replyTo={undefined} autoFocus={true} show={show} setShow={setShow} />
</>
)}
{window.localStorage.getItem("debug") && <SubDebug />}
</div>
);
}
const AccountHeader = () => {
const navigate = useNavigate();
const { isMuted } = useModeration();
const { publicKey, latestNotification, readNotifications, dms } = useSelector((s: RootState) => s.login);
const hasNotifications = useMemo(
() => latestNotification > readNotifications,
[latestNotification, readNotifications]
);
const unreadDms = useMemo(
() =>
publicKey
? totalUnread(
dms.filter(a => !isMuted(a.pubkey)),
publicKey
)
: 0,
[dms, publicKey]
);
async function goToNotifications(e: React.MouseEvent) {
e.stopPropagation();
// request permissions to send notifications
if ("Notification" in window) {
try {
if (Notification.permission !== "granted") {
const res = await Notification.requestPermission();
console.debug(res);
}
} catch (e) {
console.error(e);
}
}
navigate("/notifications");
}
return (
<div className="header-actions">
<div className="btn btn-rnd" onClick={() => navigate("/wallet")}>
<Icon name="bitcoin" />
</div>
<div className="btn btn-rnd" onClick={() => navigate("/search")}>
<Icon name="search" />
</div>
<div className="btn btn-rnd" onClick={() => navigate("/messages")}>
<Icon name="envelope" />
{unreadDms > 0 && <span className="has-unread"></span>}
</div>
<div className="btn btn-rnd" onClick={goToNotifications}>
<Icon name="bell" />
{hasNotifications && <span className="has-unread"></span>}
</div>
<ProfileImage pubkey={publicKey || ""} showUsername={false} />
</div>
);
};

View File

@ -3,9 +3,9 @@ import { useEffect, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import { encodeTLV, NostrPrefix } from "@snort/nostr";
import { encodeTLV, EventKind, HexKey, NostrPrefix } from "@snort/nostr";
import { parseNostrLink, unwrap } from "Util";
import { parseNostrLink, getReactions, unwrap } from "Util";
import { formatShort } from "Number";
import Note from "Element/Note";
import Bookmarks from "Element/Bookmarks";
@ -57,13 +57,53 @@ const BLOCKED = 6;
const RELAYS = 7;
const BOOKMARKS = 8;
function ZapsProfileTab({ id }: { id: HexKey }) {
const zaps = useZapsFeed(id);
const zapsTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
return (
<div className="main-content">
<div className="zaps-total">
<FormattedMessage {...messages.Sats} values={{ n: formatShort(zapsTotal) }} />
</div>
{zaps.map(z => (
<ZapElement showZapped={false} zap={z} />
))}
</div>
);
}
function FollowersTab({ id }: { id: HexKey }) {
const followers = useFollowersFeed(id);
return <FollowsList pubkeys={followers} showAbout={true} />;
}
function FollowsTab({ id }: { id: HexKey }) {
const follows = useFollowsFeed(id);
return <FollowsList pubkeys={follows} showAbout={true} />;
}
function RelaysTab({ id }: { id: HexKey }) {
const relays = useRelaysFeed(id);
return <RelaysMetadata relays={relays} />;
}
function BookMarksTab({ id }: { id: HexKey }) {
const bookmarks = useBookmarkFeed(id);
return <Bookmarks pubkey={id} bookmarks={bookmarks} related={bookmarks} />;
}
export default function ProfilePage() {
const { formatMessage } = useIntl();
const params = useParams();
const navigate = useNavigate();
const [id, setId] = useState<string>();
const user = useUserProfile(id);
const loginPubKey = useSelector((s: RootState) => s.login.publicKey);
const { publicKey: loginPubKey, follows } = useSelector((s: RootState) => {
return {
publicKey: s.login.publicKey,
follows: s.login.follows,
};
});
const isMe = loginPubKey === id;
const [showLnQr, setShowLnQr] = useState<boolean>(false);
const [showProfileQr, setShowProfileQr] = useState<boolean>(false);
@ -80,34 +120,25 @@ export default function ProfilePage() {
user?.website && !user.website.startsWith("http") ? "https://" + user.website : user?.website || "";
// feeds
const { blocked } = useModeration();
const { notes: pinned, related: pinRelated } = usePinnedFeed(id);
const { notes: bookmarks, related: bookmarkRelated } = useBookmarkFeed(id);
const relays = useRelaysFeed(id);
const zaps = useZapsFeed(id);
const zapsTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const followers = useFollowersFeed(id);
const follows = useFollowsFeed(id);
const pinned = usePinnedFeed(id);
const muted = useMutedFeed(id);
const badges = useProfileBadges(id);
// tabs
const ProfileTab = {
Notes: { text: formatMessage(messages.Notes), value: NOTES },
Reactions: { text: formatMessage(messages.Reactions), value: REACTIONS },
Followers: { text: formatMessage(messages.FollowersCount, { n: followers.length }), value: FOLLOWERS },
Follows: { text: formatMessage(messages.FollowsCount, { n: follows.length }), value: FOLLOWS },
Zaps: { text: formatMessage(messages.ZapsCount, { n: zaps.length }), value: ZAPS },
Muted: { text: formatMessage(messages.MutedCount, { n: muted.length }), value: MUTED },
Followers: { text: formatMessage(messages.Followers), value: FOLLOWERS },
Follows: { text: formatMessage(messages.Follows), value: FOLLOWS },
Zaps: { text: formatMessage(messages.Zaps), value: ZAPS },
Muted: { text: formatMessage(messages.Muted), value: MUTED },
Blocked: { text: formatMessage(messages.BlockedCount, { n: blocked.length }), value: BLOCKED },
Relays: { text: formatMessage(messages.RelaysCount, { n: relays.length }), value: RELAYS },
Bookmarks: { text: formatMessage(messages.BookmarksCount, { n: bookmarks.length }), value: BOOKMARKS },
Relays: { text: formatMessage(messages.Relays), value: RELAYS },
Bookmarks: { text: formatMessage(messages.Bookmarks), value: BOOKMARKS },
};
const [tab, setTab] = useState<Tab>(ProfileTab.Notes);
const optionalTabs = [
zapsTotal > 0 && ProfileTab.Zaps,
relays.length > 0 && ProfileTab.Relays,
bookmarks.length > 0 && ProfileTab.Bookmarks,
muted.length > 0 && ProfileTab.Muted,
].filter(a => unwrap(a)) as Tab[];
const optionalTabs = [ProfileTab.Zaps, ProfileTab.Relays, ProfileTab.Bookmarks, ProfileTab.Muted].filter(a =>
unwrap(a)
) as Tab[];
const horizontalScroll = useHorizontalScroll();
useEffect(() => {
@ -190,16 +221,18 @@ export default function ProfilePage() {
return (
<>
<div className="main-content">
{pinned.map(n => {
return (
<Note
key={`pinned-${n.id}`}
data={n}
related={pinRelated}
options={{ showTime: false, showPinned: true, canUnpin: id === loginPubKey }}
/>
);
})}
{pinned
.filter(a => a.kind === EventKind.TextNote)
.map(n => {
return (
<Note
key={`pinned-${n.id}`}
data={n}
related={getReactions(pinned, n.id)}
options={{ showTime: false, showPinned: true, canUnpin: id === loginPubKey }}
/>
);
})}
</div>
<Timeline
key={id}
@ -216,23 +249,17 @@ export default function ProfilePage() {
</>
);
case ZAPS: {
return (
<div className="main-content">
<div className="zaps-total">
<FormattedMessage {...messages.Sats} values={{ n: formatShort(zapsTotal) }} />
</div>
{zaps.map(z => (
<ZapElement showZapped={false} zap={z} />
))}
</div>
);
return <ZapsProfileTab id={id} />;
}
case FOLLOWS: {
return <FollowsList pubkeys={follows} showFollowAll={!isMe} showAbout={!isMe} />;
if (isMe) {
return <FollowsList pubkeys={follows} showFollowAll={!isMe} showAbout={false} />;
} else {
return <FollowsTab id={id} />;
}
}
case FOLLOWERS: {
return <FollowsList pubkeys={followers} showAbout={true} />;
return <FollowersTab id={id} />;
}
case MUTED: {
return <MutedList pubkeys={muted} />;
@ -241,10 +268,10 @@ export default function ProfilePage() {
return <BlockList />;
}
case RELAYS: {
return <RelaysMetadata relays={relays} />;
return <RelaysTab id={id} />;
}
case BOOKMARKS: {
return <Bookmarks pubkey={id} bookmarks={bookmarks} related={bookmarkRelated} />;
return <BookMarksTab id={id} />;
}
}
}
@ -260,8 +287,7 @@ export default function ProfilePage() {
function renderIcons() {
if (!id) return;
const firstRelay = relays.find(a => a.settings.write)?.url;
const link = encodeTLV(id, NostrPrefix.Profile, firstRelay ? [firstRelay] : undefined);
const link = encodeTLV(id, NostrPrefix.Profile);
return (
<div className="icon-actions">
<IconButton onClick={() => setShowProfileQr(true)}>

View File

@ -1,7 +1,7 @@
import "./Root.css";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { Link } from "react-router-dom";
import { Link, Outlet, RouteObject, useLocation, useNavigate, useParams } from "react-router-dom";
import { useIntl, FormattedMessage } from "react-intl";
import Tabs, { Tab } from "Element/Tabs";
@ -9,7 +9,7 @@ import { RootState } from "State/Store";
import Timeline from "Element/Timeline";
import { System } from "System";
import { TimelineSubject } from "Feed/TimelineFeed";
import { debounce, getRelayName, sha256, unwrap } from "Util";
import { debounce, getRelayName, sha256, unixNow, unwrap } from "Util";
import messages from "./messages";
@ -20,58 +20,98 @@ interface RelayOption {
export default function RootPage() {
const { formatMessage } = useIntl();
const { loggedOut, publicKey: pubKey, follows, tags, relays, preferences } = useSelector((s: RootState) => s.login);
const navigate = useNavigate();
const location = useLocation();
const { publicKey: pubKey, tags, preferences } = useSelector((s: RootState) => s.login);
const RootTab: Record<string, Tab> = {
Posts: {
text: formatMessage(messages.Posts),
value: 0,
data: "/posts",
},
PostsAndReplies: {
text: formatMessage(messages.Conversations),
value: 1,
data: "/conversations",
},
Global: {
text: formatMessage(messages.Global),
value: 2,
data: "/global",
},
};
const [tab, setTab] = useState<Tab>(() => {
switch (preferences.defaultRootTab) {
case "conversations":
const tab = useMemo(() => {
const pTab = location.pathname.split("/").slice(-1)[0];
switch (pTab) {
case "conversations": {
return RootTab.PostsAndReplies;
case "global":
}
case "global": {
return RootTab.Global;
default:
}
default: {
return RootTab.Posts;
}
}
});
const [relay, setRelay] = useState<RelayOption>();
const [allRelays, setAllRelays] = useState<RelayOption[]>();
}, [location]);
useEffect(() => {
if (location.pathname === "/") {
navigate(unwrap(preferences.defaultRootTab ?? tab.data), {
replace: true,
});
}
}, [location]);
const tagTabs = tags.map((t, idx) => {
return { text: `#${t}`, value: idx + 3 };
return { text: `#${t}`, value: idx + 3, data: `/tag/${t}` };
});
const tabs = [RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global, ...tagTabs];
const isGlobal = loggedOut || tab.value === RootTab.Global.value;
function followHints() {
if (follows?.length === 0 && pubKey && tab !== RootTab.Global) {
return (
<FormattedMessage
{...messages.NoFollows}
values={{
newUsersPage: (
<Link to={"/new/discover"}>
<FormattedMessage {...messages.NewUsers} />
</Link>
),
}}
/>
);
}
return (
<>
<div className="main-content">
{pubKey && <Tabs tabs={tabs} tab={tab} setTab={t => navigate(unwrap(t.data))} />}
</div>
<Outlet />
</>
);
}
const FollowsHint = () => {
const { publicKey: pubKey, follows } = useSelector((s: RootState) => s.login);
if (follows?.length === 0 && pubKey) {
return (
<FormattedMessage
{...messages.NoFollows}
values={{
newUsersPage: (
<Link to={"/new/discover"}>
<FormattedMessage {...messages.NewUsers} />
</Link>
),
}}
/>
);
}
return null;
};
const GlobalTab = () => {
const { relays } = useSelector((s: RootState) => s.login);
const [relay, setRelay] = useState<RelayOption>();
const [allRelays, setAllRelays] = useState<RelayOption[]>();
const [now] = useState(unixNow());
const subject: TimelineSubject = {
type: "global",
items: [],
discriminator: `all-${sha256(relay?.url ?? "").slice(0, 12)}`,
};
function globalRelaySelector() {
if (!isGlobal || !allRelays || allRelays.length === 0) return null;
if (!allRelays || allRelays.length === 0) return null;
const paidRelays = allRelays.filter(a => a.paid);
const publicRelays = allRelays.filter(a => !a.paid);
@ -108,60 +148,80 @@ export default function RootPage() {
}
useEffect(() => {
if (isGlobal) {
return debounce(500, () => {
const ret: RelayOption[] = [];
System.Sockets.forEach((v, k) => {
ret.push({
url: k,
paid: v.Info?.limitation?.payment_required ?? false,
});
return debounce(500, () => {
const ret: RelayOption[] = [];
System.Sockets.forEach((v, k) => {
ret.push({
url: k,
paid: v.Info?.limitation?.payment_required ?? false,
});
ret.sort(a => (a.paid ? -1 : 1));
if (ret.length > 0 && !relay) {
setRelay(ret[0]);
}
setAllRelays(ret);
});
}
}, [relays, relay, tab]);
ret.sort(a => (a.paid ? -1 : 1));
const timelineSubect: TimelineSubject = (() => {
if (isGlobal) {
return { type: "global", items: [], discriminator: `all-${sha256(relay?.url ?? "").slice(0, 12)}` };
}
if (tab.value >= 3) {
const hashtag = tab.text.slice(1);
return { type: "hashtag", items: [hashtag], discriminator: hashtag };
}
return { type: "pubkey", items: follows, discriminator: "follows" };
})();
function renderTimeline() {
if (isGlobal && !relay) return null;
return (
<Timeline
key={tab.value}
subject={timelineSubect}
postsOnly={tab.value === RootTab.Posts.value}
method={"TIME_RANGE"}
window={undefined}
relay={isGlobal ? unwrap(relay).url : undefined}
/>
);
}
if (ret.length > 0 && !relay) {
setRelay(ret[0]);
}
setAllRelays(ret);
});
}, [relays, relay]);
return (
<>
<div className="main-content">
{pubKey && <Tabs tabs={tabs} tab={tab} setTab={setTab} />}
{globalRelaySelector()}
</div>
{followHints()}
{renderTimeline()}
{globalRelaySelector()}
{relay && (
<Timeline subject={subject} postsOnly={false} method={"TIME_RANGE"} window={600} relay={relay.url} now={now} />
)}
</>
);
}
};
const PostsTab = () => {
const follows = useSelector((s: RootState) => s.login.follows);
const subject: TimelineSubject = { type: "pubkey", items: follows, discriminator: "follows" };
return (
<>
<FollowsHint />
<Timeline subject={subject} postsOnly={true} method={"TIME_RANGE"} window={undefined} relay={undefined} />
</>
);
};
const ConversationsTab = () => {
const { follows } = useSelector((s: RootState) => s.login);
const subject: TimelineSubject = { type: "pubkey", items: follows, discriminator: "follows" };
return <Timeline subject={subject} postsOnly={false} method={"TIME_RANGE"} window={undefined} relay={undefined} />;
};
const TagsTab = () => {
const { tag } = useParams();
const subject: TimelineSubject = { type: "hashtag", items: [tag ?? ""], discriminator: `tags-${tag}` };
return <Timeline subject={subject} postsOnly={false} method={"TIME_RANGE"} window={undefined} relay={undefined} />;
};
export const RootRoutes = [
{
path: "/",
element: <RootPage />,
children: [
{
path: "global",
element: <GlobalTab />,
},
{
path: "posts",
element: <PostsTab />,
},
{
path: "conversations",
element: <ConversationsTab />,
},
{
path: "tag/:tag",
element: <TagsTab />,
},
],
},
] as RouteObject[];

View File

@ -401,23 +401,6 @@ const PreferencesPage = () => {
/>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>
<FormattedMessage {...messages.RewriteTwitterPosts} />
</div>
<small>
<FormattedMessage {...messages.RewriteTwitterPostsHelp} />
</small>
</div>
<div>
<input
type="checkbox"
checked={perf.rewriteTwitterPosts}
onChange={e => dispatch(setPreferences({ ...perf, rewriteTwitterPosts: e.target.checked }))}
/>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>

View File

@ -75,18 +75,23 @@ const RelayInfo = () => {
</h4>
<div className="f-grow">
{stats.info.supported_nips.map(a => (
<span
key={a}
className="pill"
onClick={() =>
navigate(`https://github.com/nostr-protocol/nips/blob/master/${a.toString().padStart(2, "0")}.md`)
}>
<a
target="_blank"
rel="noreferrer"
href={`https://github.com/nostr-protocol/nips/blob/master/${a.toString().padStart(2, "0")}.md`}
className="pill">
NIP-{a.toString().padStart(2, "0")}
</span>
</a>
))}
</div>
</>
)}
<h4>
<FormattedMessage defaultMessage="Active Subscriptions" />
</h4>
<div className="f-grow">
<span className="pill">TBD</span>
</div>
<div className="flex mt10 f-end">
<div
className="btn error"

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
@ -6,17 +6,21 @@ import { randomSample } from "Util";
import Relay from "Element/Relay";
import useEventPublisher from "Feed/EventPublisher";
import { RootState } from "State/Store";
import { RelaySettings } from "@snort/nostr";
import { setRelays } from "State/Login";
import { System } from "System";
import messages from "./messages";
const RelaySettingsPage = () => {
const dispatch = useDispatch();
const publisher = useEventPublisher();
const relays = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const relays = useSelector((s: RootState) => s.login.relays);
const [newRelay, setNewRelay] = useState<string>();
const otherConnections = useMemo(() => {
return [...System.Sockets.keys()].filter(a => relays[a] === undefined);
}, [relays]);
async function saveRelays() {
const ev = await publisher.saveRelays();
publisher.broadcast(ev);
@ -84,6 +88,14 @@ const RelaySettingsPage = () => {
</button>
</div>
{addRelay()}
<h3>
<FormattedMessage defaultMessage="Other Connections" />
</h3>
<div className="flex f-col mb10">
{otherConnections.map(a => (
<Relay addr={a} key={a} />
))}
</div>
</>
);
};

View File

@ -61,6 +61,4 @@ export default defineMessages({
Nip05: { defaultMessage: "NIP-05" },
ReactionEmoji: { defaultMessage: "Reaction emoji" },
ReactionEmojiHelp: { defaultMessage: "Emoji to send when reactiong to a note" },
RewriteTwitterPosts: { defaultMessage: "Nitter Rewrite" },
RewriteTwitterPostsHelp: { defaultMessage: "Rewrite Twitter links to Nitter links" },
});

View File

@ -5,6 +5,7 @@ import { HexKey, TaggedRawEvent } from "@snort/nostr";
import { RelaySettings } from "@snort/nostr";
import type { AppDispatch, RootState } from "State/Store";
import { ImgProxySettings } from "Hooks/useImgProxy";
import { sanitizeRelayUrl } from "Util";
const PrivateKeyItem = "secret";
const PublicKeyItem = "pubkey";
@ -51,11 +52,6 @@ export interface UserPreferences {
*/
confirmReposts: boolean;
/**
* Rewrite Twitter links to Nitter links
*/
rewriteTwitterPosts: boolean;
/**
* Automatically show the latests notes
*/
@ -250,7 +246,6 @@ export const InitState = {
confirmReposts: false,
showDebugMenus: false,
autoShowLatest: false,
rewriteTwitterPosts: false,
fileUploader: "void.cat",
imgProxyConfig: DefaultImgProxy,
defaultRootTab: "posts",
@ -364,7 +359,10 @@ const LoginSlice = createSlice({
const filtered = new Map<string, RelaySettings>();
for (const [k, v] of Object.entries(relays)) {
if (k.startsWith("wss://") || k.startsWith("ws://")) {
filtered.set(k, v as RelaySettings);
const url = sanitizeRelayUrl(k);
if (url) {
filtered.set(url, v as RelaySettings);
}
}
}

View File

@ -0,0 +1,83 @@
import { FullRelaySettings, HexKey } from "@snort/nostr";
import { db } from "Db";
import { unixNowMs, unwrap } from "Util";
export class UserRelays {
#store: Map<HexKey, Array<FullRelaySettings>>;
#snapshot: Readonly<Map<HexKey, Array<FullRelaySettings>>>;
constructor() {
this.#store = new Map();
this.#snapshot = Object.freeze(new Map());
}
get snapshot() {
return this.#snapshot;
}
async get(key: HexKey) {
if (!this.#store.has(key) && db.ready) {
const cached = await db.userRelays.get(key);
if (cached) {
this.#store.set(key, cached.relays);
return cached.relays;
}
}
return this.#store.get(key);
}
async bulkGet(keys: Array<HexKey>) {
const missing = keys.filter(a => !this.#store.has(a));
if (missing.length > 0 && db.ready) {
const cached = await db.userRelays.bulkGet(missing);
cached.forEach(a => {
if (a) {
this.#store.set(a.pubkey, a.relays);
}
});
}
return new Map(keys.map(a => [a, this.#store.get(a) ?? []]));
}
async set(key: HexKey, relays: Array<FullRelaySettings>) {
this.#store.set(key, relays);
if (db.ready) {
await db.userRelays.put({
pubkey: key,
relays,
});
}
this._update();
}
async bulkSet(obj: Record<HexKey, Array<FullRelaySettings>>) {
if (db.ready) {
await db.userRelays.bulkPut(
Object.entries(obj).map(([k, v]) => {
return {
pubkey: k,
relays: v,
};
})
);
}
Object.entries(obj).forEach(([k, v]) => this.#store.set(k, v));
this._update();
}
async preload() {
const start = unixNowMs();
const keys = await db.userRelays.toCollection().keys();
const fullCache = await db.userRelays.bulkGet(keys);
this.#store = new Map(fullCache.filter(a => a !== undefined).map(a => [unwrap(a).pubkey, a?.relays ?? []]));
this._update();
console.debug(`Preloaded ${this.#store.size} users relays in ${(unixNowMs() - start).toLocaleString()} ms`);
}
private _update() {
this.#snapshot = Object.freeze(new Map(this.#store));
}
}
export const FollowsRelays = new UserRelays();

View File

@ -1,226 +0,0 @@
import {
AuthHandler,
HexKey,
TaggedRawEvent,
Event as NEvent,
EventKind,
RelaySettings,
Connection,
Subscriptions,
} from "@snort/nostr";
import { ProfileCacheExpire } from "Const";
import { mapEventToProfile, MetadataCache } from "State/Users";
import { UserCache } from "State/Users/UserCache";
import { unixNowMs, unwrap } from "Util";
/**
* Manages nostr content retrieval system
*/
export class NostrSystem {
/**
* All currently connected websockets
*/
Sockets: Map<string, Connection>;
/**
* All active subscriptions
*/
Subscriptions: Map<string, Subscriptions>;
/**
* Pending subscriptions to send when sockets become open
*/
PendingSubscriptions: Subscriptions[];
/**
* List of pubkeys to fetch metadata for
*/
WantsMetadata: Set<HexKey>;
/**
* Handler function for NIP-42
*/
HandleAuth?: AuthHandler;
constructor() {
this.Sockets = new Map();
this.Subscriptions = new Map();
this.PendingSubscriptions = [];
this.WantsMetadata = new Set();
this._FetchMetadata();
}
/**
* Connect to a NOSTR relay if not already connected
*/
async ConnectToRelay(address: string, options: RelaySettings) {
try {
if (!this.Sockets.has(address)) {
const c = new Connection(address, options, this.HandleAuth);
await c.Connect();
this.Sockets.set(address, c);
for (const [, s] of this.Subscriptions) {
c.AddSubscription(s);
}
} else {
// update settings if already connected
unwrap(this.Sockets.get(address)).Settings = options;
}
} catch (e) {
console.error(e);
}
}
/**
* Disconnect from a relay
*/
DisconnectRelay(address: string) {
const c = this.Sockets.get(address);
if (c) {
this.Sockets.delete(address);
c.Close();
}
}
AddSubscriptionToRelay(sub: Subscriptions, relay: string) {
this.Sockets.get(relay)?.AddSubscription(sub);
}
AddSubscription(sub: Subscriptions) {
for (const [, s] of this.Sockets) {
s.AddSubscription(sub);
}
this.Subscriptions.set(sub.Id, sub);
}
RemoveSubscription(subId: string) {
for (const [, s] of this.Sockets) {
s.RemoveSubscription(subId);
}
this.Subscriptions.delete(subId);
}
/**
* Send events to writable relays
*/
BroadcastEvent(ev: NEvent) {
for (const [, s] of this.Sockets) {
s.SendEvent(ev);
}
}
/**
* Write an event to a relay then disconnect
*/
async WriteOnceToRelay(address: string, ev: NEvent) {
const c = new Connection(address, { write: true, read: false }, this.HandleAuth);
await c.Connect();
await c.SendAsync(ev);
c.Close();
}
/**
* Request profile metadata for a set of pubkeys
*/
TrackMetadata(pk: HexKey | Array<HexKey>) {
for (const p of Array.isArray(pk) ? pk : [pk]) {
if (p.length > 0) {
this.WantsMetadata.add(p);
}
}
}
/**
* Stop tracking metadata for a set of pubkeys
*/
UntrackMetadata(pk: HexKey | Array<HexKey>) {
for (const p of Array.isArray(pk) ? pk : [pk]) {
if (p.length > 0) {
this.WantsMetadata.delete(p);
}
}
}
/**
* Request/Response pattern
*/
RequestSubscription(sub: Subscriptions, timeout?: number) {
return new Promise<TaggedRawEvent[]>(resolve => {
const events: TaggedRawEvent[] = [];
// force timeout returning current results
const t = setTimeout(() => {
this.RemoveSubscription(sub.Id);
resolve(events);
}, timeout ?? 10_000);
const onEventPassthrough = sub.OnEvent;
sub.OnEvent = ev => {
if (typeof onEventPassthrough === "function") {
onEventPassthrough(ev);
}
if (!events.some(a => a.id === ev.id)) {
events.push(ev);
} else {
const existing = events.find(a => a.id === ev.id);
if (existing) {
for (const v of ev.relays) {
existing.relays.push(v);
}
}
}
};
sub.OnEnd = c => {
c.RemoveSubscription(sub.Id);
if (sub.IsFinished()) {
clearInterval(t);
console.debug(`[${sub.Id}] Finished`);
resolve(events);
}
};
this.AddSubscription(sub);
});
}
async _FetchMetadata() {
const missingFromCache = await UserCache.buffer([...this.WantsMetadata]);
const expire = unixNowMs() - ProfileCacheExpire;
const expired = [...this.WantsMetadata]
.filter(a => !missingFromCache.includes(a))
.filter(a => (UserCache.get(a)?.loaded ?? 0) < expire);
const missing = new Set([...missingFromCache, ...expired]);
if (missing.size > 0) {
console.debug(`Wants profiles: ${missingFromCache.length} missing, ${expired.length} expired`);
const sub = new Subscriptions();
sub.Id = `profiles:${sub.Id.slice(0, 8)}`;
sub.Kinds = new Set([EventKind.SetMetadata]);
sub.Authors = missing;
sub.OnEvent = async e => {
const profile = mapEventToProfile(e);
if (profile) {
await UserCache.update(profile);
}
};
const results = await this.RequestSubscription(sub, 5_000);
const couldNotFetch = [...missing].filter(a => !results.some(b => b.pubkey === a));
if (couldNotFetch.length > 0) {
console.debug("No profiles: ", couldNotFetch);
const empty = couldNotFetch.map(a =>
UserCache.update({
pubkey: a,
loaded: unixNowMs(),
created: 69,
} as MetadataCache)
);
await Promise.all(empty);
}
}
setTimeout(() => this._FetchMetadata(), 500);
}
}
export const System = new NostrSystem();

View File

@ -0,0 +1,165 @@
import * as secp from "@noble/secp256k1";
import { EventKind, HexKey, RawEvent, Tag } from "@snort/nostr";
import base64 from "@protobufjs/base64";
import { sha256, unixNow } from "Util";
export interface Thread {
root?: Tag;
replyTo?: Tag;
mentions: Array<Tag>;
pubKeys: Array<HexKey>;
}
export abstract class EventExt {
/**
* Get the pub key of the creator of this event NIP-26
*/
static getRootPubKey(e: RawEvent): HexKey {
const delegation = e.tags.find(a => a[0] === "delegation");
if (delegation?.[1]) {
return delegation[1];
}
return e.pubkey;
}
/**
* Sign this message with a private key
*/
static async sign(e: RawEvent, key: HexKey) {
e.id = await this.createId(e);
const sig = await secp.schnorr.sign(e.id, key);
e.sig = secp.utils.bytesToHex(sig);
if (!(await secp.schnorr.verify(e.sig, e.id, e.pubkey))) {
throw new Error("Signing failed");
}
}
/**
* Check the signature of this message
* @returns True if valid signature
*/
static async verify(e: RawEvent) {
const id = await this.createId(e);
const result = await secp.schnorr.verify(e.sig, id, e.pubkey);
return result;
}
static async createId(e: RawEvent) {
const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content];
const hash = sha256(JSON.stringify(payload));
if (e.id !== "" && hash !== e.id) {
console.debug(payload);
throw new Error("ID doesnt match!");
}
return hash;
}
/**
* Create a new event for a specific pubkey
*/
static forPubKey(pk: HexKey, kind: EventKind) {
return {
pubkey: pk,
kind: kind,
created_at: unixNow(),
content: "",
tags: [],
id: "",
sig: "",
} as RawEvent;
}
static extractThread(ev: RawEvent) {
const isThread = ev.tags.some(a => a[0] === "e" && a[3] !== "mention");
if (!isThread) {
return undefined;
}
const shouldWriteMarkers = ev.kind === EventKind.TextNote;
const ret = {
mentions: [],
pubKeys: [],
} as Thread;
const eTags = ev.tags.filter(a => a[0] === "e").map((v, i) => new Tag(v, i));
const marked = eTags.some(a => a.Marker !== undefined);
if (!marked) {
ret.root = eTags[0];
ret.root.Marker = shouldWriteMarkers ? "root" : undefined;
if (eTags.length > 1) {
ret.replyTo = eTags[1];
ret.replyTo.Marker = shouldWriteMarkers ? "reply" : undefined;
}
if (eTags.length > 2) {
ret.mentions = eTags.slice(2);
if (shouldWriteMarkers) {
ret.mentions.forEach(a => (a.Marker = "mention"));
}
}
} else {
const root = eTags.find(a => a.Marker === "root");
const reply = eTags.find(a => a.Marker === "reply");
ret.root = root;
ret.replyTo = reply;
ret.mentions = eTags.filter(a => a.Marker === "mention");
}
ret.pubKeys = Array.from(new Set(ev.tags.filter(a => a[0] === "p").map(a => a[1])));
return ret;
}
/**
* Encrypt the given message content
*/
static async encryptData(content: string, pubkey: HexKey, privkey: HexKey) {
const key = await this.#getDmSharedKey(pubkey, privkey);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const data = new TextEncoder().encode(content);
const result = await window.crypto.subtle.encrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
const uData = new Uint8Array(result);
return `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(iv, 0, 16)}`;
}
/**
* Decrypt the content of the message
*/
static async decryptData(cyphertext: string, privkey: HexKey, pubkey: HexKey) {
const key = await this.#getDmSharedKey(pubkey, privkey);
const cSplit = cyphertext.split("?iv=");
const data = new Uint8Array(base64.length(cSplit[0]));
base64.decode(cSplit[0], data, 0);
const iv = new Uint8Array(base64.length(cSplit[1]));
base64.decode(cSplit[1], iv, 0);
const result = await window.crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
return new TextDecoder().decode(result);
}
/**
* Decrypt the content of this message in place
*/
static async decryptDm(content: string, privkey: HexKey, pubkey: HexKey) {
return await this.decryptData(content, privkey, pubkey);
}
static async #getDmSharedKey(pubkey: HexKey, privkey: HexKey) {
const sharedPoint = secp.getSharedSecret(privkey, "02" + pubkey);
const sharedX = sharedPoint.slice(1, 33);
return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"]);
}
}

View File

@ -0,0 +1,52 @@
import { TaggedRawEvent } from "@snort/nostr";
import { FlatNoteStore, ReplaceableNoteStore } from "./NoteCollection";
describe("NoteStore", () => {
describe("flat", () => {
test("one event", () => {
const ev = { id: "one" } as TaggedRawEvent;
const c = new FlatNoteStore();
c.add(ev);
expect(c.getSnapshotData()).toEqual([ev]);
});
test("still one event", () => {
const ev = { id: "one" } as TaggedRawEvent;
const c = new FlatNoteStore();
c.add(ev);
c.add(ev);
expect(c.getSnapshotData()).toEqual([ev]);
});
test("clears", () => {
const ev = { id: "one" } as TaggedRawEvent;
const c = new FlatNoteStore();
c.add(ev);
expect(c.getSnapshotData()).toEqual([ev]);
c.clear();
expect(c.getSnapshotData()).toEqual([]);
});
});
describe("replacable", () => {
test("one event", () => {
const ev = { id: "test", created_at: 69 } as TaggedRawEvent;
const c = new ReplaceableNoteStore();
c.add(ev);
expect(c.getSnapshotData()).toEqual(ev);
});
test("dont replace with older", () => {
const ev = { id: "test", created_at: 69 } as TaggedRawEvent;
const evOlder = { id: "test2", created_at: 68 } as TaggedRawEvent;
const c = new ReplaceableNoteStore();
c.add(ev);
c.add(evOlder);
expect(c.getSnapshotData()).toEqual(ev);
});
test("replace with newer", () => {
const ev = { id: "test", created_at: 69 } as TaggedRawEvent;
const evNewer = { id: "test2", created_at: 70 } as TaggedRawEvent;
const c = new ReplaceableNoteStore();
c.add(ev);
c.add(evNewer);
expect(c.getSnapshotData()).toEqual(evNewer);
});
});
});

View File

@ -0,0 +1,273 @@
import { TaggedRawEvent, u256 } from "@snort/nostr";
import { findTag } from "Util";
export interface StoreSnapshot<TSnapshot> {
data: TSnapshot | undefined;
store: NoteStore;
}
export type NoteStoreSnapshotData = Readonly<Array<TaggedRawEvent>> | Readonly<TaggedRawEvent>;
export type NoteStoreHook = () => void;
export type NoteStoreHookRelease = () => void;
export type OnEventCallback = (e: Readonly<Array<TaggedRawEvent>>) => void;
export type OnEventCallbackRelease = () => void;
export type OnEoseCallback = (c: string) => void;
export type OnEoseCallbackRelease = () => void;
/**
* Generic note store interface
*/
export abstract class NoteStore {
abstract add(ev: Readonly<TaggedRawEvent> | Readonly<Array<TaggedRawEvent>>): void;
abstract clear(): void;
abstract eose(c: string): void;
// react hooks
abstract hook(cb: NoteStoreHook): NoteStoreHookRelease;
abstract getSnapshotData(): NoteStoreSnapshotData | undefined;
// events
abstract onEvent(cb: OnEventCallback): OnEventCallbackRelease;
abstract onEose(cb: OnEoseCallback): OnEoseCallback;
abstract get snapshot(): StoreSnapshot<NoteStoreSnapshotData>;
abstract get loading(): boolean;
abstract set loading(v: boolean);
}
export abstract class HookedNoteStore<TSnapshot extends NoteStoreSnapshotData> implements NoteStore {
#hooks: Array<NoteStoreHook> = [];
#eventHooks: Array<OnEventCallback> = [];
#eoseHooks: Array<OnEoseCallback> = [];
#loading = true;
#storeSnapshot: StoreSnapshot<TSnapshot> = {
store: this,
data: undefined,
};
#needsSnapshot = true;
get snapshot() {
this.#updateSnapshot();
return this.#storeSnapshot;
}
get loading() {
return this.#loading;
}
set loading(v: boolean) {
this.#loading = v;
this.onChange([]);
}
abstract add(ev: TaggedRawEvent | Array<TaggedRawEvent>): void;
abstract clear(): void;
eose(c: string): void {
for (const hkE of this.#eoseHooks) {
hkE(c);
}
}
hook(cb: NoteStoreHook): NoteStoreHookRelease {
this.#hooks.push(cb);
return () => {
const idx = this.#hooks.findIndex(a => a === cb);
this.#hooks.splice(idx, 1);
};
}
getSnapshotData() {
this.#updateSnapshot();
return this.#storeSnapshot.data;
}
onEvent(cb: OnEventCallback): OnEventCallbackRelease {
this.#eventHooks.push(cb);
return () => {
const idx = this.#eventHooks.findIndex(a => a === cb);
this.#eventHooks.splice(idx, 1);
};
}
onEose(cb: OnEoseCallback): OnEoseCallback {
this.#eoseHooks.push(cb);
return () => {
const idx = this.#eoseHooks.findIndex(a => a === cb);
this.#eoseHooks.splice(idx, 1);
};
}
protected abstract takeSnapshot(): TSnapshot | undefined;
protected onChange(changes: Readonly<Array<TaggedRawEvent>>): void {
this.#needsSnapshot = true;
for (const hk of this.#hooks) {
hk();
}
if (changes.length > 0) {
for (const hkE of this.#eventHooks) {
hkE(changes);
}
}
}
#updateSnapshot() {
if (this.#needsSnapshot) {
this.#storeSnapshot = {
data: this.takeSnapshot(),
store: this,
};
this.#needsSnapshot = false;
}
}
}
export type Node = Map<u256, Array<NodeBranch>>;
export type NodeBranch = TaggedRawEvent | Node;
/**
* Tree note store
*/
export class NostrEventTree extends HookedNoteStore<TaggedRawEvent> {
base: Node = new Map();
#nodeIndex: Map<u256, Node> = new Map();
add(ev: TaggedRawEvent | Array<TaggedRawEvent>) {
throw new Error("Not implemented");
}
clear(): void {
throw new Error("Method not implemented.");
}
takeSnapshot(): TaggedRawEvent {
throw new Error("Method not implemented.");
}
}
/**
* A simple flat container of events with no duplicates
*/
export class FlatNoteStore extends HookedNoteStore<Readonly<Array<TaggedRawEvent>>> {
#events: Array<TaggedRawEvent> = [];
#ids: Set<u256> = new Set();
add(ev: TaggedRawEvent | Array<TaggedRawEvent>) {
ev = Array.isArray(ev) ? ev : [ev];
const changes: Array<TaggedRawEvent> = [];
ev.forEach(a => {
if (!this.#ids.has(a.id)) {
this.#events.push(a);
this.#ids.add(a.id);
changes.push(a);
}
});
if (changes.length > 0) {
this.onChange(changes);
}
}
clear() {
this.#events = [];
this.#ids.clear();
this.onChange([]);
}
takeSnapshot() {
return [...this.#events];
}
}
/**
* A note store that holds a single replaceable event for a given user defined key generator function
*/
export class KeyedReplaceableNoteStore extends HookedNoteStore<Readonly<Array<TaggedRawEvent>>> {
#keyFn: (ev: TaggedRawEvent) => string;
#events: Map<string, TaggedRawEvent> = new Map();
constructor(fn: (ev: TaggedRawEvent) => string) {
super();
this.#keyFn = fn;
}
add(ev: TaggedRawEvent | Array<TaggedRawEvent>) {
ev = Array.isArray(ev) ? ev : [ev];
const changes: Array<TaggedRawEvent> = [];
ev.forEach(a => {
const keyOnEvent = this.#keyFn(a);
const existingCreated = this.#events.get(keyOnEvent)?.created_at ?? 0;
if (a.created_at > existingCreated) {
this.#events.set(keyOnEvent, a);
changes.push(a);
}
});
if (changes.length > 0) {
this.onChange(changes);
}
}
clear() {
this.#events.clear();
this.onChange([]);
}
takeSnapshot() {
return [...this.#events.values()];
}
}
/**
* A note store that holds a single replaceable event
*/
export class ReplaceableNoteStore extends HookedNoteStore<Readonly<TaggedRawEvent>> {
#event?: TaggedRawEvent;
add(ev: TaggedRawEvent | Array<TaggedRawEvent>) {
ev = Array.isArray(ev) ? ev : [ev];
const changes: Array<TaggedRawEvent> = [];
ev.forEach(a => {
const existingCreated = this.#event?.created_at ?? 0;
if (a.created_at > existingCreated) {
this.#event = a;
changes.push(a);
}
});
if (changes.length > 0) {
this.onChange(changes);
}
}
clear() {
this.#event = undefined;
this.onChange([]);
}
takeSnapshot() {
if (this.#event) {
return Object.freeze({ ...this.#event });
}
}
}
/**
* A note store that holds a single replaceable event per pubkey
*/
export class PubkeyReplaceableNoteStore extends KeyedReplaceableNoteStore {
constructor() {
super(e => e.pubkey);
}
}
/**
* A note store that holds a single replaceable event per "pubkey-dtag"
*/
export class ParameterizedReplaceableNoteStore extends KeyedReplaceableNoteStore {
constructor() {
super(ev => {
const dTag = findTag(ev, "d");
return `${ev.pubkey}-${dTag}`;
});
}
}

View File

@ -0,0 +1,100 @@
import { EventKind, HexKey, TaggedRawEvent } from "@snort/nostr";
import { ProfileCacheExpire } from "Const";
import { mapEventToProfile, MetadataCache } from "State/Users";
import { UserCache } from "State/Users/UserCache";
import { PubkeyReplaceableNoteStore, RequestBuilder, System } from "System";
import { unixNowMs } from "Util";
class ProfileCache {
/**
* List of pubkeys to fetch metadata for
*/
WantsMetadata: Set<HexKey> = new Set();
constructor() {
this.#FetchMetadata();
}
/**
* Request profile metadata for a set of pubkeys
*/
TrackMetadata(pk: HexKey | Array<HexKey>) {
for (const p of Array.isArray(pk) ? pk : [pk]) {
if (p.length > 0) {
this.WantsMetadata.add(p);
}
}
}
/**
* Stop tracking metadata for a set of pubkeys
*/
UntrackMetadata(pk: HexKey | Array<HexKey>) {
for (const p of Array.isArray(pk) ? pk : [pk]) {
if (p.length > 0) {
this.WantsMetadata.delete(p);
}
}
}
async #FetchMetadata() {
const missingFromCache = await UserCache.buffer([...this.WantsMetadata]);
const expire = unixNowMs() - ProfileCacheExpire;
const expired = [...this.WantsMetadata]
.filter(a => !missingFromCache.includes(a))
.filter(a => (UserCache.get(a)?.loaded ?? 0) < expire);
const missing = new Set([...missingFromCache, ...expired]);
if (missing.size > 0) {
console.debug(`Wants profiles: ${missingFromCache.length} missing, ${expired.length} expired`);
const sub = new RequestBuilder(`profiles`);
sub
.withFilter()
.kinds([EventKind.SetMetadata])
.authors([...missing]);
const q = System.Query<PubkeyReplaceableNoteStore>(PubkeyReplaceableNoteStore, sub);
// never release this callback, it will stop firing anyway after eose
q.onEvent(async ev => {
for (const e of ev) {
const profile = mapEventToProfile(e);
if (profile) {
await UserCache.update(profile);
}
}
});
const results = await new Promise<Readonly<Array<TaggedRawEvent>>>(resolve => {
let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
const release = q.hook(() => {
if (!q.loading) {
clearTimeout(timeout);
resolve(q.getSnapshotData() ?? []);
}
release();
});
timeout = setTimeout(() => {
release();
resolve(q.getSnapshotData() ?? []);
}, 5_000);
});
const couldNotFetch = [...missing].filter(a => !results.some(b => b.pubkey === a));
if (couldNotFetch.length > 0) {
console.debug("No profiles: ", couldNotFetch);
const empty = couldNotFetch.map(a =>
UserCache.update({
pubkey: a,
loaded: unixNowMs() - ProfileCacheExpire + 5_000, // expire in 5s
created: 69,
} as MetadataCache)
);
await Promise.all(empty);
}
}
setTimeout(() => this.#FetchMetadata(), 500);
}
}
export const ProfileLoader = new ProfileCache();

View File

@ -0,0 +1,91 @@
import { Connection, RawReqFilter, Nips } from "@snort/nostr";
import { unixNowMs } from "Util";
export interface QueryRequest {
filters: Array<RawReqFilter>;
started: number;
finished?: number;
}
/**
* Active or queued query on the system
*/
export class Query {
/**
* Uniquie ID of this query
*/
id: string;
/**
* The query payload (REQ filters)
*/
request: QueryRequest;
/**
* Sub-Queries which are connected to this subscription
*/
subQueries: Array<Query> = [];
/**
* Which relays this query has already been executed on
*/
#sentToRelays: Array<Readonly<Connection>> = [];
/**
* Leave the query open until its removed
*/
leaveOpen = false;
/**
* List of relays to send this query to
*/
relays: Array<string> = [];
/**
* Time when this query can be removed
*/
#cancelTimeout?: number;
constructor(id: string, request: QueryRequest) {
this.id = id;
this.request = request;
}
get closing() {
return this.#cancelTimeout !== undefined;
}
get closingAt() {
return this.#cancelTimeout;
}
cancel() {
this.#cancelTimeout = unixNowMs() + 5_000;
}
unCancel() {
this.#cancelTimeout = undefined;
}
sendToRelay(c: Connection) {
if (this.relays.length > 0 && !this.relays.includes(c.Address)) {
return;
}
if (this.relays.length === 0 && c.Ephemeral) {
console.debug("Cant send non-specific REQ to ephemeral connection");
return;
}
if (this.request.filters.some(a => a.search) && !c.SupportsNip(Nips.Search)) {
console.debug("Cant send REQ to non-search relay", c.Address);
return;
}
c.QueueReq(["REQ", this.id, ...this.request.filters]);
this.#sentToRelays.push(c);
}
sendClose() {
for (const c of this.#sentToRelays) {
c.CloseReq(this.id);
}
}
}

View File

@ -0,0 +1,64 @@
import { RequestBuilder } from "./RequestBuilder";
describe("RequestBuilder", () => {
describe("basic", () => {
test("empty filter", () => {
const b = new RequestBuilder("test");
b.withFilter();
expect(b.build()).toEqual([{}]);
});
test("only kind", () => {
const b = new RequestBuilder("test");
b.withFilter().kinds([0]);
expect(b.build()).toEqual([{ kinds: [0] }]);
});
test("empty authors", () => {
const b = new RequestBuilder("test");
b.withFilter().authors([]);
expect(b.build()).toEqual([{ authors: [] }]);
});
test("authors/kinds/ids", () => {
const authors = ["a1", "a2"];
const kinds = [0, 1, 2, 3];
const ids = ["id1", "id2", "id3"];
const b = new RequestBuilder("test");
b.withFilter().authors(authors).kinds(kinds).ids(ids);
expect(b.build()).toEqual([{ ids, authors, kinds }]);
});
test("authors and kinds, duplicates removed", () => {
const authors = ["a1", "a2"];
const kinds = [0, 1, 2, 3];
const ids = ["id1", "id2", "id3"];
const b = new RequestBuilder("test");
b.withFilter().ids(ids).authors(authors).kinds(kinds).ids(ids).authors(authors).kinds(kinds);
expect(b.build()).toEqual([{ ids, authors, kinds }]);
});
test("search", () => {
const b = new RequestBuilder("test");
b.withFilter().kinds([1]).search("test-search");
expect(b.build()).toEqual([{ kinds: [1], search: "test-search" }]);
});
test("timeline", () => {
const authors = ["a1", "a2"];
const kinds = [0, 1, 2, 3];
const until = 10;
const since = 5;
const b = new RequestBuilder("test");
b.withFilter().kinds(kinds).authors(authors).since(since).until(until);
expect(b.build()).toEqual([{ kinds, authors, until, since }]);
});
test("multi-filter timeline", () => {
const authors = ["a1", "a2"];
const kinds = [0, 1, 2, 3];
const until = 10;
const since = 5;
const b = new RequestBuilder("test");
b.withFilter().kinds(kinds).authors(authors).since(since).until(until);
b.withFilter().kinds(kinds).authors(authors).since(since).until(until);
expect(b.build()).toEqual([
{ kinds, authors, until, since },
{ kinds, authors, until, since },
]);
});
});
});

View File

@ -0,0 +1,148 @@
import { RawReqFilter, u256, HexKey, EventKind } from "@snort/nostr";
import { appendDedupe } from "Util";
/**
* Which strategy is used when building REQ filters
*/
export enum NostrRequestStrategy {
/**
* Use the users default relays to fetch events,
* this is the fallback option when there is no better way to query a given filter set
*/
DefaultRelays = 1,
/**
* Using a cached copy of the authors relay lists NIP-65, split a given set of request filters by pubkey
*/
AuthorsRelays = 2,
/**
* Relay hints are usually provided when using replies
*/
RelayHintedEventIds = 3,
}
/**
* A built REQ filter ready for sending to System
*/
export interface BuiltRawReqFilter {
id: string;
filter: Array<RawReqFilter>;
relays: Array<string>;
strategy: NostrRequestStrategy;
}
export interface RequestBuilderOptions {
leaveOpen?: boolean;
relays?: Array<string>;
}
/**
* Nostr REQ builder
*/
export class RequestBuilder {
id: string;
#builders: Array<RequestFilterBuilder>;
#options?: RequestBuilderOptions;
constructor(id: string) {
this.id = id;
this.#builders = [];
}
get numFilters() {
return this.#builders.length;
}
get options() {
return this.#options;
}
withFilter() {
const ret = new RequestFilterBuilder();
this.#builders.push(ret);
return ret;
}
withOptions(opt: RequestBuilderOptions) {
this.#options = {
...this.#options,
...opt,
};
return this;
}
build(): Array<RawReqFilter> {
return this.#builders.map(a => a.filter);
}
}
/**
* Builder class for a single request filter
*/
export class RequestFilterBuilder {
#filter: RawReqFilter = {};
#relayHints: Map<u256, Array<string>> = new Map();
get filter() {
return { ...this.#filter };
}
get relayHints() {
return new Map(this.#relayHints);
}
ids(ids: Array<u256>) {
this.#filter.ids = appendDedupe(this.#filter.ids, ids);
return this;
}
id(id: u256, relay?: string) {
if (relay) {
this.#relayHints.set(id, appendDedupe(this.#relayHints.get(id), [relay]));
}
return this.ids([id]);
}
authors(authors?: Array<HexKey>) {
if (!authors) return this;
this.#filter.authors = appendDedupe(this.#filter.authors, authors);
return this;
}
kinds(kinds?: Array<EventKind>) {
if (!kinds) return this;
this.#filter.kinds = appendDedupe(this.#filter.kinds, kinds);
return this;
}
since(since?: number) {
if (!since) return this;
this.#filter.since = since;
return this;
}
until(until?: number) {
if (!until) return this;
this.#filter.until = until;
return this;
}
limit(limit?: number) {
if (!limit) return this;
this.#filter.limit = limit;
return this;
}
tag(key: "e" | "p" | "d" | "t" | "r", value?: Array<string>) {
if (!value) return this;
this.#filter[`#${key}`] = value;
return this;
}
search(keyword?: string) {
if (!keyword) return this;
this.#filter.search = keyword;
return this;
}
}

View File

@ -0,0 +1,74 @@
import { RawReqFilter } from "@snort/nostr";
import { diffFilters } from "./RequestSplitter";
describe("RequestSplitter", () => {
test("single filter add value", () => {
const a: Array<RawReqFilter> = [{ kinds: [0], authors: ["a"] }];
const b: Array<RawReqFilter> = [{ kinds: [0], authors: ["a", "b"] }];
const diff = diffFilters(a, b);
expect(diff).toEqual({ filters: [{ kinds: [0], authors: ["b"] }], changed: true });
});
test("single filter remove value", () => {
const a: Array<RawReqFilter> = [{ kinds: [0], authors: ["a"] }];
const b: Array<RawReqFilter> = [{ kinds: [0], authors: ["b"] }];
const diff = diffFilters(a, b);
expect(diff).toEqual({ filters: [{ kinds: [0], authors: ["b"] }], changed: true });
});
test("single filter change critical key", () => {
const a: Array<RawReqFilter> = [{ kinds: [0], authors: ["a"], since: 100 }];
const b: Array<RawReqFilter> = [{ kinds: [0], authors: ["a", "b"], since: 101 }];
const diff = diffFilters(a, b);
expect(diff).toEqual({ filters: [{ kinds: [0], authors: ["a", "b"], since: 101 }], changed: true });
});
test("multiple filter add value", () => {
const a: Array<RawReqFilter> = [
{ kinds: [0], authors: ["a"] },
{ kinds: [69], authors: ["a"] },
];
const b: Array<RawReqFilter> = [
{ kinds: [0], authors: ["a", "b"] },
{ kinds: [69], authors: ["a", "c"] },
];
const diff = diffFilters(a, b);
expect(diff).toEqual({
filters: [
{ kinds: [0], authors: ["b"] },
{ kinds: [69], authors: ["c"] },
],
changed: true,
});
});
test("multiple filter remove value", () => {
const a: Array<RawReqFilter> = [
{ kinds: [0], authors: ["a"] },
{ kinds: [69], authors: ["a"] },
];
const b: Array<RawReqFilter> = [
{ kinds: [0], authors: ["b"] },
{ kinds: [69], authors: ["c"] },
];
const diff = diffFilters(a, b);
expect(diff).toEqual({
filters: [
{ kinds: [0], authors: ["b"] },
{ kinds: [69], authors: ["c"] },
],
changed: true,
});
});
test("add filter", () => {
const a: Array<RawReqFilter> = [{ kinds: [0], authors: ["a"] }];
const b: Array<RawReqFilter> = [
{ kinds: [0], authors: ["a"] },
{ kinds: [69], authors: ["c"] },
];
const diff = diffFilters(a, b);
expect(diff).toEqual({
filters: [
{ kinds: [0], authors: ["a"] },
{ kinds: [69], authors: ["c"] },
],
changed: true,
});
});
});

View File

@ -0,0 +1,43 @@
import { RawReqFilter } from "@snort/nostr";
export function diffFilters(a: Array<RawReqFilter>, b: Array<RawReqFilter>) {
const result: Array<RawReqFilter> = [];
let anyChanged = false;
for (const [i, bN] of b.entries()) {
const prev: Record<string, string | number | string[] | number[] | undefined> = a[i];
if (!prev) {
result.push(bN);
anyChanged = true;
} else {
// Critical keys changing means the entire filter has changed
const criticalKeys = ["since", "until", "limit"];
let anyCriticalKeyChanged = false;
for (const [k, v] of Object.entries(bN)) {
if (Array.isArray(v)) {
const prevArray = prev[k] as Array<string | number>;
const thisArray = v as Array<string | number>;
const added = thisArray.filter(a => !prevArray.includes(a));
// support adding new values to array, removing values is ignored since we only care about getting new values
result[i] = { ...result[i], [k]: added.length === 0 ? prevArray : added };
if (added.length > 0) {
anyChanged = true;
}
} else if (prev[k] !== v) {
result[i] = { ...result[i], [k]: v };
if (criticalKeys.includes(k)) {
anyCriticalKeyChanged = anyChanged = true;
break;
}
}
}
if (anyCriticalKeyChanged) {
result[i] = bN;
}
}
}
return {
filters: result,
changed: anyChanged,
};
}

View File

@ -0,0 +1,334 @@
import { AuthHandler, TaggedRawEvent, RelaySettings, Connection, RawReqFilter, RawEvent } from "@snort/nostr";
import { sanitizeRelayUrl, unixNowMs, unwrap } from "Util";
import { RequestBuilder } from "./RequestBuilder";
import {
FlatNoteStore,
NoteStore,
PubkeyReplaceableNoteStore,
ParameterizedReplaceableNoteStore,
} from "./NoteCollection";
import { diffFilters } from "./RequestSplitter";
import { Query } from "./Query";
export {
NoteStore,
RequestBuilder,
FlatNoteStore,
PubkeyReplaceableNoteStore,
ParameterizedReplaceableNoteStore,
Query,
};
export interface SystemSnapshot {
queries: Array<{
id: string;
filters: Array<RawReqFilter>;
subFilters: Array<RawReqFilter>;
closing: boolean;
}>;
}
export type HookSystemSnapshotRelease = () => void;
export type HookSystemSnapshot = () => void;
/**
* Manages nostr content retrieval system
*/
export class NostrSystem {
/**
* All currently connected websockets
*/
Sockets: Map<string, Connection>;
/**
* All active queries
*/
Queries: Map<string, Query> = new Map();
/**
* Collection of all feeds which are keyed by subscription id
*/
Feeds: Map<string, NoteStore> = new Map();
/**
* Handler function for NIP-42
*/
HandleAuth?: AuthHandler;
/**
* State change hooks
*/
#stateHooks: Array<HookSystemSnapshot> = [];
/**
* Current snapshot of the system
*/
#snapshot: Readonly<SystemSnapshot> = { queries: [] };
constructor() {
this.Sockets = new Map();
this.#cleanup();
}
hook(cb: HookSystemSnapshot): HookSystemSnapshotRelease {
this.#stateHooks.push(cb);
return () => {
const idx = this.#stateHooks.findIndex(a => a === cb);
this.#stateHooks.splice(idx, 1);
};
}
getSnapshot(): Readonly<SystemSnapshot> {
return this.#snapshot;
}
/**
* Connect to a NOSTR relay if not already connected
*/
async ConnectToRelay(address: string, options: RelaySettings) {
try {
const addr = unwrap(sanitizeRelayUrl(address));
if (!this.Sockets.has(addr)) {
const c = new Connection(addr, options, this.HandleAuth);
this.Sockets.set(addr, c);
c.OnEvent = (s, e) => this.OnEvent(s, e);
c.OnEose = s => this.OnEndOfStoredEvents(c, s);
c.OnConnected = () => {
for (const [, q] of this.Queries) {
q.sendToRelay(c);
}
};
await c.Connect();
} else {
// update settings if already connected
unwrap(this.Sockets.get(addr)).Settings = options;
}
} catch (e) {
console.error(e);
}
}
OnEndOfStoredEvents(c: Connection, sub: string) {
const q = this.GetQuery(sub);
if (q) {
q.request.finished = unixNowMs();
const f = this.Feeds.get(sub);
if (f) {
f.eose(c.Address);
f.loading = false;
}
if (!q.leaveOpen) {
c.CloseReq(sub);
}
}
}
OnEvent(sub: string, ev: TaggedRawEvent) {
const feed = this.GetFeed(sub);
if (feed) {
feed.add(ev);
}
}
GetFeed(sub: string) {
const subFilterId = /-\d+$/i;
if (sub.match(subFilterId)) {
// feed events back into parent query
sub = sub.split(subFilterId)[0];
}
return this.Feeds.get(sub);
}
GetQuery(sub: string) {
const subFilterId = /-\d+$/i;
if (sub.match(subFilterId)) {
// feed events back into parent query
sub = sub.split(subFilterId)[0];
}
return this.Queries.get(sub);
}
/**
*
* @param address Relay address URL
*/
async ConnectEphemeralRelay(address: string): Promise<Connection | undefined> {
try {
const addr = unwrap(sanitizeRelayUrl(address));
if (!this.Sockets.has(addr)) {
const c = new Connection(addr, { read: true, write: false }, this.HandleAuth, true);
this.Sockets.set(addr, c);
c.OnEvent = (s, e) => this.OnEvent(s, e);
c.OnEose = s => this.OnEndOfStoredEvents(c, s);
c.OnConnected = () => {
for (const [, q] of this.Queries) {
q.sendToRelay(c);
}
};
await c.Connect();
return c;
}
} catch (e) {
console.error(e);
}
}
/**
* Disconnect from a relay
*/
DisconnectRelay(address: string) {
const c = this.Sockets.get(address);
if (c) {
this.Sockets.delete(address);
c.Close();
}
}
Query<T extends NoteStore>(type: { new (): T }, req: RequestBuilder | null): Readonly<T> {
/**
* ## Notes
*
* Given a set of existing filters:
* ["REQ", "1", { kinds: [0, 7], authors: [...], since: now()-1hr, until: now() }]
* ["REQ", "2", { kinds: [0, 7], authors: [...], since: now(), limit: 0 }]
*
* ## Problem 1:
* Assume we now want to update sub "1" with a new set of authors,
* what should we do, should we close sub "1" and send the new set or create another
* subscription with the new pubkeys (diff)
*
* Creating a new subscription sounds great but also is a problem when relays limit
* active subscriptions, maybe we should instead queue the new
* subscription (assuming that we expect to close at EOSE)
*
* ## Problem 2:
* When multiple filters a specifid in a single filter but only 1 filter changes,
* ~~same as above~~
*
* Seems reasonable to do "Queue Diff", should also be possible to collapse multiple
* pending filters for the same subscription
*/
if (!req) return new type();
if (this.Queries.has(req.id)) {
const filters = req.build();
const q = unwrap(this.Queries.get(req.id));
q.unCancel();
const diff = diffFilters(q.request.filters, filters);
if (!diff.changed) {
this.#changed();
return unwrap(this.Feeds.get(req.id)) as Readonly<T>;
} else {
const subQ = new Query(`${q.id}-${q.subQueries.length + 1}`, {
filters: diff.filters,
started: unixNowMs(),
});
q.subQueries.push(subQ);
q.request.filters = filters;
const f = unwrap(this.Feeds.get(req.id));
f.loading = true;
this.SendQuery(subQ);
this.#changed();
return f as Readonly<T>;
}
} else {
return this.AddQuery<T>(type, req);
}
}
AddQuery<T extends NoteStore>(type: { new (): T }, rb: RequestBuilder): T {
const q = new Query(rb.id, {
filters: rb.build(),
started: unixNowMs(),
finished: 0,
});
if (rb.options?.leaveOpen) {
q.leaveOpen = rb.options.leaveOpen;
}
if (rb.options?.relays) {
q.relays = rb.options.relays;
}
this.Queries.set(rb.id, q);
const store = new type();
this.Feeds.set(rb.id, store);
store.onEose(c => console.debug(`[EOSE][${rb.id}]: ${c}`));
this.SendQuery(q);
this.#changed();
return store;
}
CancelQuery(sub: string) {
const q = this.Queries.get(sub);
if (q) {
q.cancel();
}
}
SendQuery(q: Query) {
for (const [, s] of this.Sockets) {
q.sendToRelay(s);
}
}
/**
* Send events to writable relays
*/
BroadcastEvent(ev: RawEvent) {
for (const [, s] of this.Sockets) {
s.SendEvent(ev);
}
}
/**
* Write an event to a relay then disconnect
*/
async WriteOnceToRelay(address: string, ev: RawEvent) {
const c = new Connection(address, { write: true, read: false }, this.HandleAuth, true);
await c.Connect();
await c.SendAsync(ev);
c.Close();
}
#changed() {
this.#snapshot = Object.freeze({
queries: [...this.Queries.values()].map(a => {
return {
id: a.id,
filters: a.request.filters,
closing: a.closing,
subFilters: a.subQueries.map(a => a.request.filters).flat(),
};
}),
});
for (const h of this.#stateHooks) {
h();
}
}
#cleanup() {
const now = unixNowMs();
let changed = false;
for (const [k, v] of this.Queries) {
if (v.closingAt && v.closingAt < now) {
if (v.leaveOpen) {
v.sendClose();
}
this.Queries.delete(k);
this.Feeds.delete(k);
console.debug("Removed:", k);
changed = true;
}
}
if (changed) {
this.#changed();
}
setTimeout(() => this.#cleanup(), 1_000);
}
}
export const System = new NostrSystem();

View File

@ -8,7 +8,7 @@ import base32Decode from "base32-decode";
import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix, decodeTLV, TLVEntryType } from "@snort/nostr";
import { MetadataCache } from "State/Users";
export const sha256 = (str: string) => {
export const sha256 = (str: string | Uint8Array): u256 => {
return secp.utils.bytesToHex(hash(str));
};
@ -138,8 +138,12 @@ export function normalizeReaction(content: string) {
/**
* Get reactions to a specific event (#e + kind filter)
*/
export function getReactions(notes: TaggedRawEvent[], id: u256, kind = EventKind.Reaction) {
return notes?.filter(a => a.kind === kind && a.tags.some(a => a[0] === "e" && a[1] === id)) || [];
export function getReactions(notes: readonly TaggedRawEvent[] | undefined, id: u256, kind?: EventKind) {
return notes?.filter(a => a.kind === (kind ?? a.kind) && a.tags.some(a => a[0] === "e" && a[1] === id)) || [];
}
export function getAllReactions(notes: readonly TaggedRawEvent[] | undefined, ids: Array<u256>, kind?: EventKind) {
return notes?.filter(a => a.kind === (kind ?? a.kind) && a.tags.some(a => a[0] === "e" && ids.includes(a[1]))) || [];
}
/**
@ -212,6 +216,45 @@ export function dedupeById(events: TaggedRawEvent[]) {
return deduped.list as TaggedRawEvent[];
}
/**
* Return newest event by pubkey
* @param events List of all notes to filter from
* @returns
*/
export function getLatestByPubkey(events: TaggedRawEvent[]): Map<HexKey, TaggedRawEvent> {
const deduped = events.reduce((results: Map<HexKey, TaggedRawEvent>, ev) => {
if (!results.has(ev.pubkey)) {
const latest = getNewest(events.filter(a => a.pubkey === ev.pubkey));
if (latest) {
results.set(ev.pubkey, latest);
}
}
return results;
}, new Map<HexKey, TaggedRawEvent>());
return deduped;
}
export function getLatestProfileByPubkey(profiles: MetadataCache[]): Map<HexKey, MetadataCache> {
const deduped = profiles.reduce((results: Map<HexKey, MetadataCache>, ev) => {
if (!results.has(ev.pubkey)) {
const latest = getNewestProfile(profiles.filter(a => a.pubkey === ev.pubkey));
if (latest) {
results.set(ev.pubkey, latest);
}
}
return results;
}, new Map<HexKey, MetadataCache>());
return deduped;
}
export function dedupe<T>(v: Array<T>) {
return [...new Set(v)];
}
export function appendDedupe<T>(a?: Array<T>, b?: Array<T>) {
return dedupe([...(a ?? []), ...(b ?? [])]);
}
export function unwrap<T>(v: T | undefined | null): T {
if (v === undefined || v === null) {
throw new Error("missing value");
@ -224,14 +267,33 @@ export function randomSample<T>(coll: T[], size: number) {
return random.sort(() => (Math.random() >= 0.5 ? 1 : -1)).slice(0, size);
}
export function getNewest(rawNotes: TaggedRawEvent[]) {
export function getNewest(rawNotes: readonly TaggedRawEvent[]) {
const notes = [...rawNotes];
notes.sort((a, b) => a.created_at - b.created_at);
notes.sort((a, b) => b.created_at - a.created_at);
if (notes.length > 0) {
return notes[0];
}
}
export function getNewestProfile(rawNotes: MetadataCache[]) {
const notes = [...rawNotes];
notes.sort((a, b) => b.created - a.created);
if (notes.length > 0) {
return notes[0];
}
}
export function getNewestEventTagsByKey(evs: TaggedRawEvent[], tag: string) {
const newest = getNewest(evs);
if (newest) {
const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === tag).map(p => p[1]);
return {
keys,
createdAt: newest.created_at,
};
}
}
export function tagFilterOfTextRepost(note: TaggedRawEvent, id?: u256): (tag: string[], i: number) => boolean {
return (tag, i) =>
tag[0] === "e" && tag[3] === "mention" && note.content === `#[${i}]` && (id ? tag[1] === id : true);
@ -507,3 +569,11 @@ export function parseNostrLink(link: string): NostrLink | undefined {
}
}
}
export function sanitizeRelayUrl(url: string) {
try {
return new URL(url).toString();
} catch {
// ignore
}
}

View File

@ -1,4 +1,3 @@
import { EventPublisher } from "Feed/EventPublisher";
import {
InvoiceRequest,
LNWallet,
@ -17,14 +16,13 @@ const defaultHeaders = {
};
export default class LNDHubWallet implements LNWallet {
type: "lndhub" | "snort";
type: "lndhub";
url: URL;
user: string;
password: string;
auth?: AuthResponse;
publisher?: EventPublisher;
constructor(url: string, publisher?: EventPublisher) {
constructor(url: string) {
if (url.startsWith("lndhub://")) {
const regex = /^lndhub:\/\/([\S-]+):([\S-]+)@(.*)$/i;
const parsedUrl = url.match(regex);
@ -36,13 +34,6 @@ export default class LNDHubWallet implements LNWallet {
this.user = parsedUrl[1];
this.password = parsedUrl[2];
this.type = "lndhub";
} else if (url.startsWith("snort://")) {
const u = new URL(url);
this.url = new URL(`https://${u.host}${u.pathname}`);
this.user = "";
this.password = "";
this.type = "snort";
this.publisher = publisher;
} else {
throw new Error("Invalid config");
}
@ -61,8 +52,6 @@ export default class LNDHubWallet implements LNWallet {
}
async login() {
if (this.type === "snort") return true;
const rsp = await this.getJson<AuthResponse>("POST", "/auth?type=auth", {
login: this.user,
password: this.password,
@ -123,11 +112,7 @@ export default class LNDHubWallet implements LNWallet {
}
private async getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
let auth = `Bearer ${this.auth?.access_token}`;
if (this.type === "snort") {
const ev = await this.publisher?.generic(`${this.url.pathname}${path}`, 30_000);
auth = JSON.stringify(ev?.ToObject());
}
const auth = `Bearer ${this.auth?.access_token}`;
const url = `${this.url.pathname === "/" ? this.url.toString().slice(0, -1) : this.url.toString()}${path}`;
const rsp = await fetch(url, {
method: method,

View File

@ -10,11 +10,10 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
import { IntlProvider } from "IntlProvider";
import Store from "State/Store";
import EventPage from "Pages/EventPage";
import Layout from "Pages/Layout";
import LoginPage from "Pages/Login";
import ProfilePage from "Pages/ProfilePage";
import RootPage from "Pages/Root";
import { RootRoutes } from "Pages/Root";
import NotificationsPage from "Pages/Notifications";
import SettingsPage, { SettingsRoutes } from "Pages/SettingsPage";
import ErrorPage from "Pages/ErrorPage";
@ -28,6 +27,7 @@ import HelpPage from "Pages/HelpPage";
import { NewUserRoutes } from "Pages/new";
import { WalletRoutes } from "Pages/WalletPage";
import NostrLinkHandler from "Pages/NostrLinkHandler";
import Thread from "Element/Thread";
import { unwrap } from "Util";
/**
@ -42,10 +42,7 @@ export const router = createBrowserRouter([
element: <Layout />,
errorElement: <ErrorPage />,
children: [
{
path: "/",
element: <RootPage />,
},
...RootRoutes,
{
path: "/login",
element: <LoginPage />,
@ -56,7 +53,7 @@ export const router = createBrowserRouter([
},
{
path: "/e/:id",
element: <EventPage />,
element: <Thread />,
},
{
path: "/p/:id",

View File

@ -75,9 +75,6 @@
"2k0Cv+": {
"defaultMessage": "Dislikes ({n})"
},
"3LAxQH": {
"defaultMessage": "Rewrite Twitter links to Nitter links"
},
"3cc4Ct": {
"defaultMessage": "Light"
},
@ -366,6 +363,9 @@
"L7SZPr": {
"defaultMessage": "For more information about donations see {link}."
},
"LF5kYT": {
"defaultMessage": "Other Connections"
},
"LXxsbk": {
"defaultMessage": "Anonymous"
},
@ -525,9 +525,6 @@
"X7xU8J": {
"defaultMessage": "nsec, npub, nip-05, hex, mnemonic"
},
"XSoRjZ": {
"defaultMessage": "Nitter Rewrite"
},
"XgWvGA": {
"defaultMessage": "Reactions"
},
@ -672,9 +669,6 @@
"iNWbVV": {
"defaultMessage": "Handle"
},
"iXPL0Z": {
"defaultMessage": "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead"
},
"ieGrWo": {
"defaultMessage": "Follow"
},
@ -792,6 +786,9 @@
"oxCa4R": {
"defaultMessage": "Getting an identifier helps confirm the real you to people who know you. Many people can have a username @jack, but there is only one jack@cash.app."
},
"p85Uwy": {
"defaultMessage": "Active Subscriptions"
},
"puLNUJ": {
"defaultMessage": "Pin"
},
@ -864,6 +861,9 @@
"uSV4Ti": {
"defaultMessage": "Reposts need to be manually confirmed"
},
"ubtr9S": {
"defaultMessage": "Can't login with private key on 'http://' connection, please use a Nostr key manager extension instead"
},
"usAvMr": {
"defaultMessage": "Edit Profile"
},

View File

@ -24,7 +24,6 @@
"2LbrkB": "Enter password",
"2a2YiP": "{n} Bookmarks",
"2k0Cv+": "Dislikes ({n})",
"3LAxQH": "Rewrite Twitter links to Nitter links",
"3cc4Ct": "Light",
"3gOsZq": "Translators",
"3t3kok": "{n,plural,=1{{n} new note} other{{n} new notes}}",
@ -119,6 +118,7 @@
"KWuDfz": "I have saved my keys, continue",
"KahimY": "Unknown event kind: {kind}",
"L7SZPr": "For more information about donations see {link}.",
"LF5kYT": "Other Connections",
"LXxsbk": "Anonymous",
"LgbKvU": "Comment",
"LxY9tW": "Generate Key",
@ -170,7 +170,6 @@
"WONP5O": "Find your twitter follows on nostr (Data provided by {provider})",
"WxthCV": "e.g. Jack",
"X7xU8J": "nsec, npub, nip-05, hex, mnemonic",
"XSoRjZ": "Nitter Rewrite",
"XgWvGA": "Reactions",
"XzF0aC": "Key manager extensions are more secure and allow you to easily login to any Nostr client, here are some well known extensions:",
"Y31HTH": "Help fund the development of Snort",
@ -218,7 +217,6 @@
"iDGAbc": "Get a Snort identifier",
"iGT1eE": "Prevent fake accounts from imitating you",
"iNWbVV": "Handle",
"iXPL0Z": "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead",
"ieGrWo": "Follow",
"itPgxd": "Profile",
"izWS4J": "Unfollow",
@ -257,6 +255,7 @@
"odhABf": "Login",
"osUr8O": "You can also use these extensions to login to most Nostr sites.",
"oxCa4R": "Getting an identifier helps confirm the real you to people who know you. Many people can have a username @jack, but there is only one jack@cash.app.",
"p85Uwy": "Active Subscriptions",
"puLNUJ": "Pin",
"pzTOmv": "Followers",
"qDwvZ4": "Unknown error",
@ -281,6 +280,7 @@
"u4bHcR": "Check out the code here: {link}",
"uD/N6c": "Zap {target} {n} sats",
"uSV4Ti": "Reposts need to be manually confirmed",
"ubtr9S": "Can't login with private key on 'http://' connection, please use a Nostr key manager extension instead",
"usAvMr": "Edit Profile",
"ut+2Cd": "Get a partner identifier",
"vOKedj": "{n,plural,=1{& {n} other} other{& {n} others}}",

View File

@ -1,25 +1,18 @@
import * as secp from "@noble/secp256k1";
import { v4 as uuid } from "uuid";
import { Subscriptions } from "./Subscriptions";
import { default as NEvent } from "./Event";
import { DefaultConnectTimeout } from "./Const";
import { ConnectionStats } from "./ConnectionStats";
import { RawEvent, RawReqFilter, TaggedRawEvent, u256 } from "./index";
import { RawEvent, RawReqFilter, ReqCommand, TaggedRawEvent, u256 } from "./index";
import { RelayInfo } from "./RelayInfo";
import Nips from "./Nips";
import { unwrap } from "./Util";
export type CustomHook = (state: Readonly<StateSnapshot>) => void;
export type AuthHandler = (
challenge: string,
relay: string
) => Promise<NEvent | undefined>;
export type AuthHandler = (challenge: string, relay: string) => Promise<RawEvent | undefined>;
/**
* Relay settings
*/
export type RelaySettings = {
export interface RelaySettings {
read: boolean;
write: boolean;
};
@ -42,35 +35,36 @@ export type StateSnapshot = {
export class Connection {
Id: string;
Address: string;
Socket: WebSocket | null;
Pending: Array<RawReqFilter>;
Subscriptions: Map<string, Subscriptions>;
Socket: WebSocket | null = null;
PendingRaw: Array<object> = [];
PendingRequests: Array<ReqCommand> = [];
ActiveRequests: Set<string> = new Set();
Settings: RelaySettings;
Info?: RelayInfo;
ConnectTimeout: number;
Stats: ConnectionStats;
StateHooks: Map<string, CustomHook>;
HasStateChange: boolean;
ConnectTimeout: number = DefaultConnectTimeout;
Stats: ConnectionStats = new ConnectionStats();
StateHooks: Map<string, CustomHook> = new Map();
HasStateChange: boolean = true;
CurrentState: StateSnapshot;
LastState: Readonly<StateSnapshot>;
IsClosed: boolean;
ReconnectTimer: ReturnType<typeof setTimeout> | null;
EventsCallback: Map<u256, (msg: boolean[]) => void>;
OnConnected?: () => void;
OnEvent?: (sub: string, e: TaggedRawEvent) => void;
OnEose?: (sub: string) => void;
Auth?: AuthHandler;
AwaitingAuth: Map<string, boolean>;
Authed: boolean;
Ephemeral: boolean;
EphemeralTimeout: ReturnType<typeof setTimeout> | undefined;
constructor(addr: string, options: RelaySettings, auth?: AuthHandler) {
constructor(addr: string, options: RelaySettings, auth?: AuthHandler, ephemeral: boolean = false) {
this.Id = uuid();
this.Address = addr;
this.Socket = null;
this.Pending = [];
this.Subscriptions = new Map();
this.Settings = options;
this.ConnectTimeout = DefaultConnectTimeout;
this.Stats = new ConnectionStats();
this.StateHooks = new Map();
this.HasStateChange = true;
this.CurrentState = {
connected: false,
disconnects: 0,
@ -87,13 +81,29 @@ export class Connection {
this.AwaitingAuth = new Map();
this.Authed = false;
this.Auth = auth;
this.Ephemeral = ephemeral;
if (this.Ephemeral) {
this.ResetEphemeralTimeout();
}
}
ResetEphemeralTimeout() {
if (this.EphemeralTimeout) {
clearTimeout(this.EphemeralTimeout);
}
if (this.Ephemeral) {
this.EphemeralTimeout = setTimeout(() => {
this.Close();
}, 10_000);
}
}
async Connect() {
try {
if (this.Info === undefined) {
const u = new URL(this.Address);
const rsp = await fetch(`https://${u.host}`, {
const rsp = await fetch(`${u.protocol === "wss:" ? "https:" : "http:"}//${u.host}`, {
headers: {
accept: "application/nostr+json",
},
@ -101,7 +111,7 @@ export class Connection {
if (rsp.ok) {
const data = await rsp.json();
for (const [k, v] of Object.entries(data)) {
if (v === "unset" || v === "") {
if (v === "unset" || v === "" || v === "~") {
data[k] = undefined;
}
}
@ -112,11 +122,6 @@ export class Connection {
console.warn("Could not load relay information", e);
}
if (this.IsClosed) {
this._UpdateState();
return;
}
this.IsClosed = false;
this.Socket = new WebSocket(this.Address);
this.Socket.onopen = () => this.OnOpen();
@ -132,17 +137,18 @@ export class Connection {
this.ReconnectTimer = null;
}
this.Socket?.close();
this._UpdateState();
this.#UpdateState();
}
OnOpen() {
this.ConnectTimeout = DefaultConnectTimeout;
this._InitSubscriptions();
console.log(`[${this.Address}] Open!`);
this.OnConnected?.();
}
OnClose(e: CloseEvent) {
if (!this.IsClosed) {
this.#ResetQueues();
this.ConnectTimeout = this.ConnectTimeout * 2;
console.log(
`[${this.Address}] Closed (${e.reason}), trying again in ${(
@ -159,7 +165,7 @@ export class Connection {
console.log(`[${this.Address}] Closed!`);
this.ReconnectTimer = null;
}
this._UpdateState();
this.#UpdateState();
}
OnMessage(e: MessageEvent) {
@ -170,17 +176,17 @@ export class Connection {
case "AUTH": {
this._OnAuthAsync(msg[1]);
this.Stats.EventsReceived++;
this._UpdateState();
this.#UpdateState();
break;
}
case "EVENT": {
this._OnEvent(msg[1], msg[2]);
this.OnEvent?.(msg[1], msg[2]);
this.Stats.EventsReceived++;
this._UpdateState();
this.#UpdateState();
break;
}
case "EOSE": {
this._OnEnd(msg[1]);
this.OnEose?.(msg[1]);
break;
}
case "OK": {
@ -208,26 +214,26 @@ export class Connection {
OnError(e: Event) {
console.error(e);
this._UpdateState();
this.#UpdateState();
}
/**
* Send event on this connection
*/
SendEvent(e: NEvent) {
SendEvent(e: RawEvent) {
if (!this.Settings.write) {
return;
}
const req = ["EVENT", e.ToObject()];
this._SendJson(req);
const req = ["EVENT", e];
this.#SendJson(req);
this.Stats.EventsSent++;
this._UpdateState();
this.#UpdateState();
}
/**
* Send event on this connection and wait for OK response
*/
async SendAsync(e: NEvent, timeout = 5000) {
async SendAsync(e: RawEvent, timeout = 5000) {
return new Promise<void>((resolve) => {
if (!this.Settings.write) {
resolve();
@ -236,53 +242,18 @@ export class Connection {
const t = setTimeout(() => {
resolve();
}, timeout);
this.EventsCallback.set(e.Id, () => {
this.EventsCallback.set(e.id, () => {
clearTimeout(t);
resolve();
});
const req = ["EVENT", e.ToObject()];
this._SendJson(req);
const req = ["EVENT", e];
this.#SendJson(req);
this.Stats.EventsSent++;
this._UpdateState();
this.#UpdateState();
});
}
/**
* Subscribe to data from this connection
*/
AddSubscription(sub: Subscriptions) {
if (!this.Settings.read) {
return;
}
// check relay supports search
if (sub.Search && !this.SupportsNip(Nips.Search)) {
return;
}
if (this.Subscriptions.has(sub.Id)) {
return;
}
sub.Started.set(this.Address, new Date().getTime());
this._SendSubscription(sub);
this.Subscriptions.set(sub.Id, sub);
}
/**
* Remove a subscription
*/
RemoveSubscription(subId: string) {
if (this.Subscriptions.has(subId)) {
const req = ["CLOSE", subId];
this._SendJson(req);
this.Subscriptions.delete(subId);
return true;
}
return false;
}
/**
* Hook status for connection
*/
@ -312,80 +283,81 @@ export class Connection {
return this.Info?.supported_nips?.some((a) => a === n) ?? false;
}
_UpdateState() {
/**
* Queue or send command to the relay
* @param cmd The REQ to send to the server
*/
QueueReq(cmd: ReqCommand) {
if (this.ActiveRequests.size >= this.#maxSubscriptions) {
this.PendingRequests.push(cmd);
console.debug("Queuing:", this.Address, cmd);
} else {
this.ActiveRequests.add(cmd[1]);
this.#SendJson(cmd);
}
}
CloseReq(id: string) {
if (this.ActiveRequests.delete(id)) {
this.#SendJson(["CLOSE", id]);
this.#SendQueuedRequests();
}
}
#SendQueuedRequests() {
const canSend = this.#maxSubscriptions - this.ActiveRequests.size;
if (canSend > 0) {
for (let x = 0; x < canSend; x++) {
const cmd = this.PendingRequests.shift();
if (cmd) {
this.ActiveRequests.add(cmd[1]);
this.#SendJson(cmd);
console.debug("Sent pending REQ", this.Address, cmd);
}
}
}
}
#ResetQueues() {
this.ActiveRequests.clear();
this.PendingRequests = [];
this.PendingRaw = [];
}
#UpdateState() {
this.CurrentState.connected = this.Socket?.readyState === WebSocket.OPEN;
this.CurrentState.events.received = this.Stats.EventsReceived;
this.CurrentState.events.send = this.Stats.EventsSent;
this.CurrentState.avgLatency =
this.Stats.Latency.length > 0
? this.Stats.Latency.reduce((acc, v) => acc + v, 0) /
this.Stats.Latency.length
this.Stats.Latency.length
: 0;
this.CurrentState.disconnects = this.Stats.Disconnects;
this.CurrentState.info = this.Info;
this.CurrentState.id = this.Id;
this.Stats.Latency = this.Stats.Latency.slice(-20); // trim
this.HasStateChange = true;
this._NotifyState();
this.#NotifyState();
}
_NotifyState() {
#NotifyState() {
const state = this.GetState();
for (const [, h] of this.StateHooks) {
h(state);
}
}
_InitSubscriptions() {
// send pending
for (const p of this.Pending) {
this._SendJson(p);
}
this.Pending = [];
for (const [, s] of this.Subscriptions) {
this._SendSubscription(s);
}
this._UpdateState();
}
_SendSubscription(sub: Subscriptions) {
if (!this.Authed && this.AwaitingAuth.size > 0) {
this.Pending.push(sub.ToObject());
return;
}
let req = ["REQ", sub.Id, sub.ToObject()];
if (sub.OrSubs.length > 0) {
req = [...req, ...sub.OrSubs.map((o) => o.ToObject())];
}
sub.Started.set(this.Address, new Date().getTime());
this._SendJson(req);
}
_SendJson(obj: object) {
if (this.Socket?.readyState !== WebSocket.OPEN) {
this.Pending.push(obj);
#SendJson(obj: object) {
const authPending = !this.Authed && this.AwaitingAuth.size > 0;
if (this.Socket?.readyState !== WebSocket.OPEN || authPending) {
this.PendingRaw.push(obj);
return;
}
const json = JSON.stringify(obj);
this.Socket.send(json);
}
_OnEvent(subId: string, ev: RawEvent) {
if (this.Subscriptions.has(subId)) {
//this._VerifySig(ev);
const tagged: TaggedRawEvent = {
...ev,
relays: [this.Address],
};
this.Subscriptions.get(subId)?.OnEvent(tagged);
} else {
// console.warn(`No subscription for event! ${subId}`);
// ignored for now, track as "dropped event" with connection stats
}
}
async _OnAuthAsync(challenge: string): Promise<void> {
const authCleanup = () => {
this.AwaitingAuth.delete(challenge);
@ -406,61 +378,23 @@ export class Connection {
resolve();
}, 10_000);
this.EventsCallback.set(authEvent.Id, (msg: boolean[]) => {
this.EventsCallback.set(authEvent.id, (msg: boolean[]) => {
clearTimeout(t);
authCleanup();
if (msg.length > 3 && msg[2] === true) {
this.Authed = true;
this._InitSubscriptions();
}
resolve();
});
const req = ["AUTH", authEvent.ToObject()];
this._SendJson(req);
const req = ["AUTH", authEvent];
this.#SendJson(req);
this.Stats.EventsSent++;
this._UpdateState();
this.#UpdateState();
});
}
_OnEnd(subId: string) {
const sub = this.Subscriptions.get(subId);
if (sub) {
const now = new Date().getTime();
const started = sub.Started.get(this.Address);
sub.Finished.set(this.Address, now);
if (started) {
const responseTime = now - started;
if (responseTime > 10_000) {
console.warn(
`[${this.Address}][${subId}] Slow response time ${(
responseTime / 1000
).toFixed(1)} seconds`
);
}
this.Stats.Latency.push(responseTime);
} else {
console.warn("No started timestamp!");
}
sub.OnEnd(this);
this._UpdateState();
} else {
console.warn(`No subscription for end! ${subId}`);
}
}
_VerifySig(ev: RawEvent) {
const payload = [0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content];
const payloadData = new TextEncoder().encode(JSON.stringify(payload));
if (secp.utils.sha256Sync === undefined) {
throw "Cannot verify event, no sync sha256 method";
}
const data = secp.utils.sha256Sync(payloadData);
const hash = secp.utils.bytesToHex(data);
if (!secp.schnorr.verifySync(ev.sig, hash, ev.pubkey)) {
throw "Sig verify failed";
}
return ev;
get #maxSubscriptions() {
return this.Info?.limitation?.max_subscriptions ?? 25;
}
}

View File

@ -1,4 +1,4 @@
/**
* Websocket re-connect timeout
*/
export const DefaultConnectTimeout = 2000;
export const DefaultConnectTimeout = 2000;

View File

@ -1,213 +0,0 @@
import * as secp from "@noble/secp256k1";
import { sha256 } from "@noble/hashes/sha256";
import * as base64 from "@protobufjs/base64";
import { HexKey, RawEvent, TaggedRawEvent } from "./index";
import EventKind from "./EventKind";
import Tag from "./Tag";
import Thread from "./Thread";
export default class Event {
/**
* The original event
*/
Original: TaggedRawEvent | null;
/**
* Id of the event
*/
Id: string;
/**
* Pub key of the creator
*/
PubKey: string;
/**
* Timestamp when the event was created
*/
CreatedAt: number;
/**
* The type of event
*/
Kind: EventKind;
/**
* A list of metadata tags
*/
Tags: Array<Tag>;
/**
* Content of the event
*/
Content: string;
/**
* Signature of this event from the creator
*/
Signature: string;
/**
* Thread information for this event
*/
Thread: Thread | null;
constructor(e?: TaggedRawEvent) {
this.Original = e ?? null;
this.Id = e?.id ?? "";
this.PubKey = e?.pubkey ?? "";
this.CreatedAt = e?.created_at ?? Math.floor(new Date().getTime() / 1000);
this.Kind = e?.kind ?? EventKind.Unknown;
this.Tags = e?.tags.map((a, i) => new Tag(a, i)) ?? [];
this.Content = e?.content ?? "";
this.Signature = e?.sig ?? "";
this.Thread = Thread.ExtractThread(this);
}
/**
* Get the pub key of the creator of this event NIP-26
*/
get RootPubKey() {
const delegation = this.Tags.find((a) => a.Key === "delegation");
if (delegation?.PubKey) {
return delegation.PubKey;
}
return this.PubKey;
}
/**
* Sign this message with a private key
*/
async Sign(key: HexKey) {
this.Id = this.CreateId();
const sig = await secp.schnorr.sign(this.Id, key);
this.Signature = secp.utils.bytesToHex(sig);
if (!(await this.Verify())) {
throw "Signing failed";
}
}
/**
* Check the signature of this message
* @returns True if valid signature
*/
async Verify() {
const id = this.CreateId();
const result = await secp.schnorr.verify(this.Signature, id, this.PubKey);
return result;
}
CreateId() {
const payload = [
0,
this.PubKey,
this.CreatedAt,
this.Kind,
this.Tags.map((a) => a.ToObject()).filter((a) => a !== null),
this.Content,
];
const hash = secp.utils.bytesToHex(sha256(JSON.stringify(payload)));
if (this.Id !== "" && hash !== this.Id) {
console.debug(payload);
throw "ID doesnt match!";
}
return hash;
}
ToObject(): RawEvent {
return {
id: this.Id,
pubkey: this.PubKey,
created_at: this.CreatedAt,
kind: this.Kind,
tags: <string[][]>this.Tags.sort((a, b) => a.Index - b.Index)
.map((a) => a.ToObject())
.filter((a) => a !== null),
content: this.Content,
sig: this.Signature,
};
}
/**
* Create a new event for a specific pubkey
*/
static ForPubKey(pubKey: HexKey) {
const ev = new Event();
ev.PubKey = pubKey;
return ev;
}
/**
* Encrypt the given message content
*/
async EncryptData(content: string, pubkey: HexKey, privkey: HexKey) {
const key = await this._GetDmSharedKey(pubkey, privkey);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const data = new TextEncoder().encode(content);
const result = await window.crypto.subtle.encrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
const uData = new Uint8Array(result);
return `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(
iv,
0,
16
)}`;
}
/**
* Encrypt the message content in place
*/
async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) {
this.Content = await this.EncryptData(this.Content, pubkey, privkey);
}
/**
* Decrypt the content of the message
*/
async DecryptData(cyphertext: string, privkey: HexKey, pubkey: HexKey) {
const key = await this._GetDmSharedKey(pubkey, privkey);
const cSplit = cyphertext.split("?iv=");
const data = new Uint8Array(base64.length(cSplit[0]));
base64.decode(cSplit[0], data, 0);
const iv = new Uint8Array(base64.length(cSplit[1]));
base64.decode(cSplit[1], iv, 0);
const result = await window.crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
return new TextDecoder().decode(result);
}
/**
* Decrypt the content of this message in place
*/
async DecryptDm(privkey: HexKey, pubkey: HexKey) {
this.Content = await this.DecryptData(this.Content, privkey, pubkey);
}
async _GetDmSharedKey(pubkey: HexKey, privkey: HexKey) {
const sharedPoint = secp.getSharedSecret(privkey, "02" + pubkey);
const sharedX = sharedPoint.slice(1, 33);
return await window.crypto.subtle.importKey(
"raw",
sharedX,
{ name: "AES-CBC" },
false,
["encrypt", "decrypt"]
);
}
}

View File

@ -1,5 +1,3 @@
enum Nips {
export enum Nips {
Search = 50,
}
export default Nips;

View File

@ -8,5 +8,8 @@ export interface RelayInfo {
version?: string;
limitation?: {
payment_required: boolean;
max_subscriptions: number;
max_filters: number;
max_event_tags: number;
};
}

View File

@ -1,176 +0,0 @@
import { v4 as uuid } from "uuid";
import { TaggedRawEvent, RawReqFilter, u256 } from "./index";
import { Connection } from "./Connection";
import EventKind from "./EventKind";
export type NEventHandler = (e: TaggedRawEvent) => void;
export type OnEndHandler = (c: Connection) => void;
export class Subscriptions {
/**
* A unique id for this subscription filter
*/
Id: u256;
/**
* a list of event ids or prefixes
*/
Ids?: Set<u256>;
/**
* a list of pubkeys or prefixes, the pubkey of an event must be one of these
*/
Authors?: Set<u256>;
/**
* a list of a kind numbers
*/
Kinds?: Set<EventKind>;
/**
* a list of event ids that are referenced in an "e" tag
*/
ETags?: Set<u256>;
/**
* a list of pubkeys that are referenced in a "p" tag
*/
PTags?: Set<u256>;
/**
* A list of "t" tags to search
*/
HashTags?: Set<string>;
/**
* A litst of "d" tags to search
*/
DTags?: Set<string>;
/**
* A litst of "r" tags to search
*/
RTags?: Set<string>;
/**
* A list of search terms
*/
Search?: string;
/**
* a timestamp, events must be newer than this to pass
*/
Since?: number;
/**
* a timestamp, events must be older than this to pass
*/
Until?: number;
/**
* maximum number of events to be returned in the initial query
*/
Limit?: number;
/**
* Handler function for this event
*/
OnEvent: NEventHandler;
/**
* End of data event
*/
OnEnd: OnEndHandler;
/**
* Collection of OR sub scriptions linked to this
*/
OrSubs: Array<Subscriptions>;
/**
* Start time for this subscription
*/
Started: Map<string, number>;
/**
* End time for this subscription
*/
Finished: Map<string, number>;
constructor(sub?: RawReqFilter) {
this.Id = uuid();
this.Ids = sub?.ids ? new Set(sub.ids) : undefined;
this.Authors = sub?.authors ? new Set(sub.authors) : undefined;
this.Kinds = sub?.kinds ? new Set(sub.kinds) : undefined;
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined;
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined;
this.DTags = sub?.["#d"] ? new Set(["#d"]) : undefined;
this.RTags = sub?.["#r"] ? new Set(["#r"]) : undefined;
this.Search = sub?.search ?? undefined;
this.Since = sub?.since ?? undefined;
this.Until = sub?.until ?? undefined;
this.Limit = sub?.limit ?? undefined;
this.OnEvent = () => {
console.warn(`No event handler was set on subscription: ${this.Id}`);
};
this.OnEnd = () => undefined;
this.OrSubs = [];
this.Started = new Map<string, number>();
this.Finished = new Map<string, number>();
}
/**
* Adds OR filter subscriptions
*/
AddSubscription(sub: Subscriptions) {
this.OrSubs.push(sub);
}
/**
* If all relays have responded with EOSE
*/
IsFinished() {
return this.Started.size === this.Finished.size;
}
ToObject(): RawReqFilter {
const ret: RawReqFilter = {};
if (this.Ids) {
ret.ids = Array.from(this.Ids);
}
if (this.Authors) {
ret.authors = Array.from(this.Authors);
}
if (this.Kinds) {
ret.kinds = Array.from(this.Kinds);
}
if (this.ETags) {
ret["#e"] = Array.from(this.ETags);
}
if (this.PTags) {
ret["#p"] = Array.from(this.PTags);
}
if (this.HashTags) {
ret["#t"] = Array.from(this.HashTags);
}
if (this.DTags) {
ret["#d"] = Array.from(this.DTags);
}
if (this.RTags) {
ret["#r"] = Array.from(this.RTags);
}
if (this.Search) {
ret.search = this.Search;
}
if (this.Since !== null) {
ret.since = this.Since;
}
if (this.Until !== null) {
ret.until = this.Until;
}
if (this.Limit !== null) {
ret.limit = this.Limit;
}
return ret;
}
}

View File

@ -1,56 +0,0 @@
import { u256 } from "./index";
import { default as NEvent } from "./Event";
import EventKind from "./EventKind";
import Tag from "./Tag";
export default class Thread {
Root?: Tag;
ReplyTo?: Tag;
Mentions: Array<Tag>;
PubKeys: Array<u256>;
constructor() {
this.Mentions = [];
this.PubKeys = [];
}
/**
* Extract thread information from an Event
* @param ev Event to extract thread from
*/
static ExtractThread(ev: NEvent) {
const isThread = ev.Tags.some((a) => a.Key === "e" && a.Marker !== "mention");
if (!isThread) {
return null;
}
const shouldWriteMarkers = ev.Kind === EventKind.TextNote;
const ret = new Thread();
const eTags = ev.Tags.filter((a) => a.Key === "e");
const marked = eTags.some((a) => a.Marker !== undefined);
if (!marked) {
ret.Root = eTags[0];
ret.Root.Marker = shouldWriteMarkers ? "root" : undefined;
if (eTags.length > 1) {
ret.ReplyTo = eTags[1];
ret.ReplyTo.Marker = shouldWriteMarkers ? "reply" : undefined;
}
if (eTags.length > 2) {
ret.Mentions = eTags.slice(2);
if (shouldWriteMarkers) {
ret.Mentions.forEach((a) => (a.Marker = "mention"));
}
}
} else {
const root = eTags.find((a) => a.Marker === "root");
const reply = eTags.find((a) => a.Marker === "reply");
ret.Root = root;
ret.ReplyTo = reply;
ret.Mentions = eTags.filter((a) => a.Marker === "mention");
}
ret.PubKeys = Array.from(
new Set(ev.Tags.filter((a) => a.Key === "p").map((a) => <u256>a.PubKey))
);
return ret;
}
}

View File

@ -24,3 +24,11 @@ export function hexToBech32(hrp: string, hex?: string) {
return "";
}
}
export function sanitizeRelayUrl(url: string) {
try {
return new URL(url).toString();
} catch {
// ignore
}
}

View File

@ -1,16 +1,16 @@
export * from "./Connection";
export { default as EventKind } from "./EventKind";
export { Subscriptions } from "./Subscriptions";
export { default as Event } from "./Event";
export { default as Tag } from "./Tag";
export * from "./Links";
export * from "./Nips";
import { RelaySettings } from ".";
export type RawEvent = {
id: u256;
pubkey: HexKey;
created_at: number;
kind: number;
tags: string[][];
tags: Array<Array<string>>;
content: string;
sig: string;
};
@ -37,6 +37,8 @@ export type MaybeHexKey = HexKey | undefined;
*/
export type u256 = string;
export type ReqCommand = [cmd: "REQ", id: string, ...filters: Array<RawReqFilter>];
/**
* Raw REQ filter object
*/
@ -83,5 +85,5 @@ export enum Lists {
export interface FullRelaySettings {
url: string;
settings: { read: boolean; write: boolean };
settings: RelaySettings;
}

View File

@ -2166,9 +2166,9 @@
"@types/istanbul-lib-report" "*"
"@types/jest@^29.2.5":
version "29.4.0"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.4.0.tgz#a8444ad1704493e84dbf07bb05990b275b3b9206"
integrity sha512-VaywcGQ9tPorCX/Jkkni7RWGFfI11whqzs8dvxF41P17Z+z872thvEvlIbznjPJ02kl1HMX3LmLOonsj2n7HeQ==
version "29.5.0"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.0.tgz#337b90bbcfe42158f39c2fb5619ad044bbb518ac"
integrity sha512-3Emr5VOl/aoBwnWcH/EFQvlSAmjV+XtV9GGu5mwdYew5vhQh0IUZx/60x0TzHDu09Bi7HMx10t/namdJw5QIcg==
dependencies:
expect "^29.0.0"
pretty-format "^29.0.0"
@ -9768,6 +9768,11 @@ throat@^6.0.1:
resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.2.tgz#51a3fbb5e11ae72e2cf74861ed5c8020f89f29fe"
integrity sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==
throttle-debounce@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-5.0.0.tgz#a17a4039e82a2ed38a5e7268e4132d6960d41933"
integrity sha512-2iQTSgkkc1Zyk0MeVrt/3BvuOXYPl/R8Z0U2xxo9rjwNciaHDG3R+Lm6dh4EeUci49DanvBnuqI6jshoQQRGEg==
through@^2.3.8:
version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"