refactor: RequestBuilder
This commit is contained in:
parent
1bf6c7031e
commit
465c59ea20
18
.github/workflows/eslint.yaml
vendored
18
.github/workflows/eslint.yaml
vendored
@ -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
|
@ -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 .
|
@ -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": {
|
||||
|
@ -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);
|
||||
|
@ -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) {
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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}
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
14
packages/app/src/Element/SubDebug.css
Normal file
14
packages/app/src/Element/SubDebug.css
Normal 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;
|
||||
}
|
104
packages/app/src/Element/SubDebug.tsx
Normal file
104
packages/app/src/Element/SubDebug.tsx
Normal 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">
|
||||
Filters: {q.filters.length} ({countElements(q.filters)} elements)
|
||||
</span>
|
||||
<br />
|
||||
<span onClick={() => copy(JSON.stringify(q.subFilters))} className="pointer">
|
||||
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;
|
@ -5,6 +5,7 @@ export interface Tab {
|
||||
text: string;
|
||||
value: number;
|
||||
disabled?: boolean;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
|
@ -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] ?? ""} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 [];
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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 ?? [];
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
@ -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))];
|
||||
|
@ -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())];
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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[];
|
||||
|
73
packages/app/src/Feed/RelaysFeedFollows.tsx
Normal file
73
packages/app/src/Feed/RelaysFeedFollows.tsx
Normal 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]);
|
||||
}
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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 };
|
||||
}
|
46
packages/app/src/Hooks/useNotelistSubscription.ts
Normal file
46
packages/app/src/Hooks/useNotelistSubscription.ts
Normal 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 ?? [];
|
||||
}
|
65
packages/app/src/Hooks/useRelaysForFollows.tsx
Normal file
65
packages/app/src/Hooks/useRelaysForFollows.tsx
Normal 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]);
|
||||
}
|
50
packages/app/src/Hooks/useRequestBuilder.tsx
Normal file
50
packages/app/src/Hooks/useRequestBuilder.tsx
Normal 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;
|
9
packages/app/src/Hooks/useSystemState.tsx
Normal file
9
packages/app/src/Hooks/useSystemState.tsx
Normal 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()
|
||||
);
|
||||
}
|
18
packages/app/src/Hooks/useTimelineWindow.tsx
Normal file
18
packages/app/src/Hooks/useTimelineWindow.tsx
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
@ -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]);
|
||||
|
||||
|
@ -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}`;
|
||||
|
@ -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>;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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)}>
|
||||
|
@ -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[];
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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" },
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
83
packages/app/src/State/Relays/index.ts
Normal file
83
packages/app/src/State/Relays/index.ts
Normal 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();
|
@ -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();
|
165
packages/app/src/System/EventExt.ts
Normal file
165
packages/app/src/System/EventExt.ts
Normal 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"]);
|
||||
}
|
||||
}
|
52
packages/app/src/System/NoteCollection.test.ts
Normal file
52
packages/app/src/System/NoteCollection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
273
packages/app/src/System/NoteCollection.ts
Normal file
273
packages/app/src/System/NoteCollection.ts
Normal 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}`;
|
||||
});
|
||||
}
|
||||
}
|
100
packages/app/src/System/ProfileCache.ts
Normal file
100
packages/app/src/System/ProfileCache.ts
Normal 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();
|
91
packages/app/src/System/Query.ts
Normal file
91
packages/app/src/System/Query.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
64
packages/app/src/System/RequestBuilder.test.ts
Normal file
64
packages/app/src/System/RequestBuilder.test.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
148
packages/app/src/System/RequestBuilder.ts
Normal file
148
packages/app/src/System/RequestBuilder.ts
Normal 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;
|
||||
}
|
||||
}
|
74
packages/app/src/System/RequestSplitter.test.ts
Normal file
74
packages/app/src/System/RequestSplitter.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
43
packages/app/src/System/RequestSplitter.ts
Normal file
43
packages/app/src/System/RequestSplitter.ts
Normal 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,
|
||||
};
|
||||
}
|
334
packages/app/src/System/index.ts
Normal file
334
packages/app/src/System/index.ts
Normal 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();
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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}}",
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
@ -1,5 +1,3 @@
|
||||
enum Nips {
|
||||
export enum Nips {
|
||||
Search = 50,
|
||||
}
|
||||
|
||||
export default Nips;
|
||||
|
@ -8,5 +8,8 @@ export interface RelayInfo {
|
||||
version?: string;
|
||||
limitation?: {
|
||||
payment_required: boolean;
|
||||
max_subscriptions: number;
|
||||
max_filters: number;
|
||||
max_event_tags: number;
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
11
yarn.lock
11
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user