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:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
jobs:
|
jobs:
|
||||||
formatting:
|
test_and_lint:
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@ -14,5 +14,11 @@ jobs:
|
|||||||
node-version: 16
|
node-version: 16
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: yarn install
|
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
|
- name: Check Formatting
|
||||||
run: yarn workspace @snort/app prettier --check .
|
run: yarn workspace @snort/app prettier --check .
|
@ -20,6 +20,7 @@
|
|||||||
"bech32": "^2.0.0",
|
"bech32": "^2.0.0",
|
||||||
"dexie": "^3.2.2",
|
"dexie": "^3.2.2",
|
||||||
"dexie-react-hooks": "^1.1.1",
|
"dexie-react-hooks": "^1.1.1",
|
||||||
|
"events": "^3.3.0",
|
||||||
"light-bolt11-decoder": "^2.1.0",
|
"light-bolt11-decoder": "^2.1.0",
|
||||||
"qr-code-styling": "^1.6.0-rc.1",
|
"qr-code-styling": "^1.6.0-rc.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@ -30,9 +31,9 @@
|
|||||||
"react-query": "^3.39.2",
|
"react-query": "^3.39.2",
|
||||||
"react-redux": "^8.0.5",
|
"react-redux": "^8.0.5",
|
||||||
"react-router-dom": "^6.5.0",
|
"react-router-dom": "^6.5.0",
|
||||||
"react-scripts": "5.0.1",
|
|
||||||
"react-textarea-autosize": "^8.4.0",
|
"react-textarea-autosize": "^8.4.0",
|
||||||
"react-twitter-embed": "^4.0.4",
|
"react-twitter-embed": "^4.0.4",
|
||||||
|
"throttle-debounce": "^5.0.0",
|
||||||
"unist-util-visit": "^4.1.2",
|
"unist-util-visit": "^4.1.2",
|
||||||
"use-long-press": "^2.0.3",
|
"use-long-press": "^2.0.3",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
@ -94,6 +95,7 @@
|
|||||||
"lint-staged": ">=10",
|
"lint-staged": ">=10",
|
||||||
"prettier": "2.8.3",
|
"prettier": "2.8.3",
|
||||||
"react-app-rewired": "^2.2.1",
|
"react-app-rewired": "^2.2.1",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
"typescript": "^4.9.4"
|
"typescript": "^4.9.4"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import Dexie, { Table } from "dexie";
|
import Dexie, { Table } from "dexie";
|
||||||
import { u256 } from "@snort/nostr";
|
import { FullRelaySettings, HexKey, u256 } from "@snort/nostr";
|
||||||
import { MetadataCache } from "State/Users";
|
import { MetadataCache } from "State/Users";
|
||||||
|
|
||||||
export const NAME = "snortDB";
|
export const NAME = "snortDB";
|
||||||
export const VERSION = 4;
|
export const VERSION = 6;
|
||||||
|
|
||||||
export interface SubCache {
|
export interface SubCache {
|
||||||
id: string;
|
id: string;
|
||||||
@ -12,13 +12,29 @@ export interface SubCache {
|
|||||||
since?: number;
|
since?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RelayMetrics {
|
||||||
|
addr: string;
|
||||||
|
events: number;
|
||||||
|
disconnects: number;
|
||||||
|
latency: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsersRelays {
|
||||||
|
pubkey: HexKey;
|
||||||
|
relays: FullRelaySettings[];
|
||||||
|
}
|
||||||
|
|
||||||
const STORES = {
|
const STORES = {
|
||||||
users: "++pubkey, name, display_name, picture, nip05, npub",
|
users: "++pubkey, name, display_name, picture, nip05, npub",
|
||||||
|
relays: "++addr",
|
||||||
|
userRelays: "++pubkey",
|
||||||
};
|
};
|
||||||
|
|
||||||
export class SnortDB extends Dexie {
|
export class SnortDB extends Dexie {
|
||||||
ready = false;
|
ready = false;
|
||||||
users!: Table<MetadataCache>;
|
users!: Table<MetadataCache>;
|
||||||
|
relayMetrics!: Table<RelayMetrics>;
|
||||||
|
userRelays!: Table<UsersRelays>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(NAME);
|
super(NAME);
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { useState, useMemo, ChangeEvent } from "react";
|
import { useState, useMemo, ChangeEvent } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
import { dedupeByPubkey } from "Util";
|
|
||||||
import Note from "Element/Note";
|
|
||||||
import { HexKey, TaggedRawEvent } from "@snort/nostr";
|
import { HexKey, TaggedRawEvent } from "@snort/nostr";
|
||||||
|
|
||||||
|
import Note from "Element/Note";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { UserCache } from "State/Users/UserCache";
|
import { UserCache } from "State/Users/UserCache";
|
||||||
|
|
||||||
@ -12,15 +11,15 @@ import messages from "./messages";
|
|||||||
|
|
||||||
interface BookmarksProps {
|
interface BookmarksProps {
|
||||||
pubkey: HexKey;
|
pubkey: HexKey;
|
||||||
bookmarks: TaggedRawEvent[];
|
bookmarks: readonly TaggedRawEvent[];
|
||||||
related: TaggedRawEvent[];
|
related: readonly TaggedRawEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
|
const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
|
||||||
const [onlyPubkey, setOnlyPubkey] = useState<HexKey | "all">("all");
|
const [onlyPubkey, setOnlyPubkey] = useState<HexKey | "all">("all");
|
||||||
const loginPubKey = useSelector((s: RootState) => s.login.publicKey);
|
const loginPubKey = useSelector((s: RootState) => s.login.publicKey);
|
||||||
const ps = useMemo(() => {
|
const ps = useMemo(() => {
|
||||||
return dedupeByPubkey(bookmarks).map(ev => ev.pubkey);
|
return [...new Set(bookmarks.map(ev => ev.pubkey))];
|
||||||
}, [bookmarks]);
|
}, [bookmarks]);
|
||||||
|
|
||||||
function renderOption(p: HexKey) {
|
function renderOption(p: HexKey) {
|
||||||
|
@ -3,14 +3,13 @@ import { useEffect, useState } from "react";
|
|||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { useIntl } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
|
import { HexKey, TaggedRawEvent } from "@snort/nostr";
|
||||||
|
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import { Event } from "@snort/nostr";
|
|
||||||
import NoteTime from "Element/NoteTime";
|
import NoteTime from "Element/NoteTime";
|
||||||
import Text from "Element/Text";
|
import Text from "Element/Text";
|
||||||
import { setLastReadDm } from "Pages/MessagesPage";
|
import { setLastReadDm } from "Pages/MessagesPage";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { HexKey, TaggedRawEvent } from "@snort/nostr";
|
|
||||||
import { incDmInteraction } from "State/Login";
|
import { incDmInteraction } from "State/Login";
|
||||||
import { unwrap } from "Util";
|
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]);
|
const otherPubkey = isMe ? pubKey : unwrap(props.data.tags.find(a => a[0] === "p")?.[1]);
|
||||||
|
|
||||||
async function decrypt() {
|
async function decrypt() {
|
||||||
const e = new Event(props.data);
|
const decrypted = await publisher.decryptDm(props.data);
|
||||||
const decrypted = await publisher.decryptDm(e);
|
|
||||||
setContent(decrypted || "<ERROR>");
|
setContent(decrypted || "<ERROR>");
|
||||||
if (!isMe) {
|
if (!isMe) {
|
||||||
setLastReadDm(e.PubKey);
|
setLastReadDm(props.data.pubkey);
|
||||||
dispatch(incDmInteraction());
|
dispatch(incDmInteraction());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -111,25 +111,12 @@ export default function HyperText({ link, creator }: { link: string; creator: He
|
|||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (tweetId && !pref.rewriteTwitterPosts) {
|
} else if (tweetId) {
|
||||||
return (
|
return (
|
||||||
<div className="tweet" key={tweetId}>
|
<div className="tweet" key={tweetId}>
|
||||||
<TwitterTweetEmbed tweetId={tweetId} />
|
<TwitterTweetEmbed tweetId={tweetId} />
|
||||||
</div>
|
</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) {
|
} else if (youtubeId) {
|
||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
|
@ -4,6 +4,7 @@ import { useNavigate, Link } from "react-router-dom";
|
|||||||
import { useSelector, useDispatch } from "react-redux";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
import { useIntl, FormattedMessage } from "react-intl";
|
import { useIntl, FormattedMessage } from "react-intl";
|
||||||
|
import { TaggedRawEvent, HexKey, EventKind, NostrPrefix } from "@snort/nostr";
|
||||||
|
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import Icon from "Icons/Icon";
|
import Icon from "Icons/Icon";
|
||||||
@ -19,22 +20,21 @@ import {
|
|||||||
normalizeReaction,
|
normalizeReaction,
|
||||||
Reaction,
|
Reaction,
|
||||||
profileLink,
|
profileLink,
|
||||||
unwrap,
|
|
||||||
} from "Util";
|
} from "Util";
|
||||||
import NoteFooter, { Translation } from "Element/NoteFooter";
|
import NoteFooter, { Translation } from "Element/NoteFooter";
|
||||||
import NoteTime from "Element/NoteTime";
|
import NoteTime from "Element/NoteTime";
|
||||||
import { TaggedRawEvent, HexKey, Event as NEvent, EventKind, NostrPrefix } from "@snort/nostr";
|
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
import { setPinned, setBookmarked } from "State/Login";
|
import { setPinned, setBookmarked } from "State/Login";
|
||||||
import type { RootState } from "State/Store";
|
import type { RootState } from "State/Store";
|
||||||
import { UserCache } from "State/Users/UserCache";
|
import { UserCache } from "State/Users/UserCache";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
import { EventExt } from "System/EventExt";
|
||||||
|
|
||||||
export interface NoteProps {
|
export interface NoteProps {
|
||||||
data?: TaggedRawEvent;
|
data: TaggedRawEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
related: TaggedRawEvent[];
|
related: readonly TaggedRawEvent[];
|
||||||
highlight?: boolean;
|
highlight?: boolean;
|
||||||
ignoreModeration?: boolean;
|
ignoreModeration?: boolean;
|
||||||
options?: {
|
options?: {
|
||||||
@ -47,7 +47,6 @@ export interface NoteProps {
|
|||||||
canUnpin?: boolean;
|
canUnpin?: boolean;
|
||||||
canUnbookmark?: boolean;
|
canUnbookmark?: boolean;
|
||||||
};
|
};
|
||||||
["data-ev"]?: NEvent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
|
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
|
||||||
@ -71,12 +70,11 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => {
|
|||||||
export default function Note(props: NoteProps) {
|
export default function Note(props: NoteProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useDispatch();
|
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 [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 { isMuted } = useModeration();
|
||||||
const isOpMuted = isMuted(ev.PubKey);
|
const isOpMuted = isMuted(ev?.pubkey);
|
||||||
const { ref, inView, entry } = useInView({ triggerOnce: true });
|
const { ref, inView, entry } = useInView({ triggerOnce: true });
|
||||||
const [extendable, setExtendable] = useState<boolean>(false);
|
const [extendable, setExtendable] = useState<boolean>(false);
|
||||||
const [showMore, setShowMore] = useState<boolean>(false);
|
const [showMore, setShowMore] = useState<boolean>(false);
|
||||||
@ -85,7 +83,7 @@ export default function Note(props: NoteProps) {
|
|||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
const [translated, setTranslated] = useState<Translation>();
|
const [translated, setTranslated] = useState<Translation>();
|
||||||
const { formatMessage } = useIntl();
|
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 groupReactions = useMemo(() => {
|
||||||
const result = reactions?.reduce(
|
const result = reactions?.reduce(
|
||||||
(acc, reaction) => {
|
(acc, reaction) => {
|
||||||
@ -108,15 +106,15 @@ export default function Note(props: NoteProps) {
|
|||||||
const reposts = useMemo(
|
const reposts = useMemo(
|
||||||
() =>
|
() =>
|
||||||
dedupeByPubkey([
|
dedupeByPubkey([
|
||||||
...getReactions(related, ev.Id, EventKind.TextNote).filter(e => e.tags.some(tagFilterOfTextRepost(e, ev.Id))),
|
...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.Repost),
|
||||||
]),
|
]),
|
||||||
[related, ev]
|
[related, ev]
|
||||||
);
|
);
|
||||||
const zaps = useMemo(() => {
|
const zaps = useMemo(() => {
|
||||||
const sortedZaps = getReactions(related, ev.Id, EventKind.ZapReceipt)
|
const sortedZaps = getReactions(related, ev.id, EventKind.ZapReceipt)
|
||||||
.map(parseZap)
|
.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);
|
sortedZaps.sort((a, b) => b.amount - a.amount);
|
||||||
return sortedZaps;
|
return sortedZaps;
|
||||||
}, [related]);
|
}, [related]);
|
||||||
@ -154,7 +152,7 @@ export default function Note(props: NoteProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const transformBody = useCallback(() => {
|
const transformBody = useCallback(() => {
|
||||||
const body = ev?.Content ?? "";
|
const body = ev?.content ?? "";
|
||||||
if (deletions?.length > 0) {
|
if (deletions?.length > 0) {
|
||||||
return (
|
return (
|
||||||
<b className="error">
|
<b className="error">
|
||||||
@ -162,7 +160,7 @@ export default function Note(props: NoteProps) {
|
|||||||
</b>
|
</b>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <Text content={body} tags={ev.Tags} creator={ev.PubKey} />;
|
return <Text content={body} tags={ev.tags} creator={ev.pubkey} />;
|
||||||
}, [ev]);
|
}, [ev]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@ -189,20 +187,23 @@ export default function Note(props: NoteProps) {
|
|||||||
if (e.metaKey) {
|
if (e.metaKey) {
|
||||||
window.open(link, "_blank");
|
window.open(link, "_blank");
|
||||||
} else {
|
} else {
|
||||||
navigate(link);
|
navigate(link, {
|
||||||
|
state: ev,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function replyTag() {
|
function replyTag() {
|
||||||
if (ev.Thread === null) {
|
const thread = EventExt.extractThread(ev);
|
||||||
return null;
|
if (thread === undefined) {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxMentions = 2;
|
const maxMentions = 2;
|
||||||
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
const replyId = thread?.replyTo?.Event ?? thread?.root?.Event;
|
||||||
const replyRelayHints = ev?.Thread?.ReplyTo?.Relay ?? ev.Thread.Root?.Relay;
|
const replyRelayHints = thread?.replyTo?.Relay ?? thread.root?.Relay;
|
||||||
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
|
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 u = UserCache.get(pk);
|
||||||
const npub = hexToBech32(NostrPrefix.PublicKey, pk);
|
const npub = hexToBech32(NostrPrefix.PublicKey, pk);
|
||||||
const shortNpub = npub.substring(0, 12);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4>
|
<h4>
|
||||||
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.Kind }} />
|
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.kind }} />
|
||||||
</h4>
|
</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() {
|
function content() {
|
||||||
if (!inView) return null;
|
if (!inView) return undefined;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{options.showHeader && (
|
{options.showHeader && (
|
||||||
<div className="header flex">
|
<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) && (
|
{(options.showTime || options.showBookmarked) && (
|
||||||
<div className="info">
|
<div className="info">
|
||||||
{options.showBookmarked && (
|
{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} />
|
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!options.showBookmarked && <NoteTime from={ev.CreatedAt * 1000} />}
|
{!options.showBookmarked && <NoteTime from={ev.created_at * 1000} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{options.showPinned && (
|
{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} />
|
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="body" onClick={e => goToEvent(e, unwrap(ev.Original), true)}>
|
<div className="body" onClick={e => goToEvent(e, ev, true)}>
|
||||||
{transformBody()}
|
{transformBody()}
|
||||||
{translation()}
|
{translation()}
|
||||||
{options.showReactionsLink && (
|
{options.showReactionsLink && (
|
||||||
@ -330,7 +331,7 @@ export default function Note(props: NoteProps) {
|
|||||||
const note = (
|
const note = (
|
||||||
<div
|
<div
|
||||||
className={`${baseClassName}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`}
|
className={`${baseClassName}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`}
|
||||||
onClick={e => goToEvent(e, unwrap(ev.Original))}
|
onClick={e => goToEvent(e, ev)}
|
||||||
ref={ref}>
|
ref={ref}>
|
||||||
{content()}
|
{content()}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import "./NoteCreator.css";
|
import "./NoteCreator.css";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import { TaggedRawEvent } from "@snort/nostr";
|
||||||
|
|
||||||
import Icon from "Icons/Icon";
|
import Icon from "Icons/Icon";
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
@ -8,22 +9,21 @@ import { openFile } from "Util";
|
|||||||
import Textarea from "Element/Textarea";
|
import Textarea from "Element/Textarea";
|
||||||
import Modal from "Element/Modal";
|
import Modal from "Element/Modal";
|
||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
import { Event as NEvent } from "@snort/nostr";
|
|
||||||
import useFileUpload from "Upload";
|
import useFileUpload from "Upload";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
|
||||||
interface NotePreviewProps {
|
interface NotePreviewProps {
|
||||||
note: NEvent;
|
note: TaggedRawEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotePreview({ note }: NotePreviewProps) {
|
function NotePreview({ note }: NotePreviewProps) {
|
||||||
return (
|
return (
|
||||||
<div className="note-preview">
|
<div className="note-preview">
|
||||||
<ProfileImage pubkey={note.PubKey} />
|
<ProfileImage pubkey={note.pubkey} />
|
||||||
<div className="note-preview-body">
|
<div className="note-preview-body">
|
||||||
{note.Content.slice(0, 136)}
|
{note.content.slice(0, 136)}
|
||||||
{note.Content.length > 140 && "..."}
|
{note.content.length > 140 && "..."}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -32,7 +32,7 @@ function NotePreview({ note }: NotePreviewProps) {
|
|||||||
export interface NoteCreatorProps {
|
export interface NoteCreatorProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
setShow: (s: boolean) => void;
|
setShow: (s: boolean) => void;
|
||||||
replyTo?: NEvent;
|
replyTo?: TaggedRawEvent;
|
||||||
onSend?: () => void;
|
onSend?: () => void;
|
||||||
autoFocus: boolean;
|
autoFocus: boolean;
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { useSelector, useDispatch } from "react-redux";
|
|||||||
import { useIntl, FormattedMessage } from "react-intl";
|
import { useIntl, FormattedMessage } from "react-intl";
|
||||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||||
import { useLongPress } from "use-long-press";
|
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 Icon from "Icons/Icon";
|
||||||
import Spinner from "Icons/Spinner";
|
import Spinner from "Icons/Spinner";
|
||||||
@ -85,7 +85,7 @@ export interface NoteFooterProps {
|
|||||||
negative: TaggedRawEvent[];
|
negative: TaggedRawEvent[];
|
||||||
showReactions: boolean;
|
showReactions: boolean;
|
||||||
setShowReactions(b: boolean): void;
|
setShowReactions(b: boolean): void;
|
||||||
ev: NEvent;
|
ev: TaggedRawEvent;
|
||||||
onTranslated?: (content: Translation) => void;
|
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 login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||||
const { mute, block } = useModeration();
|
const { mute, block } = useModeration();
|
||||||
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
||||||
const author = useUserProfile(ev.RootPubKey);
|
const author = useUserProfile(ev.pubkey);
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
const [reply, setReply] = useState(false);
|
const [reply, setReply] = useState(false);
|
||||||
const [tip, setTip] = useState(false);
|
const [tip, setTip] = useState(false);
|
||||||
@ -105,13 +105,13 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
const walletState = useWallet();
|
const walletState = useWallet();
|
||||||
const wallet = walletState.wallet;
|
const wallet = walletState.wallet;
|
||||||
|
|
||||||
const isMine = ev.RootPubKey === login;
|
const isMine = ev.pubkey === login;
|
||||||
const lang = window.navigator.language;
|
const lang = window.navigator.language;
|
||||||
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
|
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
|
||||||
type: "language",
|
type: "language",
|
||||||
});
|
});
|
||||||
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
|
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(
|
const longPress = useLongPress(
|
||||||
e => {
|
e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -138,15 +138,15 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEvent() {
|
async function deleteEvent() {
|
||||||
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.Id.substring(0, 8) }))) {
|
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) }))) {
|
||||||
const evDelete = await publisher.delete(ev.Id);
|
const evDelete = await publisher.delete(ev.id);
|
||||||
publisher.broadcast(evDelete);
|
publisher.broadcast(evDelete);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function repost() {
|
async function repost() {
|
||||||
if (!hasReposted()) {
|
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);
|
const evRepost = await publisher.repost(ev);
|
||||||
publisher.broadcast(evRepost);
|
publisher.broadcast(evRepost);
|
||||||
}
|
}
|
||||||
@ -160,7 +160,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
if (wallet?.isReady() && lnurl) {
|
if (wallet?.isReady() && lnurl) {
|
||||||
setZapping(true);
|
setZapping(true);
|
||||||
try {
|
try {
|
||||||
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.PubKey, ev.Id);
|
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id);
|
||||||
fastZapDonate();
|
fastZapDonate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Fast zap failed", e);
|
console.warn("Fast zap failed", e);
|
||||||
@ -202,14 +202,14 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
const lnurl = author?.lud16 || author?.lud06;
|
||||||
if (wallet?.isReady() && lnurl) {
|
if (wallet?.isReady() && lnurl) {
|
||||||
setZapping(true);
|
setZapping(true);
|
||||||
queueMicrotask(async () => {
|
queueMicrotask(async () => {
|
||||||
try {
|
try {
|
||||||
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.PubKey, ev.Id);
|
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id);
|
||||||
ZapCache.add(ev.Id);
|
ZapCache.add(ev.id);
|
||||||
fastZapDonate();
|
fastZapDonate();
|
||||||
} catch {
|
} catch {
|
||||||
// ignored
|
// ignored
|
||||||
@ -263,7 +263,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function share() {
|
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}`;
|
const url = `${window.location.protocol}//${window.location.host}/e/${link}`;
|
||||||
if ("share" in window.navigator) {
|
if ("share" in window.navigator) {
|
||||||
await window.navigator.share({
|
await window.navigator.share({
|
||||||
@ -279,7 +279,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
const res = await fetch(`${TranslateHost}/translate`, {
|
const res = await fetch(`${TranslateHost}/translate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
q: ev.Content,
|
q: ev.content,
|
||||||
source: "auto",
|
source: "auto",
|
||||||
target: lang.split("-")[0],
|
target: lang.split("-")[0],
|
||||||
}),
|
}),
|
||||||
@ -299,7 +299,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyId() {
|
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);
|
await navigator.clipboard.writeText(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -318,7 +318,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyEvent() {
|
async function copyEvent() {
|
||||||
await navigator.clipboard.writeText(JSON.stringify(ev.Original, undefined, " "));
|
await navigator.clipboard.writeText(JSON.stringify(ev, undefined, " "));
|
||||||
}
|
}
|
||||||
|
|
||||||
function menuItems() {
|
function menuItems() {
|
||||||
@ -339,14 +339,14 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
<Icon name="share" />
|
<Icon name="share" />
|
||||||
<FormattedMessage {...messages.Share} />
|
<FormattedMessage {...messages.Share} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{!pinned.includes(ev.Id) && (
|
{!pinned.includes(ev.id) && (
|
||||||
<MenuItem onClick={() => pin(ev.Id)}>
|
<MenuItem onClick={() => pin(ev.id)}>
|
||||||
<Icon name="pin" />
|
<Icon name="pin" />
|
||||||
<FormattedMessage {...messages.Pin} />
|
<FormattedMessage {...messages.Pin} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{!bookmarked.includes(ev.Id) && (
|
{!bookmarked.includes(ev.id) && (
|
||||||
<MenuItem onClick={() => bookmark(ev.Id)}>
|
<MenuItem onClick={() => bookmark(ev.id)}>
|
||||||
<Icon name="bookmark" />
|
<Icon name="bookmark" />
|
||||||
<FormattedMessage {...messages.Bookmark} />
|
<FormattedMessage {...messages.Bookmark} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -355,7 +355,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
<Icon name="copy" />
|
<Icon name="copy" />
|
||||||
<FormattedMessage {...messages.CopyID} />
|
<FormattedMessage {...messages.CopyID} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={() => mute(ev.PubKey)}>
|
<MenuItem onClick={() => mute(ev.pubkey)}>
|
||||||
<Icon name="mute" />
|
<Icon name="mute" />
|
||||||
<FormattedMessage {...messages.Mute} />
|
<FormattedMessage {...messages.Mute} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -365,7 +365,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
<FormattedMessage {...messages.DislikeAction} />
|
<FormattedMessage {...messages.DislikeAction} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
<MenuItem onClick={() => block(ev.PubKey)}>
|
<MenuItem onClick={() => block(ev.pubkey)}>
|
||||||
<Icon name="block" />
|
<Icon name="block" />
|
||||||
<FormattedMessage {...messages.Block} />
|
<FormattedMessage {...messages.Block} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -423,7 +423,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
show={tip}
|
show={tip}
|
||||||
author={author?.pubkey}
|
author={author?.pubkey}
|
||||||
target={author?.display_name || author?.name}
|
target={author?.display_name || author?.name}
|
||||||
note={ev.Id}
|
note={ev.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="zaps-container">
|
<div className="zaps-container">
|
||||||
|
@ -1,28 +1,26 @@
|
|||||||
import "./NoteReaction.css";
|
import "./NoteReaction.css";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useMemo } from "react";
|
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 Note from "Element/Note";
|
||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
import { eventLink, hexToBech32 } from "Util";
|
import { eventLink, hexToBech32 } from "Util";
|
||||||
import NoteTime from "Element/NoteTime";
|
import NoteTime from "Element/NoteTime";
|
||||||
import { RawEvent, TaggedRawEvent } from "@snort/nostr";
|
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
|
import { EventExt } from "System/EventExt";
|
||||||
|
|
||||||
export interface NoteReactionProps {
|
export interface NoteReactionProps {
|
||||||
data?: TaggedRawEvent;
|
data: TaggedRawEvent;
|
||||||
["data-ev"]?: NEvent;
|
|
||||||
root?: TaggedRawEvent;
|
root?: TaggedRawEvent;
|
||||||
}
|
}
|
||||||
export default function NoteReaction(props: NoteReactionProps) {
|
export default function NoteReaction(props: NoteReactionProps) {
|
||||||
const { ["data-ev"]: dataEv, data } = props;
|
const { data: ev } = props;
|
||||||
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]);
|
|
||||||
const { isMuted } = useModeration();
|
const { isMuted } = useModeration();
|
||||||
|
|
||||||
const refEvent = useMemo(() => {
|
const refEvent = useMemo(() => {
|
||||||
if (ev) {
|
if (ev) {
|
||||||
const eTags = ev.Tags.filter(a => a.Key === "e");
|
const eTags = ev.tags.filter(a => a[0] === "e");
|
||||||
if (eTags.length > 0) {
|
if (eTags.length > 0) {
|
||||||
return eTags[0];
|
return eTags[0];
|
||||||
}
|
}
|
||||||
@ -31,10 +29,10 @@ export default function NoteReaction(props: NoteReactionProps) {
|
|||||||
}, [ev]);
|
}, [ev]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
ev.Kind !== EventKind.Reaction &&
|
ev.kind !== EventKind.Reaction &&
|
||||||
ev.Kind !== EventKind.Repost &&
|
ev.kind !== EventKind.Repost &&
|
||||||
(ev.Kind !== EventKind.TextNote ||
|
(ev.kind !== EventKind.TextNote ||
|
||||||
ev.Tags.every((a, i) => a.Event !== refEvent?.Event || a.Marker !== "mention" || ev.Content !== `#[${i}]`))
|
ev.tags.every((a, i) => a[1] !== refEvent?.[1] || a[3] !== "mention" || ev.content !== `#[${i}]`))
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -43,9 +41,9 @@ export default function NoteReaction(props: NoteReactionProps) {
|
|||||||
* Some clients embed the reposted note in the content
|
* Some clients embed the reposted note in the content
|
||||||
*/
|
*/
|
||||||
function extractRoot() {
|
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 {
|
try {
|
||||||
const r: RawEvent = JSON.parse(ev.Content);
|
const r: RawEvent = JSON.parse(ev.content);
|
||||||
return r as TaggedRawEvent;
|
return r as TaggedRawEvent;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Could not load reposted content", 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 isOpMuted = root && isMuted(root.pubkey);
|
||||||
const shouldNotBeRendered = isOpMuted || root?.kind !== EventKind.TextNote;
|
const shouldNotBeRendered = isOpMuted || root?.kind !== EventKind.TextNote;
|
||||||
const opt = {
|
const opt = {
|
||||||
showHeader: ev?.Kind === EventKind.Repost || ev?.Kind === EventKind.TextNote,
|
showHeader: ev?.kind === EventKind.Repost || ev?.kind === EventKind.TextNote,
|
||||||
showFooter: false,
|
showFooter: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
return shouldNotBeRendered ? null : (
|
return shouldNotBeRendered ? null : (
|
||||||
<div className="reaction">
|
<div className="reaction">
|
||||||
<div className="header flex">
|
<div className="header flex">
|
||||||
<ProfileImage pubkey={ev.RootPubKey} />
|
<ProfileImage pubkey={EventExt.getRootPubKey(ev)} />
|
||||||
<div className="info">
|
<div className="info">
|
||||||
<NoteTime from={ev.CreatedAt * 1000} />
|
<NoteTime from={ev.created_at * 1000} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{root ? <Note data={root} options={opt} related={[]} /> : null}
|
{root ? <Note data={root} options={opt} related={[]} /> : null}
|
||||||
{!root && refEvent ? (
|
{!root && refEvent ? (
|
||||||
<p>
|
<p>
|
||||||
<Link to={eventLink(refEvent.Event ?? "", refEvent.Relay)}>
|
<Link to={eventLink(refEvent[1] ?? "", refEvent[2])}>
|
||||||
#{hexToBech32(NostrPrefix.Event, refEvent.Event).substring(0, 12)}
|
#{hexToBech32(NostrPrefix.Event, refEvent[1]).substring(0, 12)}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import "./Relay.css";
|
import "./Relay.css";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useIntl, FormattedMessage } from "react-intl";
|
import { useIntl, FormattedMessage } from "react-intl";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import {
|
||||||
faPlug,
|
faPlug,
|
||||||
faSquareCheck,
|
faSquareCheck,
|
||||||
@ -9,16 +12,15 @@ import {
|
|||||||
faPlugCircleXmark,
|
faPlugCircleXmark,
|
||||||
faGear,
|
faGear,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} 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 { 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 messages from "./messages";
|
||||||
import { getRelayName } from "Util";
|
|
||||||
|
|
||||||
export interface RelayProps {
|
export interface RelayProps {
|
||||||
addr: string;
|
addr: string;
|
||||||
@ -29,7 +31,7 @@ export default function Relay(props: RelayProps) {
|
|||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
|
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 state = useRelayState(props.addr);
|
||||||
const name = useMemo(() => getRelayName(props.addr), [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 React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useIntl, FormattedMessage } from "react-intl";
|
import { useIntl, FormattedMessage } from "react-intl";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
import { HexKey, RawEvent } from "@snort/nostr";
|
||||||
|
|
||||||
import { formatShort } from "Number";
|
import { formatShort } from "Number";
|
||||||
import { Event, HexKey, Tag } from "@snort/nostr";
|
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import Icon from "Icons/Icon";
|
import Icon from "Icons/Icon";
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
@ -14,9 +14,10 @@ import QrCode from "Element/QrCode";
|
|||||||
import Copy from "Element/Copy";
|
import Copy from "Element/Copy";
|
||||||
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
|
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
|
||||||
import { chunks, debounce } from "Util";
|
import { chunks, debounce } from "Util";
|
||||||
|
import { useWallet } from "Wallet";
|
||||||
|
import { EventExt } from "System/EventExt";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
import { useWallet } from "Wallet";
|
|
||||||
|
|
||||||
enum ZapType {
|
enum ZapType {
|
||||||
PublicZap = 1,
|
PublicZap = 1,
|
||||||
@ -120,7 +121,7 @@ export default function SendSats(props: SendSatsProps) {
|
|||||||
async function loadInvoice() {
|
async function loadInvoice() {
|
||||||
if (!amount || !handler) return null;
|
if (!amount || !handler) return null;
|
||||||
|
|
||||||
let zap: Event | undefined;
|
let zap: RawEvent | undefined;
|
||||||
if (author && zapType !== ZapType.NonZap) {
|
if (author && zapType !== ZapType.NonZap) {
|
||||||
const ev = await publisher.zap(amount * 1000, author, note, comment);
|
const ev = await publisher.zap(amount * 1000, author, note, comment);
|
||||||
if (ev) {
|
if (ev) {
|
||||||
@ -128,10 +129,10 @@ export default function SendSats(props: SendSatsProps) {
|
|||||||
if (zapType === ZapType.AnonZap) {
|
if (zapType === ZapType.AnonZap) {
|
||||||
const randomKey = publisher.newKey();
|
const randomKey = publisher.newKey();
|
||||||
console.debug("Generated new key for zap: ", randomKey);
|
console.debug("Generated new key for zap: ", randomKey);
|
||||||
ev.PubKey = randomKey.publicKey;
|
ev.pubkey = randomKey.publicKey;
|
||||||
ev.Id = "";
|
ev.id = "";
|
||||||
ev.Tags.push(new Tag(["anon"], ev.Tags.length));
|
ev.tags.push(["anon"]);
|
||||||
await ev.Sign(randomKey.privateKey);
|
await EventExt.sign(ev, randomKey.privateKey);
|
||||||
}
|
}
|
||||||
zap = ev;
|
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;
|
text: string;
|
||||||
value: number;
|
value: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
data?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabsProps {
|
interface TabsProps {
|
||||||
|
@ -4,7 +4,7 @@ import { Link, useLocation } from "react-router-dom";
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { visit, SKIP } from "unist-util-visit";
|
import { visit, SKIP } from "unist-util-visit";
|
||||||
import * as unist from "unist";
|
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 { MentionRegex, InvoiceRegex, HashtagRegex, MagnetRegex } from "Const";
|
||||||
import { eventLink, hexToBech32, magnetURIDecode, splitByUrl, unwrap } from "Util";
|
import { eventLink, hexToBech32, magnetURIDecode, splitByUrl, unwrap } from "Util";
|
||||||
@ -18,13 +18,13 @@ export type Fragment = string | React.ReactNode;
|
|||||||
|
|
||||||
export interface TextFragment {
|
export interface TextFragment {
|
||||||
body: React.ReactNode[];
|
body: React.ReactNode[];
|
||||||
tags: Tag[];
|
tags: Array<Array<string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextProps {
|
export interface TextProps {
|
||||||
content: string;
|
content: string;
|
||||||
creator: HexKey;
|
creator: HexKey;
|
||||||
tags: Tag[];
|
tags: Array<Array<string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Text({ content, tags, creator }: TextProps) {
|
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+)\]/);
|
const matchTag = match.match(/#\[(\d+)\]/);
|
||||||
if (matchTag && matchTag.length === 2) {
|
if (matchTag && matchTag.length === 2) {
|
||||||
const idx = parseInt(matchTag[1]);
|
const idx = parseInt(matchTag[1]);
|
||||||
const ref = frag.tags?.find(a => a.Index === idx);
|
const ref = frag.tags?.[idx];
|
||||||
if (ref) {
|
if (ref) {
|
||||||
switch (ref.Key) {
|
switch (ref[0]) {
|
||||||
case "p": {
|
case "p": {
|
||||||
return <Mention pubkey={ref.PubKey ?? ""} relays={ref.Relay} />;
|
return <Mention pubkey={ref[1] ?? ""} relays={ref[2]} />;
|
||||||
}
|
}
|
||||||
case "e": {
|
case "e": {
|
||||||
const eText = hexToBech32(NostrPrefix.Event, ref.Event).substring(0, 12);
|
const eText = hexToBech32(NostrPrefix.Event, ref[1]).substring(0, 12);
|
||||||
return ref.Event ? (
|
return (
|
||||||
<Link
|
ref[1] && (
|
||||||
to={eventLink(ref.Event, ref.Relay)}
|
<Link
|
||||||
onClick={e => e.stopPropagation()}
|
to={eventLink(ref[1], ref[2])}
|
||||||
state={{ from: location.pathname }}>
|
onClick={e => e.stopPropagation()}
|
||||||
#{eText}
|
state={{ from: location.pathname }}>
|
||||||
</Link>
|
#{eText}
|
||||||
) : (
|
</Link>
|
||||||
""
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "t": {
|
case "t": {
|
||||||
return <Hashtag tag={ref.Hashtag ?? ""} />;
|
return <Hashtag tag={ref[1] ?? ""} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,18 @@
|
|||||||
import "./Thread.css";
|
import "./Thread.css";
|
||||||
import { useMemo, useState, useEffect, ReactNode } from "react";
|
import { useMemo, useState, ReactNode } from "react";
|
||||||
import { useIntl, FormattedMessage } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
import { useNavigate, useLocation, Link } from "react-router-dom";
|
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 { eventLink, unwrap, getReactions, parseNostrLink, getAllReactions } from "Util";
|
||||||
import { Event as NEvent, EventKind } from "@snort/nostr";
|
|
||||||
import { eventLink, bech32ToHex, unwrap } from "Util";
|
|
||||||
import BackButton from "Element/BackButton";
|
import BackButton from "Element/BackButton";
|
||||||
import Note from "Element/Note";
|
import Note from "Element/Note";
|
||||||
import NoteGhost from "Element/NoteGhost";
|
import NoteGhost from "Element/NoteGhost";
|
||||||
import Collapsed from "Element/Collapsed";
|
import Collapsed from "Element/Collapsed";
|
||||||
import messages from "./messages";
|
import useThreadFeed from "Feed/ThreadFeed";
|
||||||
|
|
||||||
function getParent(ev: HexKey, chains: Map<HexKey, NEvent[]>): HexKey | undefined {
|
import messages from "./messages";
|
||||||
for (const [k, vs] of chains.entries()) {
|
|
||||||
const fs = vs.map(a => a.Id);
|
|
||||||
if (fs.includes(ev)) {
|
|
||||||
return k;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DividerProps {
|
interface DividerProps {
|
||||||
variant?: "regular" | "small";
|
variant?: "regular" | "small";
|
||||||
@ -38,26 +31,25 @@ interface SubthreadProps {
|
|||||||
isLastSubthread?: boolean;
|
isLastSubthread?: boolean;
|
||||||
from: u256;
|
from: u256;
|
||||||
active: u256;
|
active: u256;
|
||||||
path: u256[];
|
notes: readonly TaggedRawEvent[];
|
||||||
notes: NEvent[];
|
related: readonly TaggedRawEvent[];
|
||||||
related: TaggedRawEvent[];
|
chains: Map<u256, Array<TaggedRawEvent>>;
|
||||||
chains: Map<u256, NEvent[]>;
|
|
||||||
onNavigate: (e: u256) => void;
|
onNavigate: (e: u256) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Subthread = ({ active, path, notes, related, chains, onNavigate }: SubthreadProps) => {
|
const Subthread = ({ active, notes, related, chains, onNavigate }: SubthreadProps) => {
|
||||||
const renderSubthread = (a: NEvent, idx: number) => {
|
const renderSubthread = (a: TaggedRawEvent, idx: number) => {
|
||||||
const isLastSubthread = idx === notes.length - 1;
|
const isLastSubthread = idx === notes.length - 1;
|
||||||
const replies = getReplies(a.Id, chains);
|
const replies = getReplies(a.id, chains);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`subthread-container ${replies.length > 0 ? "subthread-multi" : ""}`}>
|
<div className={`subthread-container ${replies.length > 0 ? "subthread-multi" : ""}`}>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Note
|
<Note
|
||||||
highlight={active === a.Id}
|
highlight={active === a.id}
|
||||||
className={`thread-note ${isLastSubthread && replies.length === 0 ? "is-last-note" : ""}`}
|
className={`thread-note ${isLastSubthread && replies.length === 0 ? "is-last-note" : ""}`}
|
||||||
data-ev={a}
|
data={a}
|
||||||
key={a.Id}
|
key={a.id}
|
||||||
related={related}
|
related={related}
|
||||||
/>
|
/>
|
||||||
<div className="line-container"></div>
|
<div className="line-container"></div>
|
||||||
@ -66,8 +58,7 @@ const Subthread = ({ active, path, notes, related, chains, onNavigate }: Subthre
|
|||||||
<TierTwo
|
<TierTwo
|
||||||
active={active}
|
active={active}
|
||||||
isLastSubthread={isLastSubthread}
|
isLastSubthread={isLastSubthread}
|
||||||
path={path}
|
from={a.id}
|
||||||
from={a.Id}
|
|
||||||
notes={replies}
|
notes={replies}
|
||||||
related={related}
|
related={related}
|
||||||
chains={chains}
|
chains={chains}
|
||||||
@ -82,26 +73,16 @@ const Subthread = ({ active, path, notes, related, chains, onNavigate }: Subthre
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
|
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
|
||||||
note: NEvent;
|
note: TaggedRawEvent;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThreadNote = ({
|
const ThreadNote = ({ active, note, isLast, isLastSubthread, from, related, chains, onNavigate }: ThreadNoteProps) => {
|
||||||
active,
|
|
||||||
note,
|
|
||||||
isLast,
|
|
||||||
path,
|
|
||||||
isLastSubthread,
|
|
||||||
from,
|
|
||||||
related,
|
|
||||||
chains,
|
|
||||||
onNavigate,
|
|
||||||
}: ThreadNoteProps) => {
|
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const replies = getReplies(note.Id, chains);
|
const replies = getReplies(note.id, chains);
|
||||||
const activeInReplies = replies.map(r => r.Id).includes(active);
|
const activeInReplies = replies.map(r => r.id).includes(active);
|
||||||
const [collapsed, setCollapsed] = useState(!activeInReplies);
|
const [collapsed, setCollapsed] = useState(!activeInReplies);
|
||||||
const hasMultipleNotes = replies.length > 0;
|
const hasMultipleNotes = replies.length > 1;
|
||||||
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes;
|
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes;
|
||||||
const className = `subthread-container ${isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid"}`;
|
const className = `subthread-container ${isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid"}`;
|
||||||
return (
|
return (
|
||||||
@ -109,19 +90,18 @@ const ThreadNote = ({
|
|||||||
<div className={className}>
|
<div className={className}>
|
||||||
<Divider variant="small" />
|
<Divider variant="small" />
|
||||||
<Note
|
<Note
|
||||||
highlight={active === note.Id}
|
highlight={active === note.id}
|
||||||
className={`thread-note ${isLastVisibleNote ? "is-last-note" : ""}`}
|
className={`thread-note ${isLastVisibleNote ? "is-last-note" : ""}`}
|
||||||
data-ev={note}
|
data={note}
|
||||||
key={note.Id}
|
key={note.id}
|
||||||
related={related}
|
related={related}
|
||||||
/>
|
/>
|
||||||
<div className="line-container"></div>
|
<div className="line-container"></div>
|
||||||
</div>
|
</div>
|
||||||
{replies.length > 0 &&
|
{replies.length > 0 && (
|
||||||
(activeInReplies ? (
|
<Collapsed text={formatMessage(messages.ShowReplies)} collapsed={collapsed} setCollapsed={setCollapsed}>
|
||||||
<TierThree
|
<TierThree
|
||||||
active={active}
|
active={active}
|
||||||
path={path}
|
|
||||||
isLastSubthread={isLastSubthread}
|
isLastSubthread={isLastSubthread}
|
||||||
from={from}
|
from={from}
|
||||||
notes={replies}
|
notes={replies}
|
||||||
@ -129,32 +109,19 @@ const ThreadNote = ({
|
|||||||
chains={chains}
|
chains={chains}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
) : (
|
</Collapsed>
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
const [first, ...rest] = notes;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ThreadNote
|
<ThreadNote
|
||||||
active={active}
|
active={active}
|
||||||
path={path}
|
|
||||||
from={from}
|
from={from}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
note={first}
|
note={first}
|
||||||
@ -164,12 +131,11 @@ const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains,
|
|||||||
isLast={rest.length === 0}
|
isLast={rest.length === 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{rest.map((r: NEvent, idx: number) => {
|
{rest.map((r: TaggedRawEvent, idx: number) => {
|
||||||
const lastReply = idx === rest.length - 1;
|
const lastReply = idx === rest.length - 1;
|
||||||
return (
|
return (
|
||||||
<ThreadNote
|
<ThreadNote
|
||||||
active={active}
|
active={active}
|
||||||
path={path}
|
|
||||||
from={from}
|
from={from}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
note={r}
|
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 [first, ...rest] = notes;
|
||||||
const replies = getReplies(first.Id, chains);
|
const replies = getReplies(first.id, chains);
|
||||||
const activeInReplies = notes.map(r => r.Id).includes(active) || replies.map(r => r.Id).includes(active);
|
|
||||||
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
|
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
|
||||||
const isLast = replies.length === 0 && rest.length === 0;
|
const isLast = replies.length === 0 && rest.length === 0;
|
||||||
return (
|
return (
|
||||||
@ -198,51 +163,42 @@ const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains
|
|||||||
}`}>
|
}`}>
|
||||||
<Divider variant="small" />
|
<Divider variant="small" />
|
||||||
<Note
|
<Note
|
||||||
highlight={active === first.Id}
|
highlight={active === first.id}
|
||||||
className={`thread-note ${isLastSubthread && isLast ? "is-last-note" : ""}`}
|
className={`thread-note ${isLastSubthread && isLast ? "is-last-note" : ""}`}
|
||||||
data-ev={first}
|
data={first}
|
||||||
key={first.Id}
|
key={first.id}
|
||||||
related={related}
|
related={related}
|
||||||
/>
|
/>
|
||||||
<div className="line-container"></div>
|
<div className="line-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{path.length <= 1 || !activeInReplies
|
{replies.length > 0 && (
|
||||||
? replies.length > 0 && (
|
<TierThree
|
||||||
<div className="show-more-container">
|
active={active}
|
||||||
<button className="show-more" type="button" onClick={() => onNavigate(from)}>
|
isLastSubthread={isLastSubthread}
|
||||||
<FormattedMessage {...messages.ShowReplies} />
|
from={from}
|
||||||
</button>
|
notes={replies}
|
||||||
</div>
|
related={related}
|
||||||
)
|
chains={chains}
|
||||||
: replies.length > 0 && (
|
onNavigate={onNavigate}
|
||||||
<TierThree
|
/>
|
||||||
active={active}
|
)}
|
||||||
path={path.slice(1)}
|
|
||||||
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 lastReply = idx === rest.length - 1;
|
||||||
const lastNote = isLastSubthread && lastReply;
|
const lastNote = isLastSubthread && lastReply;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.Id}
|
key={r.id}
|
||||||
className={`subthread-container ${lastReply ? "" : "subthread-multi"} ${
|
className={`subthread-container ${lastReply ? "" : "subthread-multi"} ${
|
||||||
lastReply ? "subthread-last" : "subthread-mid"
|
lastReply ? "subthread-last" : "subthread-mid"
|
||||||
}`}>
|
}`}>
|
||||||
<Divider variant="small" />
|
<Divider variant="small" />
|
||||||
<Note
|
<Note
|
||||||
className={`thread-note ${lastNote ? "is-last-note" : ""}`}
|
className={`thread-note ${lastNote ? "is-last-note" : ""}`}
|
||||||
highlight={active === r.Id}
|
highlight={active === r.id}
|
||||||
data-ev={r}
|
data={r}
|
||||||
key={r.Id}
|
key={r.id}
|
||||||
related={related}
|
related={related}
|
||||||
/>
|
/>
|
||||||
<div className="line-container"></div>
|
<div className="line-container"></div>
|
||||||
@ -253,148 +209,131 @@ const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ThreadProps {
|
export default function Thread() {
|
||||||
notes?: TaggedRawEvent[];
|
const params = useParams();
|
||||||
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;
|
|
||||||
const location = useLocation();
|
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 { formatMessage } = useIntl();
|
||||||
const urlNoteId = location?.pathname.slice(3);
|
|
||||||
const urlNoteHex = urlNoteId && bech32ToHex(urlNoteId);
|
|
||||||
|
|
||||||
const chains = useMemo(() => {
|
const chains = useMemo(() => {
|
||||||
const chains = new Map<u256, NEvent[]>();
|
const chains = new Map<u256, Array<TaggedRawEvent>>();
|
||||||
parsedNotes
|
if (thread.data) {
|
||||||
?.filter(a => a.Kind === EventKind.TextNote)
|
thread.data
|
||||||
.sort((a, b) => b.CreatedAt - a.CreatedAt)
|
?.filter(a => a.kind === EventKind.TextNote)
|
||||||
.forEach(v => {
|
.sort((a, b) => b.created_at - a.created_at)
|
||||||
const replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
|
.forEach(v => {
|
||||||
if (replyTo) {
|
const thread = EventExt.extractThread(v);
|
||||||
if (!chains.has(replyTo)) {
|
const replyTo = thread?.replyTo?.Event ?? thread?.root?.Event;
|
||||||
chains.set(replyTo, [v]);
|
if (replyTo) {
|
||||||
} else {
|
if (!chains.has(replyTo)) {
|
||||||
unwrap(chains.get(replyTo)).push(v);
|
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;
|
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 root = useMemo(() => {
|
||||||
const isRoot = (ne?: NEvent) => ne?.Thread === null;
|
const currentNote = thread.data?.find(ne => ne.id === currentId) ?? (location.state as TaggedRawEvent);
|
||||||
const currentNote = parsedNotes.find(ne => ne.Id === urlNoteHex);
|
if (currentNote) {
|
||||||
|
const currentThread = EventExt.extractThread(currentNote);
|
||||||
|
const isRoot = (ne?: ThreadInfo) => ne === undefined;
|
||||||
|
|
||||||
if (isRoot(currentNote)) {
|
if (isRoot(currentThread)) {
|
||||||
return currentNote;
|
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
|
const possibleRoots = thread.data?.filter(a => {
|
||||||
if (rootEventId) {
|
const thread = EventExt.extractThread(a);
|
||||||
return parsedNotes.find(ne => ne.Id === rootEventId);
|
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);
|
if (children.find(ne => ne.id === currentId)) {
|
||||||
|
return ne;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [notes, chains, urlNoteHex]);
|
}, [thread.data, currentId, location]);
|
||||||
|
|
||||||
useEffect(() => {
|
const parent = useMemo(() => {
|
||||||
if (!root) {
|
if (root) {
|
||||||
return;
|
const currentThread = EventExt.extractThread(root);
|
||||||
|
return currentThread?.replyTo?.Event ?? currentThread?.root?.Event;
|
||||||
}
|
}
|
||||||
|
}, [root]);
|
||||||
|
|
||||||
if (navigated) {
|
const brokenChains = Array.from(chains?.keys()).filter(a => !thread.data?.some(b => b.id === a));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (root.Id === urlNoteHex) {
|
function renderRoot(note: TaggedRawEvent) {
|
||||||
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) {
|
|
||||||
const className = `thread-root ${isSingleNote ? "thread-root-single" : ""}`;
|
const className = `thread-root ${isSingleNote ? "thread-root-single" : ""}`;
|
||||||
if (note) {
|
if (note) {
|
||||||
return (
|
return (
|
||||||
<Note
|
<Note
|
||||||
className={className}
|
className={className}
|
||||||
key={note.Id}
|
key={note.id}
|
||||||
data-ev={note}
|
data={note}
|
||||||
related={notes}
|
related={getReactions(thread.data, note.id)}
|
||||||
options={{ showReactionsLink: true }}
|
options={{ showReactionsLink: true }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} 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 {
|
function renderChain(from: u256): ReactNode {
|
||||||
if (!from || !chains) {
|
if (!from || !chains) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const replies = chains.get(from);
|
const replies = chains.get(from);
|
||||||
if (replies) {
|
if (replies && currentId) {
|
||||||
return (
|
return (
|
||||||
<Subthread
|
<Subthread
|
||||||
active={urlNoteHex}
|
active={currentId}
|
||||||
path={path}
|
|
||||||
from={from}
|
from={from}
|
||||||
notes={replies}
|
notes={replies}
|
||||||
related={notes}
|
related={getAllReactions(
|
||||||
|
thread.data,
|
||||||
|
replies.map(a => a.id)
|
||||||
|
)}
|
||||||
chains={chains}
|
chains={chains}
|
||||||
onNavigate={onNavigate}
|
onNavigate={() => {
|
||||||
|
//nothing
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (path.length > 1) {
|
if (parent) {
|
||||||
const newPath = path.slice(0, path.length - 1);
|
setCurrentId(parent);
|
||||||
setPath(newPath);
|
|
||||||
} else {
|
} else {
|
||||||
navigate(location.state?.from ?? "/");
|
navigate(-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -408,31 +347,28 @@ export default function Thread(props: ThreadProps) {
|
|||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div className="main-content mt10">
|
<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">
|
<div className="thread-container">
|
||||||
{currentRoot && renderRoot(currentRoot)}
|
{root && renderRoot(root)}
|
||||||
{currentRoot && renderChain(currentRoot.Id)}
|
{root && renderChain(root.id)}
|
||||||
{currentRoot === root && (
|
|
||||||
<>
|
{brokenChains.length > 0 && <h3>Other replies</h3>}
|
||||||
{brokenChains.length > 0 && <h3>Other replies</h3>}
|
{brokenChains.map(a => {
|
||||||
{brokenChains.map(a => {
|
return (
|
||||||
return (
|
<div className="mb10">
|
||||||
<div className="mb10">
|
<NoteGhost className={`thread-note thread-root ghost-root`} key={a}>
|
||||||
<NoteGhost className={`thread-note thread-root ghost-root`} key={a}>
|
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
|
||||||
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
|
</NoteGhost>
|
||||||
</NoteGhost>
|
{renderChain(a)}
|
||||||
{renderChain(a)}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</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) {
|
if (!from || !chains) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,20 @@
|
|||||||
import "./Timeline.css";
|
import "./Timeline.css";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
|
import { TaggedRawEvent, EventKind, u256 } from "@snort/nostr";
|
||||||
|
|
||||||
import Icon from "Icons/Icon";
|
import Icon from "Icons/Icon";
|
||||||
import { dedupeById, dedupeByPubkey, tagFilterOfTextRepost } from "Util";
|
import { dedupeByPubkey, findTag, tagFilterOfTextRepost, unixNow } from "Util";
|
||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
|
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "Feed/TimelineFeed";
|
||||||
import { TaggedRawEvent } from "@snort/nostr";
|
|
||||||
import { EventKind } from "@snort/nostr";
|
|
||||||
import LoadMore from "Element/LoadMore";
|
import LoadMore from "Element/LoadMore";
|
||||||
import Zap, { parseZap } from "Element/Zap";
|
import Zap, { parseZap } from "Element/Zap";
|
||||||
import Note from "Element/Note";
|
import Note from "Element/Note";
|
||||||
import NoteReaction from "Element/NoteReaction";
|
import NoteReaction from "Element/NoteReaction";
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
import ProfilePreview from "./ProfilePreview";
|
import ProfilePreview from "Element/ProfilePreview";
|
||||||
import Skeleton from "Element/Skeleton";
|
import Skeleton from "Element/Skeleton";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
import { RootState } from "State/Store";
|
|
||||||
import { setTimeline } from "State/Cache";
|
|
||||||
|
|
||||||
export interface TimelineProps {
|
export interface TimelineProps {
|
||||||
postsOnly: boolean;
|
postsOnly: boolean;
|
||||||
@ -27,64 +23,59 @@ export interface TimelineProps {
|
|||||||
ignoreModeration?: boolean;
|
ignoreModeration?: boolean;
|
||||||
window?: number;
|
window?: number;
|
||||||
relay?: string;
|
relay?: string;
|
||||||
|
now?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of notes by pubkeys
|
* A list of notes by pubkeys
|
||||||
*/
|
*/
|
||||||
export default function Timeline({
|
const Timeline = (props: TimelineProps) => {
|
||||||
subject,
|
const feedOptions = useMemo(() => {
|
||||||
postsOnly = false,
|
return {
|
||||||
method,
|
method: props.method,
|
||||||
ignoreModeration = false,
|
window: props.window,
|
||||||
window: timeWindow,
|
relay: props.relay,
|
||||||
relay,
|
now: props.now,
|
||||||
}: TimelineProps) {
|
};
|
||||||
|
}, [props]);
|
||||||
|
const feed: TimelineFeed = useTimelineFeed(props.subject, feedOptions);
|
||||||
|
|
||||||
const { muted, isMuted } = useModeration();
|
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 { ref, inView } = useInView();
|
||||||
|
|
||||||
const filterPosts = useCallback(
|
const filterPosts = useCallback(
|
||||||
(nts: TaggedRawEvent[]) => {
|
(nts: readonly TaggedRawEvent[]) => {
|
||||||
return [...nts]
|
return [...nts]
|
||||||
.sort((a, b) => b.created_at - a.created_at)
|
.sort((a, b) => b.created_at - a.created_at)
|
||||||
?.filter(a => (postsOnly ? !a.tags.some(b => b[0] === "e") : true))
|
?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : a.tags.some(b => b[0] === "e")))
|
||||||
.filter(a => ignoreModeration || !isMuted(a.pubkey));
|
.filter(a => props.ignoreModeration || !isMuted(a.pubkey));
|
||||||
},
|
},
|
||||||
[postsOnly, muted, ignoreModeration]
|
[props.postsOnly, muted, props.ignoreModeration]
|
||||||
);
|
);
|
||||||
|
|
||||||
const mainFeed = useMemo(() => {
|
const mainFeed = useMemo(() => {
|
||||||
return filterPosts(cache.main);
|
return filterPosts(feed.main ?? []);
|
||||||
}, [cache, filterPosts]);
|
}, [feed, filterPosts]);
|
||||||
|
|
||||||
const latestFeed = useMemo(() => {
|
const latestFeed = useMemo(() => {
|
||||||
return filterPosts(cache.latest).filter(a => !mainFeed.some(b => b.id === a.id));
|
return filterPosts(feed.latest ?? []).filter(a => !mainFeed.some(b => b.id === a.id));
|
||||||
}, [cache, filterPosts]);
|
}, [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(() => {
|
const latestAuthors = useMemo(() => {
|
||||||
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
|
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
|
||||||
}, [latestFeed]);
|
}, [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) {
|
function eventElement(e: TaggedRawEvent) {
|
||||||
switch (e.kind) {
|
switch (e.kind) {
|
||||||
case EventKind.SetMetadata: {
|
case EventKind.SetMetadata: {
|
||||||
@ -93,9 +84,9 @@ export default function Timeline({
|
|||||||
case EventKind.TextNote: {
|
case EventKind.TextNote: {
|
||||||
const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1);
|
const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1);
|
||||||
if (eRef) {
|
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: {
|
case EventKind.ZapReceipt: {
|
||||||
const zap = parseZap(e);
|
const zap = parseZap(e);
|
||||||
@ -103,8 +94,8 @@ export default function Timeline({
|
|||||||
}
|
}
|
||||||
case EventKind.Reaction:
|
case EventKind.Reaction:
|
||||||
case EventKind.Repost: {
|
case EventKind.Repost: {
|
||||||
const eRef = e.tags.find(a => a[0] === "e")?.at(1);
|
const eRef = findTag(e, "e");
|
||||||
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)} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -115,6 +106,7 @@ export default function Timeline({
|
|||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
{latestFeed.length > 0 && (
|
{latestFeed.length > 0 && (
|
||||||
@ -144,11 +136,12 @@ export default function Timeline({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{mainFeed.map(eventElement)}
|
{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" />
|
<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>
|
</LoadMore>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
export default Timeline;
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TaggedRawEvent, EventKind, HexKey, Lists, Subscriptions } from "@snort/nostr";
|
import { EventKind, HexKey, Lists } from "@snort/nostr";
|
||||||
import useSubscription from "Feed/Subscription";
|
|
||||||
import { unwrap, findTag, chunks } from "Util";
|
import { unwrap, findTag, chunks } from "Util";
|
||||||
|
import { RequestBuilder } from "System";
|
||||||
|
import { FlatNoteStore, ReplaceableNoteStore } from "System/NoteCollection";
|
||||||
|
import useRequestBuilder from "Hooks/useRequestBuilder";
|
||||||
|
|
||||||
type BadgeAwards = {
|
type BadgeAwards = {
|
||||||
pubkeys: string[];
|
pubkeys: string[];
|
||||||
@ -11,22 +14,17 @@ type BadgeAwards = {
|
|||||||
export default function useProfileBadges(pubkey?: HexKey) {
|
export default function useProfileBadges(pubkey?: HexKey) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
if (!pubkey) return null;
|
if (!pubkey) return null;
|
||||||
const s = new Subscriptions();
|
const b = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`);
|
||||||
s.Id = `badges:${pubkey.slice(0, 12)}`;
|
b.withFilter().kinds([EventKind.ProfileBadges]).tag("d", [Lists.Badges]).authors([pubkey]);
|
||||||
s.Kinds = new Set([EventKind.ProfileBadges]);
|
return b;
|
||||||
s.DTags = new Set([Lists.Badges]);
|
|
||||||
s.Authors = new Set([pubkey]);
|
|
||||||
return s;
|
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
const profileBadges = useSubscription(sub, { leaveOpen: false, cache: false });
|
|
||||||
|
const profileBadges = useRequestBuilder<ReplaceableNoteStore>(ReplaceableNoteStore, sub);
|
||||||
|
|
||||||
const profile = useMemo(() => {
|
const profile = useMemo(() => {
|
||||||
const sorted = [...profileBadges.store.notes];
|
if (profileBadges.data) {
|
||||||
sorted.sort((a, b) => b.created_at - a.created_at);
|
|
||||||
const last = sorted[0];
|
|
||||||
if (last) {
|
|
||||||
return chunks(
|
return chunks(
|
||||||
last.tags.filter(t => t[0] === "a" || t[0] === "e"),
|
profileBadges.data.tags.filter(t => t[0] === "a" || t[0] === "e"),
|
||||||
2
|
2
|
||||||
).reduce((acc, [a, e]) => {
|
).reduce((acc, [a, e]) => {
|
||||||
return {
|
return {
|
||||||
@ -36,7 +34,7 @@ export default function useProfileBadges(pubkey?: HexKey) {
|
|||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}, [pubkey, profileBadges.store]);
|
}, [profileBadges]);
|
||||||
|
|
||||||
const { ds, pubkeys } = useMemo(() => {
|
const { ds, pubkeys } = useMemo(() => {
|
||||||
return Object.values(profile).reduce(
|
return Object.values(profile).reduce(
|
||||||
@ -55,48 +53,37 @@ export default function useProfileBadges(pubkey?: HexKey) {
|
|||||||
const awardsSub = useMemo(() => {
|
const awardsSub = useMemo(() => {
|
||||||
const ids = Object.keys(profile);
|
const ids = Object.keys(profile);
|
||||||
if (!pubkey || ids.length === 0) return null;
|
if (!pubkey || ids.length === 0) return null;
|
||||||
const s = new Subscriptions();
|
const b = new RequestBuilder(`profile_awards:${pubkey.slice(0, 12)}`);
|
||||||
s.Id = `profile_awards:${pubkey.slice(0, 12)}`;
|
b.withFilter().kinds([EventKind.BadgeAward]).ids(ids);
|
||||||
s.Kinds = new Set([EventKind.BadgeAward]);
|
b.withFilter().kinds([EventKind.Badge]).tag("d", ds).authors(pubkeys);
|
||||||
s.Ids = new Set(ids);
|
return b;
|
||||||
return s;
|
}, [profile, ds]);
|
||||||
}, [pubkey, profileBadges.store]);
|
|
||||||
|
|
||||||
const awards = useSubscription(awardsSub).store.notes;
|
const awards = useRequestBuilder<FlatNoteStore>(FlatNoteStore, awardsSub);
|
||||||
|
|
||||||
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 result = useMemo(() => {
|
const result = useMemo(() => {
|
||||||
return awards
|
if (awards.data) {
|
||||||
.map((award: TaggedRawEvent) => {
|
return awards.data
|
||||||
const [, pubkey, d] =
|
.map((award, _, arr) => {
|
||||||
award.tags
|
const [, pubkey, d] =
|
||||||
.find(t => t[0] === "a")
|
award.tags
|
||||||
?.at(1)
|
.find(t => t[0] === "a")
|
||||||
?.split(":") ?? [];
|
?.at(1)
|
||||||
const badge = badges.find(b => b.pubkey === pubkey && findTag(b, "d") === d);
|
?.split(":") ?? [];
|
||||||
|
const badge = arr.find(b => b.pubkey === pubkey && findTag(b, "d") === d);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
award,
|
award,
|
||||||
badge,
|
badge,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(
|
.filter(
|
||||||
({ award, badge }) =>
|
({ award, badge }) =>
|
||||||
badge && award.pubkey === badge.pubkey && award.tags.find(t => t[0] === "p" && t[1] === pubkey)
|
badge && award.pubkey === badge.pubkey && award.tags.find(t => t[0] === "p" && t[1] === pubkey)
|
||||||
)
|
)
|
||||||
.map(({ badge }) => unwrap(badge));
|
.map(({ badge }) => unwrap(badge));
|
||||||
}, [pubkey, awards, badges]);
|
}
|
||||||
|
}, [pubkey, awards]);
|
||||||
|
|
||||||
return result;
|
return result ?? [];
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
import { HexKey, Lists } from "@snort/nostr";
|
||||||
|
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { HexKey, Lists } from "@snort/nostr";
|
import useNotelistSubscription from "Hooks/useNotelistSubscription";
|
||||||
import useNotelistSubscription from "Feed/useNotelistSubscription";
|
|
||||||
|
|
||||||
export default function useBookmarkFeed(pubkey?: HexKey) {
|
export default function useBookmarkFeed(pubkey?: HexKey) {
|
||||||
const { bookmarked } = useSelector((s: RootState) => s.login);
|
const { bookmarked } = useSelector((s: RootState) => s.login);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import * as secp from "@noble/secp256k1";
|
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 { RootState } from "State/Store";
|
||||||
import { HexKey, RawEvent, u256, UserMetadata, Lists } from "@snort/nostr";
|
|
||||||
import { bech32ToHex, delay, unwrap } from "Util";
|
import { bech32ToHex, delay, unwrap } from "Util";
|
||||||
import { DefaultRelays, HashtagRegex } from "Const";
|
import { DefaultRelays, HashtagRegex } from "Const";
|
||||||
import { System } from "System";
|
import { System } from "System";
|
||||||
import { useMemo } from "react";
|
import { EventExt } from "System/EventExt";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -33,26 +32,27 @@ export default function useEventPublisher() {
|
|||||||
const relays = useSelector((s: RootState) => s.login.relays);
|
const relays = useSelector((s: RootState) => s.login.relays);
|
||||||
const hasNip07 = "nostr" in window;
|
const hasNip07 = "nostr" in window;
|
||||||
|
|
||||||
async function signEvent(ev: NEvent): Promise<NEvent> {
|
async function signEvent(ev: RawEvent): Promise<RawEvent> {
|
||||||
if (hasNip07 && !privKey) {
|
if (hasNip07 && !privKey) {
|
||||||
ev.Id = ev.CreateId();
|
ev.id = await EventExt.createId(ev);
|
||||||
const tmpEv = (await barrierNip07(() => window.nostr.signEvent(ev.ToObject()))) as RawEvent;
|
const tmpEv = (await barrierNip07(() => window.nostr.signEvent(ev))) as RawEvent;
|
||||||
return new NEvent(tmpEv as TaggedRawEvent);
|
ev.sig = tmpEv.sig;
|
||||||
|
return ev;
|
||||||
} else if (privKey) {
|
} else if (privKey) {
|
||||||
await ev.Sign(privKey);
|
await EventExt.sign(ev, privKey);
|
||||||
} else {
|
} else {
|
||||||
console.warn("Count not sign event, no private keys available");
|
console.warn("Count not sign event, no private keys available");
|
||||||
}
|
}
|
||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
|
|
||||||
function processContent(ev: NEvent, msg: string) {
|
function processContent(ev: RawEvent, msg: string) {
|
||||||
const replaceNpub = (match: string) => {
|
const replaceNpub = (match: string) => {
|
||||||
const npub = match.slice(1);
|
const npub = match.slice(1);
|
||||||
try {
|
try {
|
||||||
const hex = bech32ToHex(npub);
|
const hex = bech32ToHex(npub);
|
||||||
const idx = ev.Tags.length;
|
const idx = ev.tags.length;
|
||||||
ev.Tags.push(new Tag(["p", hex], idx));
|
ev.tags.push(["p", hex]);
|
||||||
return `#[${idx}]`;
|
return `#[${idx}]`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return match;
|
return match;
|
||||||
@ -62,8 +62,8 @@ export default function useEventPublisher() {
|
|||||||
const noteId = match.slice(1);
|
const noteId = match.slice(1);
|
||||||
try {
|
try {
|
||||||
const hex = bech32ToHex(noteId);
|
const hex = bech32ToHex(noteId);
|
||||||
const idx = ev.Tags.length;
|
const idx = ev.tags.length;
|
||||||
ev.Tags.push(new Tag(["e", hex, "", "mention"], idx));
|
ev.tags.push(["e", hex, "", "mention"]);
|
||||||
return `#[${idx}]`;
|
return `#[${idx}]`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return match;
|
return match;
|
||||||
@ -71,29 +71,26 @@ export default function useEventPublisher() {
|
|||||||
};
|
};
|
||||||
const replaceHashtag = (match: string) => {
|
const replaceHashtag = (match: string) => {
|
||||||
const tag = match.slice(1);
|
const tag = match.slice(1);
|
||||||
const idx = ev.Tags.length;
|
ev.tags.push(["t", tag.toLowerCase()]);
|
||||||
ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx));
|
|
||||||
return match;
|
return match;
|
||||||
};
|
};
|
||||||
const content = msg
|
const content = msg
|
||||||
.replace(/@npub[a-z0-9]+/g, replaceNpub)
|
.replace(/@npub[a-z0-9]+/g, replaceNpub)
|
||||||
.replace(/@note1[acdefghjklmnpqrstuvwxyz023456789]{58}/g, replaceNoteId)
|
.replace(/@note1[acdefghjklmnpqrstuvwxyz023456789]{58}/g, replaceNoteId)
|
||||||
.replace(HashtagRegex, replaceHashtag);
|
.replace(HashtagRegex, replaceHashtag);
|
||||||
ev.Content = content;
|
ev.content = content;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ret = {
|
const ret = {
|
||||||
nip42Auth: async (challenge: string, relay: string) => {
|
nip42Auth: async (challenge: string, relay: string) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.Auth);
|
||||||
ev.Kind = EventKind.Auth;
|
ev.tags.push(["relay", relay]);
|
||||||
ev.Content = "";
|
ev.tags.push(["challenge", challenge]);
|
||||||
ev.Tags.push(new Tag(["relay", relay], 0));
|
|
||||||
ev.Tags.push(new Tag(["challenge", challenge], 1));
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: (ev: NEvent | undefined) => {
|
broadcast: (ev: RawEvent | undefined) => {
|
||||||
if (ev) {
|
if (ev) {
|
||||||
console.debug("Sending event: ", ev);
|
console.debug("Sending event: ", ev);
|
||||||
System.BroadcastEvent(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,
|
* 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
|
* 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) {
|
if (ev) {
|
||||||
for (const [k] of DefaultRelays) {
|
for (const [k] of DefaultRelays) {
|
||||||
System.WriteOnceToRelay(k, ev);
|
System.WriteOnceToRelay(k, ev);
|
||||||
@ -114,7 +111,7 @@ export default function useEventPublisher() {
|
|||||||
/**
|
/**
|
||||||
* Write event to all given relays.
|
* Write event to all given relays.
|
||||||
*/
|
*/
|
||||||
broadcastAll: (ev: NEvent | undefined, relays: string[]) => {
|
broadcastAll: (ev: RawEvent | undefined, relays: string[]) => {
|
||||||
if (ev) {
|
if (ev) {
|
||||||
for (const k of relays) {
|
for (const k of relays) {
|
||||||
System.WriteOnceToRelay(k, ev);
|
System.WriteOnceToRelay(k, ev);
|
||||||
@ -123,11 +120,10 @@ export default function useEventPublisher() {
|
|||||||
},
|
},
|
||||||
muted: async (keys: HexKey[], priv: HexKey[]) => {
|
muted: async (keys: HexKey[], priv: HexKey[]) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.PubkeyLists);
|
||||||
ev.Kind = EventKind.PubkeyLists;
|
ev.tags.push(["d", Lists.Muted]);
|
||||||
ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length));
|
|
||||||
keys.forEach(p => {
|
keys.forEach(p => {
|
||||||
ev.Tags.push(new Tag(["p", p], ev.Tags.length));
|
ev.tags.push(["p", p]);
|
||||||
});
|
});
|
||||||
let content = "";
|
let content = "";
|
||||||
if (priv.length > 0) {
|
if (priv.length > 0) {
|
||||||
@ -136,76 +132,67 @@ export default function useEventPublisher() {
|
|||||||
if (hasNip07 && !privKey) {
|
if (hasNip07 && !privKey) {
|
||||||
content = await barrierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext));
|
content = await barrierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext));
|
||||||
} else if (privKey) {
|
} 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);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
pinned: async (notes: HexKey[]) => {
|
pinned: async (notes: HexKey[]) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.NoteLists);
|
||||||
ev.Kind = EventKind.NoteLists;
|
ev.tags.push(["d", Lists.Pinned]);
|
||||||
ev.Tags.push(new Tag(["d", Lists.Pinned], ev.Tags.length));
|
|
||||||
notes.forEach(n => {
|
notes.forEach(n => {
|
||||||
ev.Tags.push(new Tag(["e", n], ev.Tags.length));
|
ev.tags.push(["e", n]);
|
||||||
});
|
});
|
||||||
ev.Content = "";
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
bookmarked: async (notes: HexKey[]) => {
|
bookmarked: async (notes: HexKey[]) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.NoteLists);
|
||||||
ev.Kind = EventKind.NoteLists;
|
ev.tags.push(["d", Lists.Bookmarked]);
|
||||||
ev.Tags.push(new Tag(["d", Lists.Bookmarked], ev.Tags.length));
|
|
||||||
notes.forEach(n => {
|
notes.forEach(n => {
|
||||||
ev.Tags.push(new Tag(["e", n], ev.Tags.length));
|
ev.tags.push(["e", n]);
|
||||||
});
|
});
|
||||||
ev.Content = "";
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tags: async (tags: string[]) => {
|
tags: async (tags: string[]) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.TagLists);
|
||||||
ev.Kind = EventKind.TagLists;
|
ev.tags.push(["d", Lists.Followed]);
|
||||||
ev.Tags.push(new Tag(["d", Lists.Followed], ev.Tags.length));
|
|
||||||
tags.forEach(t => {
|
tags.forEach(t => {
|
||||||
ev.Tags.push(new Tag(["t", t], ev.Tags.length));
|
ev.tags.push(["t", t]);
|
||||||
});
|
});
|
||||||
ev.Content = "";
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
metadata: async (obj: UserMetadata) => {
|
metadata: async (obj: UserMetadata) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.SetMetadata);
|
||||||
ev.Kind = EventKind.SetMetadata;
|
ev.content = JSON.stringify(obj);
|
||||||
ev.Content = JSON.stringify(obj);
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
note: async (msg: string) => {
|
note: async (msg: string) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.TextNote);
|
||||||
ev.Kind = EventKind.TextNote;
|
|
||||||
processContent(ev, msg);
|
processContent(ev, msg);
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
zap: async (amount: number, author: HexKey, note?: HexKey, msg?: string) => {
|
zap: async (amount: number, author: HexKey, note?: HexKey, msg?: string) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.ZapRequest);
|
||||||
ev.Kind = EventKind.ZapRequest;
|
|
||||||
if (note) {
|
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())];
|
const relayTag = ["relays", ...Object.keys(relays).map(a => a.trim())];
|
||||||
ev.Tags.push(new Tag(relayTag, ev.Tags.length));
|
ev.tags.push(relayTag);
|
||||||
ev.Tags.push(new Tag(["amount", amount.toString()], ev.Tags.length));
|
ev.tags.push(["amount", amount.toString()]);
|
||||||
processContent(ev, msg || "");
|
processContent(ev, msg || "");
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
@ -213,57 +200,54 @@ export default function useEventPublisher() {
|
|||||||
/**
|
/**
|
||||||
* Reply to a note
|
* Reply to a note
|
||||||
*/
|
*/
|
||||||
reply: async (replyTo: NEvent, msg: string) => {
|
reply: async (replyTo: TaggedRawEvent, msg: string) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.TextNote);
|
||||||
ev.Kind = EventKind.TextNote;
|
|
||||||
|
|
||||||
const thread = replyTo.Thread;
|
const thread = EventExt.extractThread(ev);
|
||||||
if (thread) {
|
if (thread) {
|
||||||
if (thread.Root || thread.ReplyTo) {
|
if (thread.root || thread.replyTo) {
|
||||||
ev.Tags.push(new Tag(["e", thread.Root?.Event ?? thread.ReplyTo?.Event ?? "", "", "root"], ev.Tags.length));
|
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
|
// dont tag self in replies
|
||||||
if (replyTo.PubKey !== pubKey) {
|
if (replyTo.pubkey !== pubKey) {
|
||||||
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
|
ev.tags.push(["p", replyTo.pubkey]);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const pk of thread.PubKeys) {
|
for (const pk of thread.pubKeys) {
|
||||||
if (pk === pubKey) {
|
if (pk === pubKey) {
|
||||||
continue; // dont tag self in replies
|
continue; // dont tag self in replies
|
||||||
}
|
}
|
||||||
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
ev.tags.push(["p", pk]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0));
|
ev.tags.push(["e", replyTo.id, "", "reply"]);
|
||||||
// dont tag self in replies
|
// dont tag self in replies
|
||||||
if (replyTo.PubKey !== pubKey) {
|
if (replyTo.pubkey !== pubKey) {
|
||||||
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
|
ev.tags.push(["p", replyTo.pubkey]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
processContent(ev, msg);
|
processContent(ev, msg);
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
react: async (evRef: NEvent, content = "+") => {
|
react: async (evRef: RawEvent, content = "+") => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.Reaction);
|
||||||
ev.Kind = EventKind.Reaction;
|
ev.content = content;
|
||||||
ev.Content = content;
|
ev.tags.push(["e", evRef.id]);
|
||||||
ev.Tags.push(new Tag(["e", evRef.Id], 0));
|
ev.tags.push(["p", evRef.pubkey]);
|
||||||
ev.Tags.push(new Tag(["p", evRef.PubKey], 1));
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
saveRelays: async () => {
|
saveRelays: async () => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
|
||||||
ev.Kind = EventKind.ContactList;
|
ev.content = JSON.stringify(relays);
|
||||||
ev.Content = JSON.stringify(relays);
|
|
||||||
for (const pk of follows) {
|
for (const pk of follows) {
|
||||||
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
ev.tags.push(["p", pk]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
@ -271,9 +255,7 @@ export default function useEventPublisher() {
|
|||||||
},
|
},
|
||||||
saveRelaysSettings: async () => {
|
saveRelaysSettings: async () => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.Relays);
|
||||||
ev.Kind = EventKind.Relays;
|
|
||||||
ev.Content = "";
|
|
||||||
for (const [url, settings] of Object.entries(relays)) {
|
for (const [url, settings] of Object.entries(relays)) {
|
||||||
const rTag = ["r", url];
|
const rTag = ["r", url];
|
||||||
if (settings.read && !settings.write) {
|
if (settings.read && !settings.write) {
|
||||||
@ -282,16 +264,15 @@ export default function useEventPublisher() {
|
|||||||
if (settings.write && !settings.read) {
|
if (settings.write && !settings.read) {
|
||||||
rTag.push("write");
|
rTag.push("write");
|
||||||
}
|
}
|
||||||
ev.Tags.push(new Tag(rTag, ev.Tags.length));
|
ev.tags.push(rTag);
|
||||||
}
|
}
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addFollow: async (pkAdd: HexKey | HexKey[], newRelays?: Record<string, RelaySettings>) => {
|
addFollow: async (pkAdd: HexKey | HexKey[], newRelays?: Record<string, RelaySettings>) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
|
||||||
ev.Kind = EventKind.ContactList;
|
ev.content = JSON.stringify(newRelays ?? relays);
|
||||||
ev.Content = JSON.stringify(newRelays ?? relays);
|
|
||||||
const temp = new Set(follows);
|
const temp = new Set(follows);
|
||||||
if (Array.isArray(pkAdd)) {
|
if (Array.isArray(pkAdd)) {
|
||||||
pkAdd.forEach(a => temp.add(a));
|
pkAdd.forEach(a => temp.add(a));
|
||||||
@ -302,7 +283,7 @@ export default function useEventPublisher() {
|
|||||||
if (pk.length !== 64) {
|
if (pk.length !== 64) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
ev.tags.push(["p", pk.toLowerCase()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
@ -310,14 +291,13 @@ export default function useEventPublisher() {
|
|||||||
},
|
},
|
||||||
removeFollow: async (pkRemove: HexKey) => {
|
removeFollow: async (pkRemove: HexKey) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
|
||||||
ev.Kind = EventKind.ContactList;
|
ev.content = JSON.stringify(relays);
|
||||||
ev.Content = JSON.stringify(relays);
|
|
||||||
for (const pk of follows) {
|
for (const pk of follows) {
|
||||||
if (pk === pkRemove || pk.length !== 64) {
|
if (pk === pkRemove || pk.length !== 64) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
ev.tags.push(["p", pk]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
@ -328,39 +308,33 @@ export default function useEventPublisher() {
|
|||||||
*/
|
*/
|
||||||
delete: async (id: u256) => {
|
delete: async (id: u256) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.Deletion);
|
||||||
ev.Kind = EventKind.Deletion;
|
ev.tags.push(["e", id]);
|
||||||
ev.Content = "";
|
|
||||||
ev.Tags.push(new Tag(["e", id], 0));
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Repost a note (NIP-18)
|
* Repost a note (NIP-18)
|
||||||
*/
|
*/
|
||||||
repost: async (note: NEvent) => {
|
repost: async (note: TaggedRawEvent) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.Repost);
|
||||||
ev.Kind = EventKind.Repost;
|
ev.tags.push(["e", note.id, ""]);
|
||||||
ev.Content = JSON.stringify(note.Original);
|
ev.tags.push(["p", note.pubkey]);
|
||||||
ev.Tags.push(new Tag(["e", note.Id], 0));
|
|
||||||
ev.Tags.push(new Tag(["p", note.PubKey], 1));
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
decryptDm: async (note: NEvent): Promise<string | undefined> => {
|
decryptDm: async (note: RawEvent): Promise<string | undefined> => {
|
||||||
if (pubKey) {
|
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>";
|
return "<CANT DECRYPT>";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const otherPubKey =
|
const otherPubKey = note.pubkey === pubKey ? unwrap(note.tags.filter(a => a[0] === "p")[0][1]) : note.pubkey;
|
||||||
note.PubKey === pubKey ? unwrap(note.Tags.filter(a => a.Key === "p")[0].PubKey) : note.PubKey;
|
|
||||||
if (hasNip07 && !privKey) {
|
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) {
|
} else if (privKey) {
|
||||||
await note.DecryptDm(privKey, otherPubKey);
|
return await EventExt.decryptDm(note.content, privKey, otherPubKey);
|
||||||
return note.Content;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Decryption failed", e);
|
console.error("Decryption failed", e);
|
||||||
@ -370,18 +344,17 @@ export default function useEventPublisher() {
|
|||||||
},
|
},
|
||||||
sendDm: async (content: string, to: HexKey) => {
|
sendDm: async (content: string, to: HexKey) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, EventKind.DirectMessage);
|
||||||
ev.Kind = EventKind.DirectMessage;
|
ev.content = content;
|
||||||
ev.Content = content;
|
ev.tags.push(["p", to]);
|
||||||
ev.Tags.push(new Tag(["p", to], 0));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (hasNip07 && !privKey) {
|
if (hasNip07 && !privKey) {
|
||||||
const cx: string = await barrierNip07(() => window.nostr.nip04.encrypt(to, content));
|
const cx: string = await barrierNip07(() => window.nostr.nip04.encrypt(to, content));
|
||||||
ev.Content = cx;
|
ev.content = cx;
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
} else if (privKey) {
|
} else if (privKey) {
|
||||||
await ev.EncryptDmForPubkey(to, privKey);
|
ev.content = await EventExt.encryptData(content, to, privKey);
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -399,9 +372,8 @@ export default function useEventPublisher() {
|
|||||||
},
|
},
|
||||||
generic: async (content: string, kind: EventKind) => {
|
generic: async (content: string, kind: EventKind) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = NEvent.ForPubKey(pubKey);
|
const ev = EventExt.forPubKey(pubKey, kind);
|
||||||
ev.Kind = kind;
|
ev.content = content;
|
||||||
ev.Content = content;
|
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,23 +1,21 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { HexKey } from "@snort/nostr";
|
import { HexKey, EventKind } from "@snort/nostr";
|
||||||
import { EventKind, Subscriptions } from "@snort/nostr";
|
|
||||||
import useSubscription from "Feed/Subscription";
|
import { PubkeyReplaceableNoteStore, RequestBuilder } from "System";
|
||||||
|
import useRequestBuilder from "Hooks/useRequestBuilder";
|
||||||
|
|
||||||
export default function useFollowersFeed(pubkey?: HexKey) {
|
export default function useFollowersFeed(pubkey?: HexKey) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
if (!pubkey) return null;
|
if (!pubkey) return null;
|
||||||
const x = new Subscriptions();
|
const b = new RequestBuilder(`followers:${pubkey.slice(0, 12)}`);
|
||||||
x.Id = `followers:${pubkey.slice(0, 12)}`;
|
b.withFilter().kinds([EventKind.ContactList]).tag("p", [pubkey]);
|
||||||
x.Kinds = new Set([EventKind.ContactList]);
|
return b;
|
||||||
x.PTags = new Set([pubkey]);
|
|
||||||
|
|
||||||
return x;
|
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
const followersFeed = useSubscription(sub, { leaveOpen: false, cache: true });
|
const followersFeed = useRequestBuilder<PubkeyReplaceableNoteStore>(PubkeyReplaceableNoteStore, sub);
|
||||||
|
|
||||||
const followers = useMemo(() => {
|
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)
|
a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey)
|
||||||
);
|
);
|
||||||
return [...new Set(contactLists?.map(a => a.pubkey))];
|
return [...new Set(contactLists?.map(a => a.pubkey))];
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useSelector } from "react-redux";
|
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 { RootState } from "State/Store";
|
||||||
|
import { PubkeyReplaceableNoteStore, RequestBuilder } from "System";
|
||||||
|
import useRequestBuilder from "Hooks/useRequestBuilder";
|
||||||
|
|
||||||
export default function useFollowsFeed(pubkey?: HexKey) {
|
export default function useFollowsFeed(pubkey?: HexKey) {
|
||||||
const { publicKey, follows } = useSelector((s: RootState) => s.login);
|
const { publicKey, follows } = useSelector((s: RootState) => s.login);
|
||||||
@ -11,24 +12,22 @@ export default function useFollowsFeed(pubkey?: HexKey) {
|
|||||||
|
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
if (isMe || !pubkey) return null;
|
if (isMe || !pubkey) return null;
|
||||||
const x = new Subscriptions();
|
const b = new RequestBuilder(`follows:${pubkey.slice(0, 12)}`);
|
||||||
x.Id = `follows:${pubkey.slice(0, 12)}`;
|
b.withFilter().kinds([EventKind.ContactList]).authors([pubkey]);
|
||||||
x.Kinds = new Set([EventKind.ContactList]);
|
return b;
|
||||||
x.Authors = new Set([pubkey]);
|
|
||||||
return x;
|
|
||||||
}, [isMe, pubkey]);
|
}, [isMe, pubkey]);
|
||||||
|
|
||||||
const contactFeed = useSubscription(sub, { leaveOpen: false, cache: true });
|
const contactFeed = useRequestBuilder<PubkeyReplaceableNoteStore>(PubkeyReplaceableNoteStore, sub);
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (isMe) {
|
if (isMe) {
|
||||||
return follows;
|
return follows;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getFollowing(contactFeed.store.notes ?? [], pubkey);
|
return getFollowing(contactFeed.data ?? [], pubkey);
|
||||||
}, [contactFeed.store, follows, 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 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]));
|
const pTags = contactLists?.map(a => a.tags.filter(b => b[0] === "p").map(c => c[1]));
|
||||||
return [...new Set(pTags?.flat())];
|
return [...new Set(pTags?.flat())];
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
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 { makeNotification } from "Notifications";
|
||||||
import { TaggedRawEvent, HexKey, Lists } from "@snort/nostr";
|
|
||||||
import { Event, EventKind, Subscriptions } from "@snort/nostr";
|
|
||||||
import {
|
import {
|
||||||
addDirectMessage,
|
addDirectMessage,
|
||||||
setFollows,
|
setFollows,
|
||||||
@ -18,11 +18,12 @@ import {
|
|||||||
setLatestNotifications,
|
setLatestNotifications,
|
||||||
} from "State/Login";
|
} from "State/Login";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import useSubscription from "Feed/Subscription";
|
|
||||||
import { barrierNip07 } from "Feed/EventPublisher";
|
import { barrierNip07 } from "Feed/EventPublisher";
|
||||||
import { getMutedKeys } from "Feed/MuteList";
|
import { getMutedKeys } from "Feed/MuteList";
|
||||||
import useModeration from "Hooks/useModeration";
|
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
|
* Managed loading data for the current logged in user
|
||||||
@ -37,143 +38,75 @@ export default function useLoginFeed() {
|
|||||||
} = useSelector((s: RootState) => s.login);
|
} = useSelector((s: RootState) => s.login);
|
||||||
const { isMuted } = useModeration();
|
const { isMuted } = useModeration();
|
||||||
|
|
||||||
const subMetadata = useMemo(() => {
|
const subLogin = useMemo(() => {
|
||||||
if (!pubKey) return null;
|
if (!pubKey) return null;
|
||||||
|
|
||||||
const sub = new Subscriptions();
|
const b = new RequestBuilder("login");
|
||||||
sub.Id = `login:meta`;
|
b.withOptions({
|
||||||
sub.Authors = new Set([pubKey]);
|
leaveOpen: true,
|
||||||
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));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}, [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(() => {
|
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));
|
dispatch(setMuted(muted));
|
||||||
|
|
||||||
const newest = getNewest(mutedFeed.store.notes);
|
const newest = getNewest(mutedFeed);
|
||||||
if (newest && newest.content.length > 0 && pubKey && newest.created_at > latestMuted) {
|
if (newest && newest.content.length > 0 && pubKey && newest.created_at > latestMuted) {
|
||||||
decryptBlocked(newest, pubKey, privKey)
|
decryptBlocked(newest, pubKey, privKey)
|
||||||
.then(plaintext => {
|
.then(plaintext => {
|
||||||
@ -192,57 +125,64 @@ export default function useLoginFeed() {
|
|||||||
})
|
})
|
||||||
.catch(error => console.warn(error));
|
.catch(error => console.warn(error));
|
||||||
}
|
}
|
||||||
}, [dispatch, mutedFeed.store]);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
function handlePinnedFeed(pinnedFeed: TaggedRawEvent[]) {
|
||||||
const newest = getNewest(pinnedFeed.store.notes);
|
const newest = getNewestEventTagsByKey(pinnedFeed, "e");
|
||||||
if (newest) {
|
if (newest) {
|
||||||
const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === "e").map(p => p[1]);
|
dispatch(setPinned(newest));
|
||||||
dispatch(
|
|
||||||
setPinned({
|
|
||||||
keys,
|
|
||||||
createdAt: newest.created_at,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [dispatch, pinnedFeed.store]);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
function handleTagFeed(tagFeed: TaggedRawEvent[]) {
|
||||||
const newest = getNewest(tagsFeed.store.notes);
|
const newest = getNewestEventTagsByKey(tagFeed, "t");
|
||||||
if (newest) {
|
if (newest) {
|
||||||
const tags = newest.tags.filter(p => p && p.length === 2 && p[0] === "t").map(p => p[1]);
|
|
||||||
dispatch(
|
dispatch(
|
||||||
setTags({
|
setTags({
|
||||||
tags,
|
tags: newest.keys,
|
||||||
createdAt: newest.created_at,
|
createdAt: newest.createdAt,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [dispatch, tagsFeed.store]);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
function handleBookmarkFeed(bookmarkFeed: TaggedRawEvent[]) {
|
||||||
const newest = getNewest(bookmarkFeed.store.notes);
|
const newest = getNewestEventTagsByKey(bookmarkFeed, "e");
|
||||||
if (newest) {
|
if (newest) {
|
||||||
const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === "e").map(p => p[1]);
|
dispatch(setBookmarked(newest));
|
||||||
dispatch(
|
|
||||||
setBookmarked({
|
|
||||||
keys,
|
|
||||||
createdAt: newest.created_at,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [dispatch, bookmarkFeed.store]);
|
}
|
||||||
|
|
||||||
|
const listsFeed = useRequestBuilder<FlatNoteStore>(FlatNoteStore, subLists);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage);
|
if (listsFeed.data) {
|
||||||
dispatch(addDirectMessage(dms));
|
const getList = (evs: readonly TaggedRawEvent[], list: Lists) =>
|
||||||
}, [dispatch, dmsFeed.store]);
|
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) {
|
async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) {
|
||||||
const ev = new Event(raw);
|
|
||||||
if (pubKey && privKey) {
|
if (pubKey && privKey) {
|
||||||
return await ev.DecryptData(raw.content, privKey, pubKey);
|
return await EventExt.decryptData(raw.content, privKey, pubKey);
|
||||||
} else {
|
} else {
|
||||||
return await barrierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content));
|
return await barrierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content));
|
||||||
}
|
}
|
||||||
|
@ -2,10 +2,11 @@ import { useMemo } from "react";
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
import { getNewest } from "Util";
|
import { getNewest } from "Util";
|
||||||
import { HexKey, TaggedRawEvent, Lists } from "@snort/nostr";
|
import { HexKey, TaggedRawEvent, Lists, EventKind } from "@snort/nostr";
|
||||||
import { EventKind, Subscriptions } from "@snort/nostr";
|
|
||||||
import useSubscription, { NoteStore } from "Feed/Subscription";
|
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
|
import { ParameterizedReplaceableNoteStore, RequestBuilder } from "System";
|
||||||
|
import useRequestBuilder from "Hooks/useRequestBuilder";
|
||||||
|
|
||||||
export default function useMutedFeed(pubkey?: HexKey) {
|
export default function useMutedFeed(pubkey?: HexKey) {
|
||||||
const { publicKey, muted } = useSelector((s: RootState) => s.login);
|
const { publicKey, muted } = useSelector((s: RootState) => s.login);
|
||||||
@ -13,23 +14,19 @@ export default function useMutedFeed(pubkey?: HexKey) {
|
|||||||
|
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
if (isMe || !pubkey) return null;
|
if (isMe || !pubkey) return null;
|
||||||
const sub = new Subscriptions();
|
const b = new RequestBuilder(`muted:${pubkey.slice(0, 12)}`);
|
||||||
sub.Id = `muted:${pubkey.slice(0, 12)}`;
|
b.withFilter().authors([pubkey]).kinds([EventKind.PubkeyLists]).tag("d", [Lists.Muted]);
|
||||||
sub.Kinds = new Set([EventKind.PubkeyLists]);
|
return b;
|
||||||
sub.Authors = new Set([pubkey]);
|
|
||||||
sub.DTags = new Set([Lists.Muted]);
|
|
||||||
sub.Limit = 1;
|
|
||||||
return sub;
|
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
const mutedFeed = useSubscription(sub, { leaveOpen: false, cache: true });
|
const mutedFeed = useRequestBuilder<ParameterizedReplaceableNoteStore>(ParameterizedReplaceableNoteStore, sub);
|
||||||
|
|
||||||
const mutedList = useMemo(() => {
|
const mutedList = useMemo(() => {
|
||||||
if (pubkey) {
|
if (pubkey && mutedFeed.data) {
|
||||||
return getMuted(mutedFeed.store, pubkey);
|
return getMuted(mutedFeed.data, pubkey);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}, [mutedFeed.store, pubkey]);
|
}, [mutedFeed, pubkey]);
|
||||||
|
|
||||||
return isMe ? muted : mutedList;
|
return isMe ? muted : mutedList;
|
||||||
}
|
}
|
||||||
@ -50,7 +47,7 @@ export function getMutedKeys(rawNotes: TaggedRawEvent[]): {
|
|||||||
return { createdAt: 0, keys: [] };
|
return { createdAt: 0, keys: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] {
|
export function getMuted(feed: readonly TaggedRawEvent[], pubkey: HexKey): HexKey[] {
|
||||||
const lists = feed?.notes.filter(a => a.kind === EventKind.PubkeyLists && a.pubkey === pubkey);
|
const lists = feed.filter(a => a.kind === EventKind.PubkeyLists && a.pubkey === pubkey);
|
||||||
return getMutedKeys(lists).keys;
|
return getMutedKeys(lists).keys;
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ import { useSelector } from "react-redux";
|
|||||||
|
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { HexKey, Lists } from "@snort/nostr";
|
import { HexKey, Lists } from "@snort/nostr";
|
||||||
import useNotelistSubscription from "Feed/useNotelistSubscription";
|
import useNotelistSubscription from "Hooks/useNotelistSubscription";
|
||||||
|
|
||||||
export default function usePinnedFeed(pubkey?: HexKey) {
|
export default function usePinnedFeed(pubkey?: HexKey) {
|
||||||
const { pinned } = useSelector((s: RootState) => s.login);
|
const { pinned } = useSelector((s: RootState) => s.login);
|
||||||
|
@ -1,28 +1,26 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { HexKey, FullRelaySettings } from "@snort/nostr";
|
import { HexKey, FullRelaySettings, EventKind } from "@snort/nostr";
|
||||||
import { EventKind, Subscriptions } from "@snort/nostr";
|
|
||||||
import useSubscription from "./Subscription";
|
import { RequestBuilder } from "System";
|
||||||
|
import { ReplaceableNoteStore } from "System/NoteCollection";
|
||||||
|
import useRequestBuilder from "Hooks/useRequestBuilder";
|
||||||
|
|
||||||
export default function useRelaysFeed(pubkey?: HexKey) {
|
export default function useRelaysFeed(pubkey?: HexKey) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
if (!pubkey) return null;
|
if (!pubkey) return null;
|
||||||
const x = new Subscriptions();
|
const b = new RequestBuilder(`relays:${pubkey.slice(0, 12)}`);
|
||||||
x.Id = `relays:${pubkey.slice(0, 12)}`;
|
b.withFilter().authors([pubkey]).kinds([EventKind.ContactList]);
|
||||||
x.Kinds = new Set([EventKind.ContactList]);
|
return b;
|
||||||
x.Authors = new Set([pubkey]);
|
|
||||||
x.Limit = 1;
|
|
||||||
return x;
|
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
const relays = useSubscription(sub, { leaveOpen: false, cache: false });
|
const relays = useRequestBuilder<ReplaceableNoteStore>(ReplaceableNoteStore, sub);
|
||||||
const eventContent = relays.store.notes[0]?.content;
|
|
||||||
|
|
||||||
if (!eventContent) {
|
if (!relays.data?.content) {
|
||||||
return [] as FullRelaySettings[];
|
return [] as FullRelaySettings[];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return Object.entries(JSON.parse(eventContent)).map(([url, settings]) => ({
|
return Object.entries(JSON.parse(relays.data.content)).map(([url, settings]) => ({
|
||||||
url,
|
url,
|
||||||
settings,
|
settings,
|
||||||
})) as FullRelaySettings[];
|
})) 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 { 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 { useSelector } from "react-redux";
|
||||||
|
import { u256, EventKind } from "@snort/nostr";
|
||||||
|
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { UserPreferences } from "State/Login";
|
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) {
|
export default function useThreadFeed(link: NostrLink) {
|
||||||
const [trackingEvents, setTrackingEvent] = useState<u256[]>([link.id]);
|
const [trackingEvents, setTrackingEvent] = useState<u256[]>([link.id]);
|
||||||
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
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 sub = useMemo(() => {
|
||||||
const thisSub = new Subscriptions();
|
const sub = new RequestBuilder(`thread:${link.id.substring(0, 8)}`);
|
||||||
thisSub.Id = `thread:${link.id.substring(0, 8)}`;
|
sub.withOptions({
|
||||||
thisSub.Ids = new Set(trackingEvents);
|
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
|
return sub;
|
||||||
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;
|
|
||||||
}, [trackingEvents, pref, link.id]);
|
}, [trackingEvents, pref, link.id]);
|
||||||
|
|
||||||
const main = useSubscription(sub, { leaveOpen: true, cache: true });
|
const store = useRequestBuilder<FlatNoteStore>(FlatNoteStore, sub);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (main.store) {
|
if (store.data) {
|
||||||
return debounce(200, () => {
|
return debounce(500, () => {
|
||||||
const mainNotes = main.store.notes.filter(a => a.kind === EventKind.TextNote);
|
const mainNotes = store.data?.filter(a => a.kind === EventKind.TextNote) ?? [];
|
||||||
|
|
||||||
const eTags = mainNotes
|
const eTags = mainNotes
|
||||||
.filter(a => a.kind === EventKind.TextNote)
|
.filter(a => a.kind === EventKind.TextNote)
|
||||||
.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1]))
|
.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1]))
|
||||||
.flat();
|
.flat();
|
||||||
const ids = mainNotes.map(a => a.id);
|
const eTagsMissing = eTags.filter(a => !mainNotes.some(b => b.id === a));
|
||||||
const allEvents = new Set([...eTags, ...ids]);
|
setTrackingEvent(s => appendDedupe(s, eTagsMissing));
|
||||||
addId(Array.from(allEvents));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [main.store]);
|
}, [store]);
|
||||||
|
|
||||||
return main.store;
|
return store;
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,19 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
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 { useSelector } from "react-redux";
|
||||||
|
import { EventKind, u256 } from "@snort/nostr";
|
||||||
|
|
||||||
|
import { unixNow, unwrap, tagFilterOfTextRepost } from "Util";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { UserPreferences } from "State/Login";
|
import { UserPreferences } from "State/Login";
|
||||||
|
import { FlatNoteStore, RequestBuilder } from "System";
|
||||||
|
import useRequestBuilder from "Hooks/useRequestBuilder";
|
||||||
|
import useTimelineWindow from "Hooks/useTimelineWindow";
|
||||||
|
|
||||||
export interface TimelineFeedOptions {
|
export interface TimelineFeedOptions {
|
||||||
method: "TIME_RANGE" | "LIMIT_UNTIL";
|
method: "TIME_RANGE" | "LIMIT_UNTIL";
|
||||||
window?: number;
|
window?: number;
|
||||||
relay?: string;
|
relay?: string;
|
||||||
|
now?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelineSubject {
|
export interface TimelineSubject {
|
||||||
@ -19,142 +22,141 @@ export interface TimelineSubject {
|
|||||||
items: string[];
|
items: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TimelineFeed = ReturnType<typeof useTimelineFeed>;
|
||||||
|
|
||||||
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
|
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
|
||||||
const now = unixNow();
|
const { now, since, until, older, setUntil } = useTimelineWindow({
|
||||||
const [window] = useState<number>(options.window ?? 60 * 60);
|
window: options.window,
|
||||||
const [until, setUntil] = useState<number>(now);
|
now: options.now ?? unixNow(),
|
||||||
const [since, setSince] = useState<number>(now - window);
|
});
|
||||||
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
|
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
|
||||||
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
|
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
|
||||||
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
||||||
|
|
||||||
const createSub = useCallback(() => {
|
const createBuilder = useCallback(() => {
|
||||||
if (subject.type !== "global" && subject.items.length === 0) {
|
if (subject.type !== "global" && subject.items.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sub = new Subscriptions();
|
const b = new RequestBuilder(`timeline:${subject.type}:${subject.discriminator}`);
|
||||||
sub.Id = `timeline:${subject.type}:${subject.discriminator}`;
|
const f = b.withFilter().kinds([EventKind.TextNote, EventKind.Repost]);
|
||||||
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
|
|
||||||
|
if (options.relay) {
|
||||||
|
b.withOptions({
|
||||||
|
leaveOpen: false,
|
||||||
|
relays: [options.relay],
|
||||||
|
});
|
||||||
|
}
|
||||||
switch (subject.type) {
|
switch (subject.type) {
|
||||||
case "pubkey": {
|
case "pubkey": {
|
||||||
sub.Authors = new Set(subject.items);
|
f.authors(subject.items);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "hashtag": {
|
case "hashtag": {
|
||||||
sub.HashTags = new Set(subject.items);
|
f.tag("t", subject.items);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "ptag": {
|
case "ptag": {
|
||||||
sub.PTags = new Set(subject.items);
|
f.tag("p", subject.items);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "keyword": {
|
case "keyword": {
|
||||||
sub.Kinds.add(EventKind.SetMetadata);
|
f.search(subject.items[0]);
|
||||||
sub.Search = subject.items[0];
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sub;
|
return {
|
||||||
}, [subject.type, subject.items, subject.discriminator, options.relay]);
|
builder: b,
|
||||||
|
filter: f,
|
||||||
|
};
|
||||||
|
}, [subject.type, subject.items, subject.discriminator]);
|
||||||
|
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
const sub = createSub();
|
const rb = createBuilder();
|
||||||
if (sub) {
|
if (rb) {
|
||||||
if (options.method === "LIMIT_UNTIL") {
|
if (options.method === "LIMIT_UNTIL") {
|
||||||
sub.Until = until;
|
rb.filter.until(until).limit(10);
|
||||||
sub.Limit = 10;
|
|
||||||
} else {
|
} else {
|
||||||
sub.Since = since;
|
rb.filter.since(since).until(until);
|
||||||
sub.Until = until;
|
|
||||||
if (since === undefined) {
|
if (since === undefined) {
|
||||||
sub.Limit = 50;
|
rb.filter.limit(50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pref.autoShowLatest) {
|
if (pref.autoShowLatest) {
|
||||||
// copy properties of main sub but with limit 0
|
// copy properties of main sub but with limit 0
|
||||||
// this will put latest directly into main feed
|
// this will put latest directly into main feed
|
||||||
const latestSub = new Subscriptions();
|
rb.builder
|
||||||
latestSub.Authors = sub.Authors;
|
.withOptions({
|
||||||
latestSub.HashTags = sub.HashTags;
|
leaveOpen: true,
|
||||||
latestSub.PTags = sub.PTags;
|
})
|
||||||
latestSub.Kinds = sub.Kinds;
|
.withFilter()
|
||||||
latestSub.Search = sub.Search;
|
.authors(rb.filter.filter.authors)
|
||||||
latestSub.Limit = 1;
|
.kinds(rb.filter.filter.kinds)
|
||||||
latestSub.Since = Math.floor(new Date().getTime() / 1000);
|
.tag("p", rb.filter.filter["#p"])
|
||||||
sub.AddSubscription(latestSub);
|
.tag("t", rb.filter.filter["#t"])
|
||||||
|
.search(rb.filter.filter.search)
|
||||||
|
.limit(1)
|
||||||
|
.since(now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sub;
|
return rb?.builder ?? null;
|
||||||
}, [until, since, options.method, pref, createSub]);
|
}, [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 subRealtime = useMemo(() => {
|
||||||
const subLatest = createSub();
|
const rb = createBuilder();
|
||||||
if (subLatest && !pref.autoShowLatest) {
|
if (rb && !pref.autoShowLatest) {
|
||||||
subLatest.Id = `${subLatest.Id}:latest`;
|
rb.builder.withOptions({
|
||||||
subLatest.Limit = 1;
|
leaveOpen: true,
|
||||||
subLatest.Since = Math.floor(new Date().getTime() / 1000);
|
});
|
||||||
|
rb.builder.id = `${rb.builder.id}:latest`;
|
||||||
|
rb.filter.limit(1).since(now);
|
||||||
}
|
}
|
||||||
return subLatest;
|
return rb?.builder ?? null;
|
||||||
}, [pref, createSub]);
|
}, [pref.autoShowLatest, createBuilder]);
|
||||||
|
|
||||||
const latest = useSubscription(subRealtime, {
|
const latest = useRequestBuilder<FlatNoteStore>(FlatNoteStore, subRealtime);
|
||||||
leaveOpen: true,
|
|
||||||
cache: false,
|
|
||||||
relay: options.relay,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// clear store if chaning relays
|
// clear store if changing relays
|
||||||
main.clear();
|
main.store.clear();
|
||||||
latest.clear();
|
latest.store.clear();
|
||||||
}, [options.relay]);
|
}, [options.relay]);
|
||||||
|
|
||||||
const subNext = useMemo(() => {
|
const subNext = useMemo(() => {
|
||||||
let sub: Subscriptions | undefined;
|
const rb = new RequestBuilder(`timeline-related:${subject.type}`);
|
||||||
if (trackingEvents.length > 0) {
|
if (trackingEvents.length > 0) {
|
||||||
sub = new Subscriptions();
|
rb.withFilter()
|
||||||
sub.Id = `timeline-related:${subject.type}`;
|
.kinds(
|
||||||
sub.Kinds = new Set(
|
pref.enableReactions ? [EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt] : [EventKind.ZapReceipt]
|
||||||
pref.enableReactions ? [EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt] : [EventKind.ZapReceipt]
|
)
|
||||||
);
|
.tag("e", trackingEvents);
|
||||||
sub.ETags = new Set(trackingEvents);
|
|
||||||
}
|
}
|
||||||
return sub ?? null;
|
if (trackingParentEvents.length > 0) {
|
||||||
|
rb.withFilter().ids(trackingParentEvents);
|
||||||
|
}
|
||||||
|
return rb.numFilters > 0 ? rb : null;
|
||||||
}, [trackingEvents, pref, subject.type]);
|
}, [trackingEvents, pref, subject.type]);
|
||||||
|
|
||||||
const others = useSubscription(subNext, { leaveOpen: true, cache: subject.type !== "global", relay: options.relay });
|
const related = useRequestBuilder<FlatNoteStore>(FlatNoteStore, subNext);
|
||||||
|
|
||||||
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 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (main.store.notes.length > 0) {
|
if (main.data && main.data.length > 0) {
|
||||||
setTrackingEvent(s => {
|
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))) {
|
if (ids.some(a => !s.includes(a))) {
|
||||||
return Array.from(new Set([...s, ...ids]));
|
return Array.from(new Set([...s, ...ids]));
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
});
|
});
|
||||||
const repostsByKind6 = main.store.notes
|
const repostsByKind6 = main.data
|
||||||
.filter(a => a.kind === EventKind.Repost && a.content === "")
|
.filter(a => a.kind === EventKind.Repost && a.content === "")
|
||||||
.map(a => a.tags.find(b => b[0] === "e"))
|
.map(a => a.tags.find(b => b[0] === "e"))
|
||||||
.filter(a => a)
|
.filter(a => a)
|
||||||
.map(a => unwrap(a)[1]);
|
.map(a => unwrap(a)[1]);
|
||||||
const repostsByKind1 = main.store.notes
|
const repostsByKind1 = main.data
|
||||||
.filter(
|
.filter(
|
||||||
a => (a.kind === EventKind.Repost || a.kind === EventKind.TextNote) && a.tags.some(tagFilterOfTextRepost(a))
|
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 {
|
return {
|
||||||
main: main.store,
|
main: main.data,
|
||||||
related: others.store,
|
related: related.data,
|
||||||
latest: latest.store,
|
latest: latest.data,
|
||||||
parent: parent.store,
|
loading: main.store.loading,
|
||||||
loadMore: () => {
|
loadMore: () => {
|
||||||
console.debug("Timeline load more!");
|
if (main.data) {
|
||||||
if (options.method === "LIMIT_UNTIL") {
|
console.debug("Timeline load more!");
|
||||||
const oldest = main.store.notes.reduce((acc, v) => (acc = v.created_at < acc ? v.created_at : acc), unixNow());
|
if (options.method === "LIMIT_UNTIL") {
|
||||||
setUntil(oldest);
|
const oldest = main.data.reduce((acc, v) => (acc = v.created_at < acc ? v.created_at : acc), unixNow());
|
||||||
} else {
|
setUntil(oldest);
|
||||||
setUntil(s => s - window);
|
} else {
|
||||||
setSince(s => s - window);
|
older();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showLatest: () => {
|
showLatest: () => {
|
||||||
main.append(latest.store.notes);
|
if (latest.data) {
|
||||||
latest.clear();
|
main.store.add(latest.data);
|
||||||
|
latest.store.clear();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,26 +1,29 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { HexKey, EventKind, Subscriptions } from "@snort/nostr";
|
import { HexKey, EventKind } from "@snort/nostr";
|
||||||
|
|
||||||
import { parseZap } from "Element/Zap";
|
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) {
|
export default function useZapsFeed(pubkey?: HexKey) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
if (!pubkey) return null;
|
if (!pubkey) return null;
|
||||||
const x = new Subscriptions();
|
const b = new RequestBuilder(`zaps:${pubkey.slice(0, 12)}`);
|
||||||
x.Id = `zaps:${pubkey.slice(0, 12)}`;
|
b.withFilter().tag("p", [pubkey]).kinds([EventKind.ZapReceipt]);
|
||||||
x.Kinds = new Set([EventKind.ZapReceipt]);
|
return b;
|
||||||
x.PTags = new Set([pubkey]);
|
|
||||||
return x;
|
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
const zapsFeed = useSubscription(sub, { leaveOpen: false, cache: true });
|
const zapsFeed = useRequestBuilder<FlatNoteStore>(FlatNoteStore, sub);
|
||||||
|
|
||||||
const zaps = useMemo(() => {
|
const zaps = useMemo(() => {
|
||||||
const profileZaps = zapsFeed.store.notes
|
if (zapsFeed.data) {
|
||||||
.map(parseZap)
|
const profileZaps = zapsFeed.data
|
||||||
.filter(z => z.valid && z.receiver === pubkey && z.sender !== pubkey && !z.event);
|
.map(parseZap)
|
||||||
profileZaps.sort((a, b) => b.amount - a.amount);
|
.filter(z => z.valid && z.receiver === pubkey && z.sender !== pubkey && !z.event);
|
||||||
return profileZaps;
|
profileZaps.sort((a, b) => b.amount - a.amount);
|
||||||
|
return profileZaps;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}, [zapsFeed]);
|
}, [zapsFeed]);
|
||||||
|
|
||||||
return zaps;
|
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 { useEffect, useSyncExternalStore } from "react";
|
||||||
import { MetadataCache } from "State/Users";
|
|
||||||
import { HexKey } from "@snort/nostr";
|
import { HexKey } from "@snort/nostr";
|
||||||
import { System } from "System";
|
|
||||||
|
import { MetadataCache } from "State/Users";
|
||||||
import { UserCache } from "State/Users/UserCache";
|
import { UserCache } from "State/Users/UserCache";
|
||||||
|
import { ProfileLoader } from "System/ProfileCache";
|
||||||
|
|
||||||
export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined {
|
export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined {
|
||||||
const user = useSyncExternalStore<MetadataCache | undefined>(
|
const user = useSyncExternalStore<MetadataCache | undefined>(
|
||||||
@ -12,8 +13,8 @@ export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
System.TrackMetadata(pubKey);
|
ProfileLoader.TrackMetadata(pubKey);
|
||||||
return () => System.UntrackMetadata(pubKey);
|
return () => ProfileLoader.UntrackMetadata(pubKey);
|
||||||
}
|
}
|
||||||
}, [pubKey]);
|
}, [pubKey]);
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Event, HexKey } from "@snort/nostr";
|
import { HexKey, RawEvent } from "@snort/nostr";
|
||||||
import { EmailRegex } from "Const";
|
import { EmailRegex } from "Const";
|
||||||
import { bech32ToText, unwrap } from "Util";
|
import { bech32ToText, unwrap } from "Util";
|
||||||
|
|
||||||
@ -63,7 +63,7 @@ export class LNURL {
|
|||||||
* @param zap
|
* @param zap
|
||||||
* @returns
|
* @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 callback = new URL(unwrap(this.#service?.callback));
|
||||||
const query = new Map<string, string>();
|
const query = new Map<string, string>();
|
||||||
|
|
||||||
@ -81,7 +81,7 @@ export class LNURL {
|
|||||||
query.set("comment", comment);
|
query.set("comment", comment);
|
||||||
}
|
}
|
||||||
if (this.#service?.nostrPubkey && zap) {
|
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}`;
|
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 { useEffect, useMemo, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
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 Icon from "Icons/Icon";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { init, setRelays } from "State/Login";
|
import { init, setRelays } from "State/Login";
|
||||||
@ -11,34 +15,21 @@ import { System } from "System";
|
|||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
import useLoginFeed from "Feed/LoginFeed";
|
import useLoginFeed from "Feed/LoginFeed";
|
||||||
import { totalUnread } from "Pages/MessagesPage";
|
import { totalUnread } from "Pages/MessagesPage";
|
||||||
import { SearchRelays, SnortPubKey } from "Const";
|
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
import { bech32ToHex } from "Util";
|
|
||||||
import { NoteCreator } from "Element/NoteCreator";
|
import { NoteCreator } from "Element/NoteCreator";
|
||||||
import { RelaySettings } from "@snort/nostr";
|
|
||||||
import { FormattedMessage } from "react-intl";
|
|
||||||
import messages from "./messages";
|
|
||||||
import { db } from "Db";
|
import { db } from "Db";
|
||||||
import { UserCache } from "State/Users/UserCache";
|
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() {
|
export default function Layout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const { loggedOut, publicKey, relays, preferences, newUserKey } = useSelector((s: RootState) => s.login);
|
||||||
loggedOut,
|
|
||||||
publicKey,
|
|
||||||
relays,
|
|
||||||
latestNotification,
|
|
||||||
readNotifications,
|
|
||||||
dms,
|
|
||||||
preferences,
|
|
||||||
newUserKey,
|
|
||||||
dmInteraction,
|
|
||||||
} = useSelector((s: RootState) => s.login);
|
|
||||||
const { isMuted } = useModeration();
|
|
||||||
const [pageClass, setPageClass] = useState("page");
|
const [pageClass, setPageClass] = useState("page");
|
||||||
const pub = useEventPublisher();
|
const pub = useEventPublisher();
|
||||||
useLoginFeed();
|
useLoginFeed();
|
||||||
@ -61,35 +52,22 @@ export default function Layout() {
|
|||||||
}
|
}
|
||||||
}, [location]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
System.HandleAuth = pub.nip42Auth;
|
System.HandleAuth = pub.nip42Auth;
|
||||||
}, [pub]);
|
}, [pub]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (relays) {
|
if (relays) {
|
||||||
for (const [k, v] of Object.entries(relays)) {
|
(async () => {
|
||||||
System.ConnectToRelay(k, v);
|
for (const [k, v] of Object.entries(relays)) {
|
||||||
}
|
await System.ConnectToRelay(k, v);
|
||||||
for (const [k] of System.Sockets) {
|
|
||||||
if (!relays[k] && !SearchRelays.has(k)) {
|
|
||||||
System.DisconnectRelay(k);
|
|
||||||
}
|
}
|
||||||
}
|
for (const [k, c] of System.Sockets) {
|
||||||
|
if (!relays[k] && !c.Ephemeral) {
|
||||||
|
System.DisconnectRelay(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
}, [relays]);
|
}, [relays]);
|
||||||
|
|
||||||
@ -124,6 +102,7 @@ export default function Layout() {
|
|||||||
db.ready = a;
|
db.ready = a;
|
||||||
if (a) {
|
if (a) {
|
||||||
await UserCache.preload();
|
await UserCache.preload();
|
||||||
|
await FollowsRelays.preload();
|
||||||
}
|
}
|
||||||
console.debug(`Using db: ${a ? "IndexedDB" : "In-Memory"}`);
|
console.debug(`Using db: ${a ? "IndexedDB" : "In-Memory"}`);
|
||||||
dispatch(init());
|
dispatch(init());
|
||||||
@ -173,44 +152,6 @@ export default function Layout() {
|
|||||||
}
|
}
|
||||||
}, [newUserKey]);
|
}, [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") {
|
if (typeof loggedOut !== "boolean") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -223,7 +164,7 @@ export default function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{publicKey ? (
|
{publicKey ? (
|
||||||
accountHeader()
|
<AccountHeader />
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={() => navigate("/login")}>
|
<button type="button" onClick={() => navigate("/login")}>
|
||||||
<FormattedMessage {...messages.Login} />
|
<FormattedMessage {...messages.Login} />
|
||||||
@ -242,6 +183,65 @@ export default function Layout() {
|
|||||||
<NoteCreator replyTo={undefined} autoFocus={true} show={show} setShow={setShow} />
|
<NoteCreator replyTo={undefined} autoFocus={true} show={show} setShow={setShow} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{window.localStorage.getItem("debug") && <SubDebug />}
|
||||||
</div>
|
</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 { useIntl, FormattedMessage } from "react-intl";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 { formatShort } from "Number";
|
||||||
import Note from "Element/Note";
|
import Note from "Element/Note";
|
||||||
import Bookmarks from "Element/Bookmarks";
|
import Bookmarks from "Element/Bookmarks";
|
||||||
@ -57,13 +57,53 @@ const BLOCKED = 6;
|
|||||||
const RELAYS = 7;
|
const RELAYS = 7;
|
||||||
const BOOKMARKS = 8;
|
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() {
|
export default function ProfilePage() {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [id, setId] = useState<string>();
|
const [id, setId] = useState<string>();
|
||||||
const user = useUserProfile(id);
|
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 isMe = loginPubKey === id;
|
||||||
const [showLnQr, setShowLnQr] = useState<boolean>(false);
|
const [showLnQr, setShowLnQr] = useState<boolean>(false);
|
||||||
const [showProfileQr, setShowProfileQr] = 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 || "";
|
user?.website && !user.website.startsWith("http") ? "https://" + user.website : user?.website || "";
|
||||||
// feeds
|
// feeds
|
||||||
const { blocked } = useModeration();
|
const { blocked } = useModeration();
|
||||||
const { notes: pinned, related: pinRelated } = usePinnedFeed(id);
|
const pinned = 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 muted = useMutedFeed(id);
|
const muted = useMutedFeed(id);
|
||||||
const badges = useProfileBadges(id);
|
const badges = useProfileBadges(id);
|
||||||
// tabs
|
// tabs
|
||||||
const ProfileTab = {
|
const ProfileTab = {
|
||||||
Notes: { text: formatMessage(messages.Notes), value: NOTES },
|
Notes: { text: formatMessage(messages.Notes), value: NOTES },
|
||||||
Reactions: { text: formatMessage(messages.Reactions), value: REACTIONS },
|
Reactions: { text: formatMessage(messages.Reactions), value: REACTIONS },
|
||||||
Followers: { text: formatMessage(messages.FollowersCount, { n: followers.length }), value: FOLLOWERS },
|
Followers: { text: formatMessage(messages.Followers), value: FOLLOWERS },
|
||||||
Follows: { text: formatMessage(messages.FollowsCount, { n: follows.length }), value: FOLLOWS },
|
Follows: { text: formatMessage(messages.Follows), value: FOLLOWS },
|
||||||
Zaps: { text: formatMessage(messages.ZapsCount, { n: zaps.length }), value: ZAPS },
|
Zaps: { text: formatMessage(messages.Zaps), value: ZAPS },
|
||||||
Muted: { text: formatMessage(messages.MutedCount, { n: muted.length }), value: MUTED },
|
Muted: { text: formatMessage(messages.Muted), value: MUTED },
|
||||||
Blocked: { text: formatMessage(messages.BlockedCount, { n: blocked.length }), value: BLOCKED },
|
Blocked: { text: formatMessage(messages.BlockedCount, { n: blocked.length }), value: BLOCKED },
|
||||||
Relays: { text: formatMessage(messages.RelaysCount, { n: relays.length }), value: RELAYS },
|
Relays: { text: formatMessage(messages.Relays), value: RELAYS },
|
||||||
Bookmarks: { text: formatMessage(messages.BookmarksCount, { n: bookmarks.length }), value: BOOKMARKS },
|
Bookmarks: { text: formatMessage(messages.Bookmarks), value: BOOKMARKS },
|
||||||
};
|
};
|
||||||
const [tab, setTab] = useState<Tab>(ProfileTab.Notes);
|
const [tab, setTab] = useState<Tab>(ProfileTab.Notes);
|
||||||
const optionalTabs = [
|
const optionalTabs = [ProfileTab.Zaps, ProfileTab.Relays, ProfileTab.Bookmarks, ProfileTab.Muted].filter(a =>
|
||||||
zapsTotal > 0 && ProfileTab.Zaps,
|
unwrap(a)
|
||||||
relays.length > 0 && ProfileTab.Relays,
|
) as Tab[];
|
||||||
bookmarks.length > 0 && ProfileTab.Bookmarks,
|
|
||||||
muted.length > 0 && ProfileTab.Muted,
|
|
||||||
].filter(a => unwrap(a)) as Tab[];
|
|
||||||
const horizontalScroll = useHorizontalScroll();
|
const horizontalScroll = useHorizontalScroll();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -190,16 +221,18 @@ export default function ProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
{pinned.map(n => {
|
{pinned
|
||||||
return (
|
.filter(a => a.kind === EventKind.TextNote)
|
||||||
<Note
|
.map(n => {
|
||||||
key={`pinned-${n.id}`}
|
return (
|
||||||
data={n}
|
<Note
|
||||||
related={pinRelated}
|
key={`pinned-${n.id}`}
|
||||||
options={{ showTime: false, showPinned: true, canUnpin: id === loginPubKey }}
|
data={n}
|
||||||
/>
|
related={getReactions(pinned, n.id)}
|
||||||
);
|
options={{ showTime: false, showPinned: true, canUnpin: id === loginPubKey }}
|
||||||
})}
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Timeline
|
<Timeline
|
||||||
key={id}
|
key={id}
|
||||||
@ -216,23 +249,17 @@ export default function ProfilePage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case ZAPS: {
|
case ZAPS: {
|
||||||
return (
|
return <ZapsProfileTab id={id} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case FOLLOWS: {
|
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: {
|
case FOLLOWERS: {
|
||||||
return <FollowsList pubkeys={followers} showAbout={true} />;
|
return <FollowersTab id={id} />;
|
||||||
}
|
}
|
||||||
case MUTED: {
|
case MUTED: {
|
||||||
return <MutedList pubkeys={muted} />;
|
return <MutedList pubkeys={muted} />;
|
||||||
@ -241,10 +268,10 @@ export default function ProfilePage() {
|
|||||||
return <BlockList />;
|
return <BlockList />;
|
||||||
}
|
}
|
||||||
case RELAYS: {
|
case RELAYS: {
|
||||||
return <RelaysMetadata relays={relays} />;
|
return <RelaysTab id={id} />;
|
||||||
}
|
}
|
||||||
case BOOKMARKS: {
|
case BOOKMARKS: {
|
||||||
return <Bookmarks pubkey={id} bookmarks={bookmarks} related={bookmarkRelated} />;
|
return <BookMarksTab id={id} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -260,8 +287,7 @@ export default function ProfilePage() {
|
|||||||
function renderIcons() {
|
function renderIcons() {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
const firstRelay = relays.find(a => a.settings.write)?.url;
|
const link = encodeTLV(id, NostrPrefix.Profile);
|
||||||
const link = encodeTLV(id, NostrPrefix.Profile, firstRelay ? [firstRelay] : undefined);
|
|
||||||
return (
|
return (
|
||||||
<div className="icon-actions">
|
<div className="icon-actions">
|
||||||
<IconButton onClick={() => setShowProfileQr(true)}>
|
<IconButton onClick={() => setShowProfileQr(true)}>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import "./Root.css";
|
import "./Root.css";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useSelector } from "react-redux";
|
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 { useIntl, FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
import Tabs, { Tab } from "Element/Tabs";
|
import Tabs, { Tab } from "Element/Tabs";
|
||||||
@ -9,7 +9,7 @@ import { RootState } from "State/Store";
|
|||||||
import Timeline from "Element/Timeline";
|
import Timeline from "Element/Timeline";
|
||||||
import { System } from "System";
|
import { System } from "System";
|
||||||
import { TimelineSubject } from "Feed/TimelineFeed";
|
import { TimelineSubject } from "Feed/TimelineFeed";
|
||||||
import { debounce, getRelayName, sha256, unwrap } from "Util";
|
import { debounce, getRelayName, sha256, unixNow, unwrap } from "Util";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
|
||||||
@ -20,58 +20,98 @@ interface RelayOption {
|
|||||||
|
|
||||||
export default function RootPage() {
|
export default function RootPage() {
|
||||||
const { formatMessage } = useIntl();
|
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> = {
|
const RootTab: Record<string, Tab> = {
|
||||||
Posts: {
|
Posts: {
|
||||||
text: formatMessage(messages.Posts),
|
text: formatMessage(messages.Posts),
|
||||||
value: 0,
|
value: 0,
|
||||||
|
data: "/posts",
|
||||||
},
|
},
|
||||||
PostsAndReplies: {
|
PostsAndReplies: {
|
||||||
text: formatMessage(messages.Conversations),
|
text: formatMessage(messages.Conversations),
|
||||||
value: 1,
|
value: 1,
|
||||||
|
data: "/conversations",
|
||||||
},
|
},
|
||||||
Global: {
|
Global: {
|
||||||
text: formatMessage(messages.Global),
|
text: formatMessage(messages.Global),
|
||||||
value: 2,
|
value: 2,
|
||||||
|
data: "/global",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const [tab, setTab] = useState<Tab>(() => {
|
const tab = useMemo(() => {
|
||||||
switch (preferences.defaultRootTab) {
|
const pTab = location.pathname.split("/").slice(-1)[0];
|
||||||
case "conversations":
|
switch (pTab) {
|
||||||
|
case "conversations": {
|
||||||
return RootTab.PostsAndReplies;
|
return RootTab.PostsAndReplies;
|
||||||
case "global":
|
}
|
||||||
|
case "global": {
|
||||||
return RootTab.Global;
|
return RootTab.Global;
|
||||||
default:
|
}
|
||||||
|
default: {
|
||||||
return RootTab.Posts;
|
return RootTab.Posts;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}, [location]);
|
||||||
const [relay, setRelay] = useState<RelayOption>();
|
|
||||||
const [allRelays, setAllRelays] = useState<RelayOption[]>();
|
useEffect(() => {
|
||||||
|
if (location.pathname === "/") {
|
||||||
|
navigate(unwrap(preferences.defaultRootTab ?? tab.data), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
const tagTabs = tags.map((t, idx) => {
|
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 tabs = [RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global, ...tagTabs];
|
||||||
const isGlobal = loggedOut || tab.value === RootTab.Global.value;
|
|
||||||
|
|
||||||
function followHints() {
|
return (
|
||||||
if (follows?.length === 0 && pubKey && tab !== RootTab.Global) {
|
<>
|
||||||
return (
|
<div className="main-content">
|
||||||
<FormattedMessage
|
{pubKey && <Tabs tabs={tabs} tab={tab} setTab={t => navigate(unwrap(t.data))} />}
|
||||||
{...messages.NoFollows}
|
</div>
|
||||||
values={{
|
<Outlet />
|
||||||
newUsersPage: (
|
</>
|
||||||
<Link to={"/new/discover"}>
|
);
|
||||||
<FormattedMessage {...messages.NewUsers} />
|
}
|
||||||
</Link>
|
|
||||||
),
|
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() {
|
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 paidRelays = allRelays.filter(a => a.paid);
|
||||||
const publicRelays = allRelays.filter(a => !a.paid);
|
const publicRelays = allRelays.filter(a => !a.paid);
|
||||||
@ -108,60 +148,80 @@ export default function RootPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isGlobal) {
|
return debounce(500, () => {
|
||||||
return debounce(500, () => {
|
const ret: RelayOption[] = [];
|
||||||
const ret: RelayOption[] = [];
|
System.Sockets.forEach((v, k) => {
|
||||||
System.Sockets.forEach((v, k) => {
|
ret.push({
|
||||||
ret.push({
|
url: k,
|
||||||
url: k,
|
paid: v.Info?.limitation?.payment_required ?? false,
|
||||||
paid: v.Info?.limitation?.payment_required ?? false,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
ret.sort(a => (a.paid ? -1 : 1));
|
|
||||||
|
|
||||||
if (ret.length > 0 && !relay) {
|
|
||||||
setRelay(ret[0]);
|
|
||||||
}
|
|
||||||
setAllRelays(ret);
|
|
||||||
});
|
});
|
||||||
}
|
ret.sort(a => (a.paid ? -1 : 1));
|
||||||
}, [relays, relay, tab]);
|
|
||||||
|
|
||||||
const timelineSubect: TimelineSubject = (() => {
|
if (ret.length > 0 && !relay) {
|
||||||
if (isGlobal) {
|
setRelay(ret[0]);
|
||||||
return { type: "global", items: [], discriminator: `all-${sha256(relay?.url ?? "").slice(0, 12)}` };
|
}
|
||||||
}
|
setAllRelays(ret);
|
||||||
if (tab.value >= 3) {
|
});
|
||||||
const hashtag = tab.text.slice(1);
|
}, [relays, relay]);
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="main-content">
|
{globalRelaySelector()}
|
||||||
{pubKey && <Tabs tabs={tabs} tab={tab} setTab={setTab} />}
|
{relay && (
|
||||||
{globalRelaySelector()}
|
<Timeline subject={subject} postsOnly={false} method={"TIME_RANGE"} window={600} relay={relay.url} now={now} />
|
||||||
</div>
|
)}
|
||||||
{followHints()}
|
|
||||||
{renderTimeline()}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
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>
|
</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="card flex">
|
||||||
<div className="flex f-col f-grow">
|
<div className="flex f-col f-grow">
|
||||||
<div>
|
<div>
|
||||||
|
@ -75,18 +75,23 @@ const RelayInfo = () => {
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="f-grow">
|
<div className="f-grow">
|
||||||
{stats.info.supported_nips.map(a => (
|
{stats.info.supported_nips.map(a => (
|
||||||
<span
|
<a
|
||||||
key={a}
|
target="_blank"
|
||||||
className="pill"
|
rel="noreferrer"
|
||||||
onClick={() =>
|
href={`https://github.com/nostr-protocol/nips/blob/master/${a.toString().padStart(2, "0")}.md`}
|
||||||
navigate(`https://github.com/nostr-protocol/nips/blob/master/${a.toString().padStart(2, "0")}.md`)
|
className="pill">
|
||||||
}>
|
|
||||||
NIP-{a.toString().padStart(2, "0")}
|
NIP-{a.toString().padStart(2, "0")}
|
||||||
</span>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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="flex mt10 f-end">
|
||||||
<div
|
<div
|
||||||
className="btn error"
|
className="btn error"
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
@ -6,17 +6,21 @@ import { randomSample } from "Util";
|
|||||||
import Relay from "Element/Relay";
|
import Relay from "Element/Relay";
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { RelaySettings } from "@snort/nostr";
|
|
||||||
import { setRelays } from "State/Login";
|
import { setRelays } from "State/Login";
|
||||||
|
import { System } from "System";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
|
||||||
const RelaySettingsPage = () => {
|
const RelaySettingsPage = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const publisher = useEventPublisher();
|
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 [newRelay, setNewRelay] = useState<string>();
|
||||||
|
|
||||||
|
const otherConnections = useMemo(() => {
|
||||||
|
return [...System.Sockets.keys()].filter(a => relays[a] === undefined);
|
||||||
|
}, [relays]);
|
||||||
|
|
||||||
async function saveRelays() {
|
async function saveRelays() {
|
||||||
const ev = await publisher.saveRelays();
|
const ev = await publisher.saveRelays();
|
||||||
publisher.broadcast(ev);
|
publisher.broadcast(ev);
|
||||||
@ -84,6 +88,14 @@ const RelaySettingsPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{addRelay()}
|
{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" },
|
Nip05: { defaultMessage: "NIP-05" },
|
||||||
ReactionEmoji: { defaultMessage: "Reaction emoji" },
|
ReactionEmoji: { defaultMessage: "Reaction emoji" },
|
||||||
ReactionEmojiHelp: { defaultMessage: "Emoji to send when reactiong to a note" },
|
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 { RelaySettings } from "@snort/nostr";
|
||||||
import type { AppDispatch, RootState } from "State/Store";
|
import type { AppDispatch, RootState } from "State/Store";
|
||||||
import { ImgProxySettings } from "Hooks/useImgProxy";
|
import { ImgProxySettings } from "Hooks/useImgProxy";
|
||||||
|
import { sanitizeRelayUrl } from "Util";
|
||||||
|
|
||||||
const PrivateKeyItem = "secret";
|
const PrivateKeyItem = "secret";
|
||||||
const PublicKeyItem = "pubkey";
|
const PublicKeyItem = "pubkey";
|
||||||
@ -51,11 +52,6 @@ export interface UserPreferences {
|
|||||||
*/
|
*/
|
||||||
confirmReposts: boolean;
|
confirmReposts: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrite Twitter links to Nitter links
|
|
||||||
*/
|
|
||||||
rewriteTwitterPosts: boolean;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatically show the latests notes
|
* Automatically show the latests notes
|
||||||
*/
|
*/
|
||||||
@ -250,7 +246,6 @@ export const InitState = {
|
|||||||
confirmReposts: false,
|
confirmReposts: false,
|
||||||
showDebugMenus: false,
|
showDebugMenus: false,
|
||||||
autoShowLatest: false,
|
autoShowLatest: false,
|
||||||
rewriteTwitterPosts: false,
|
|
||||||
fileUploader: "void.cat",
|
fileUploader: "void.cat",
|
||||||
imgProxyConfig: DefaultImgProxy,
|
imgProxyConfig: DefaultImgProxy,
|
||||||
defaultRootTab: "posts",
|
defaultRootTab: "posts",
|
||||||
@ -364,7 +359,10 @@ const LoginSlice = createSlice({
|
|||||||
const filtered = new Map<string, RelaySettings>();
|
const filtered = new Map<string, RelaySettings>();
|
||||||
for (const [k, v] of Object.entries(relays)) {
|
for (const [k, v] of Object.entries(relays)) {
|
||||||
if (k.startsWith("wss://") || k.startsWith("ws://")) {
|
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 { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix, decodeTLV, TLVEntryType } from "@snort/nostr";
|
||||||
import { MetadataCache } from "State/Users";
|
import { MetadataCache } from "State/Users";
|
||||||
|
|
||||||
export const sha256 = (str: string) => {
|
export const sha256 = (str: string | Uint8Array): u256 => {
|
||||||
return secp.utils.bytesToHex(hash(str));
|
return secp.utils.bytesToHex(hash(str));
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -138,8 +138,12 @@ export function normalizeReaction(content: string) {
|
|||||||
/**
|
/**
|
||||||
* Get reactions to a specific event (#e + kind filter)
|
* Get reactions to a specific event (#e + kind filter)
|
||||||
*/
|
*/
|
||||||
export function getReactions(notes: TaggedRawEvent[], id: u256, kind = EventKind.Reaction) {
|
export function getReactions(notes: readonly TaggedRawEvent[] | undefined, id: u256, kind?: EventKind) {
|
||||||
return notes?.filter(a => a.kind === kind && a.tags.some(a => a[0] === "e" && a[1] === id)) || [];
|
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 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 {
|
export function unwrap<T>(v: T | undefined | null): T {
|
||||||
if (v === undefined || v === null) {
|
if (v === undefined || v === null) {
|
||||||
throw new Error("missing value");
|
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);
|
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];
|
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) {
|
if (notes.length > 0) {
|
||||||
return notes[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 {
|
export function tagFilterOfTextRepost(note: TaggedRawEvent, id?: u256): (tag: string[], i: number) => boolean {
|
||||||
return (tag, i) =>
|
return (tag, i) =>
|
||||||
tag[0] === "e" && tag[3] === "mention" && note.content === `#[${i}]` && (id ? tag[1] === id : true);
|
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 {
|
import {
|
||||||
InvoiceRequest,
|
InvoiceRequest,
|
||||||
LNWallet,
|
LNWallet,
|
||||||
@ -17,14 +16,13 @@ const defaultHeaders = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default class LNDHubWallet implements LNWallet {
|
export default class LNDHubWallet implements LNWallet {
|
||||||
type: "lndhub" | "snort";
|
type: "lndhub";
|
||||||
url: URL;
|
url: URL;
|
||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password: string;
|
||||||
auth?: AuthResponse;
|
auth?: AuthResponse;
|
||||||
publisher?: EventPublisher;
|
|
||||||
|
|
||||||
constructor(url: string, publisher?: EventPublisher) {
|
constructor(url: string) {
|
||||||
if (url.startsWith("lndhub://")) {
|
if (url.startsWith("lndhub://")) {
|
||||||
const regex = /^lndhub:\/\/([\S-]+):([\S-]+)@(.*)$/i;
|
const regex = /^lndhub:\/\/([\S-]+):([\S-]+)@(.*)$/i;
|
||||||
const parsedUrl = url.match(regex);
|
const parsedUrl = url.match(regex);
|
||||||
@ -36,13 +34,6 @@ export default class LNDHubWallet implements LNWallet {
|
|||||||
this.user = parsedUrl[1];
|
this.user = parsedUrl[1];
|
||||||
this.password = parsedUrl[2];
|
this.password = parsedUrl[2];
|
||||||
this.type = "lndhub";
|
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 {
|
} else {
|
||||||
throw new Error("Invalid config");
|
throw new Error("Invalid config");
|
||||||
}
|
}
|
||||||
@ -61,8 +52,6 @@ export default class LNDHubWallet implements LNWallet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async login() {
|
async login() {
|
||||||
if (this.type === "snort") return true;
|
|
||||||
|
|
||||||
const rsp = await this.getJson<AuthResponse>("POST", "/auth?type=auth", {
|
const rsp = await this.getJson<AuthResponse>("POST", "/auth?type=auth", {
|
||||||
login: this.user,
|
login: this.user,
|
||||||
password: this.password,
|
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> {
|
private async getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||||
let auth = `Bearer ${this.auth?.access_token}`;
|
const 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 url = `${this.url.pathname === "/" ? this.url.toString().slice(0, -1) : this.url.toString()}${path}`;
|
const url = `${this.url.pathname === "/" ? this.url.toString().slice(0, -1) : this.url.toString()}${path}`;
|
||||||
const rsp = await fetch(url, {
|
const rsp = await fetch(url, {
|
||||||
method: method,
|
method: method,
|
||||||
|
@ -10,11 +10,10 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
|||||||
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
|
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
|
||||||
import { IntlProvider } from "IntlProvider";
|
import { IntlProvider } from "IntlProvider";
|
||||||
import Store from "State/Store";
|
import Store from "State/Store";
|
||||||
import EventPage from "Pages/EventPage";
|
|
||||||
import Layout from "Pages/Layout";
|
import Layout from "Pages/Layout";
|
||||||
import LoginPage from "Pages/Login";
|
import LoginPage from "Pages/Login";
|
||||||
import ProfilePage from "Pages/ProfilePage";
|
import ProfilePage from "Pages/ProfilePage";
|
||||||
import RootPage from "Pages/Root";
|
import { RootRoutes } from "Pages/Root";
|
||||||
import NotificationsPage from "Pages/Notifications";
|
import NotificationsPage from "Pages/Notifications";
|
||||||
import SettingsPage, { SettingsRoutes } from "Pages/SettingsPage";
|
import SettingsPage, { SettingsRoutes } from "Pages/SettingsPage";
|
||||||
import ErrorPage from "Pages/ErrorPage";
|
import ErrorPage from "Pages/ErrorPage";
|
||||||
@ -28,6 +27,7 @@ import HelpPage from "Pages/HelpPage";
|
|||||||
import { NewUserRoutes } from "Pages/new";
|
import { NewUserRoutes } from "Pages/new";
|
||||||
import { WalletRoutes } from "Pages/WalletPage";
|
import { WalletRoutes } from "Pages/WalletPage";
|
||||||
import NostrLinkHandler from "Pages/NostrLinkHandler";
|
import NostrLinkHandler from "Pages/NostrLinkHandler";
|
||||||
|
import Thread from "Element/Thread";
|
||||||
import { unwrap } from "Util";
|
import { unwrap } from "Util";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,10 +42,7 @@ export const router = createBrowserRouter([
|
|||||||
element: <Layout />,
|
element: <Layout />,
|
||||||
errorElement: <ErrorPage />,
|
errorElement: <ErrorPage />,
|
||||||
children: [
|
children: [
|
||||||
{
|
...RootRoutes,
|
||||||
path: "/",
|
|
||||||
element: <RootPage />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
element: <LoginPage />,
|
element: <LoginPage />,
|
||||||
@ -56,7 +53,7 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/e/:id",
|
path: "/e/:id",
|
||||||
element: <EventPage />,
|
element: <Thread />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/p/:id",
|
path: "/p/:id",
|
||||||
|
@ -75,9 +75,6 @@
|
|||||||
"2k0Cv+": {
|
"2k0Cv+": {
|
||||||
"defaultMessage": "Dislikes ({n})"
|
"defaultMessage": "Dislikes ({n})"
|
||||||
},
|
},
|
||||||
"3LAxQH": {
|
|
||||||
"defaultMessage": "Rewrite Twitter links to Nitter links"
|
|
||||||
},
|
|
||||||
"3cc4Ct": {
|
"3cc4Ct": {
|
||||||
"defaultMessage": "Light"
|
"defaultMessage": "Light"
|
||||||
},
|
},
|
||||||
@ -366,6 +363,9 @@
|
|||||||
"L7SZPr": {
|
"L7SZPr": {
|
||||||
"defaultMessage": "For more information about donations see {link}."
|
"defaultMessage": "For more information about donations see {link}."
|
||||||
},
|
},
|
||||||
|
"LF5kYT": {
|
||||||
|
"defaultMessage": "Other Connections"
|
||||||
|
},
|
||||||
"LXxsbk": {
|
"LXxsbk": {
|
||||||
"defaultMessage": "Anonymous"
|
"defaultMessage": "Anonymous"
|
||||||
},
|
},
|
||||||
@ -525,9 +525,6 @@
|
|||||||
"X7xU8J": {
|
"X7xU8J": {
|
||||||
"defaultMessage": "nsec, npub, nip-05, hex, mnemonic"
|
"defaultMessage": "nsec, npub, nip-05, hex, mnemonic"
|
||||||
},
|
},
|
||||||
"XSoRjZ": {
|
|
||||||
"defaultMessage": "Nitter Rewrite"
|
|
||||||
},
|
|
||||||
"XgWvGA": {
|
"XgWvGA": {
|
||||||
"defaultMessage": "Reactions"
|
"defaultMessage": "Reactions"
|
||||||
},
|
},
|
||||||
@ -672,9 +669,6 @@
|
|||||||
"iNWbVV": {
|
"iNWbVV": {
|
||||||
"defaultMessage": "Handle"
|
"defaultMessage": "Handle"
|
||||||
},
|
},
|
||||||
"iXPL0Z": {
|
|
||||||
"defaultMessage": "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead"
|
|
||||||
},
|
|
||||||
"ieGrWo": {
|
"ieGrWo": {
|
||||||
"defaultMessage": "Follow"
|
"defaultMessage": "Follow"
|
||||||
},
|
},
|
||||||
@ -792,6 +786,9 @@
|
|||||||
"oxCa4R": {
|
"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."
|
"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": {
|
"puLNUJ": {
|
||||||
"defaultMessage": "Pin"
|
"defaultMessage": "Pin"
|
||||||
},
|
},
|
||||||
@ -864,6 +861,9 @@
|
|||||||
"uSV4Ti": {
|
"uSV4Ti": {
|
||||||
"defaultMessage": "Reposts need to be manually confirmed"
|
"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": {
|
"usAvMr": {
|
||||||
"defaultMessage": "Edit Profile"
|
"defaultMessage": "Edit Profile"
|
||||||
},
|
},
|
||||||
|
@ -24,7 +24,6 @@
|
|||||||
"2LbrkB": "Enter password",
|
"2LbrkB": "Enter password",
|
||||||
"2a2YiP": "{n} Bookmarks",
|
"2a2YiP": "{n} Bookmarks",
|
||||||
"2k0Cv+": "Dislikes ({n})",
|
"2k0Cv+": "Dislikes ({n})",
|
||||||
"3LAxQH": "Rewrite Twitter links to Nitter links",
|
|
||||||
"3cc4Ct": "Light",
|
"3cc4Ct": "Light",
|
||||||
"3gOsZq": "Translators",
|
"3gOsZq": "Translators",
|
||||||
"3t3kok": "{n,plural,=1{{n} new note} other{{n} new notes}}",
|
"3t3kok": "{n,plural,=1{{n} new note} other{{n} new notes}}",
|
||||||
@ -119,6 +118,7 @@
|
|||||||
"KWuDfz": "I have saved my keys, continue",
|
"KWuDfz": "I have saved my keys, continue",
|
||||||
"KahimY": "Unknown event kind: {kind}",
|
"KahimY": "Unknown event kind: {kind}",
|
||||||
"L7SZPr": "For more information about donations see {link}.",
|
"L7SZPr": "For more information about donations see {link}.",
|
||||||
|
"LF5kYT": "Other Connections",
|
||||||
"LXxsbk": "Anonymous",
|
"LXxsbk": "Anonymous",
|
||||||
"LgbKvU": "Comment",
|
"LgbKvU": "Comment",
|
||||||
"LxY9tW": "Generate Key",
|
"LxY9tW": "Generate Key",
|
||||||
@ -170,7 +170,6 @@
|
|||||||
"WONP5O": "Find your twitter follows on nostr (Data provided by {provider})",
|
"WONP5O": "Find your twitter follows on nostr (Data provided by {provider})",
|
||||||
"WxthCV": "e.g. Jack",
|
"WxthCV": "e.g. Jack",
|
||||||
"X7xU8J": "nsec, npub, nip-05, hex, mnemonic",
|
"X7xU8J": "nsec, npub, nip-05, hex, mnemonic",
|
||||||
"XSoRjZ": "Nitter Rewrite",
|
|
||||||
"XgWvGA": "Reactions",
|
"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:",
|
"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",
|
"Y31HTH": "Help fund the development of Snort",
|
||||||
@ -218,7 +217,6 @@
|
|||||||
"iDGAbc": "Get a Snort identifier",
|
"iDGAbc": "Get a Snort identifier",
|
||||||
"iGT1eE": "Prevent fake accounts from imitating you",
|
"iGT1eE": "Prevent fake accounts from imitating you",
|
||||||
"iNWbVV": "Handle",
|
"iNWbVV": "Handle",
|
||||||
"iXPL0Z": "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead",
|
|
||||||
"ieGrWo": "Follow",
|
"ieGrWo": "Follow",
|
||||||
"itPgxd": "Profile",
|
"itPgxd": "Profile",
|
||||||
"izWS4J": "Unfollow",
|
"izWS4J": "Unfollow",
|
||||||
@ -257,6 +255,7 @@
|
|||||||
"odhABf": "Login",
|
"odhABf": "Login",
|
||||||
"osUr8O": "You can also use these extensions to login to most Nostr sites.",
|
"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.",
|
"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",
|
"puLNUJ": "Pin",
|
||||||
"pzTOmv": "Followers",
|
"pzTOmv": "Followers",
|
||||||
"qDwvZ4": "Unknown error",
|
"qDwvZ4": "Unknown error",
|
||||||
@ -281,6 +280,7 @@
|
|||||||
"u4bHcR": "Check out the code here: {link}",
|
"u4bHcR": "Check out the code here: {link}",
|
||||||
"uD/N6c": "Zap {target} {n} sats",
|
"uD/N6c": "Zap {target} {n} sats",
|
||||||
"uSV4Ti": "Reposts need to be manually confirmed",
|
"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",
|
"usAvMr": "Edit Profile",
|
||||||
"ut+2Cd": "Get a partner identifier",
|
"ut+2Cd": "Get a partner identifier",
|
||||||
"vOKedj": "{n,plural,=1{& {n} other} other{& {n} others}}",
|
"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 { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
import { Subscriptions } from "./Subscriptions";
|
|
||||||
import { default as NEvent } from "./Event";
|
|
||||||
import { DefaultConnectTimeout } from "./Const";
|
import { DefaultConnectTimeout } from "./Const";
|
||||||
import { ConnectionStats } from "./ConnectionStats";
|
import { ConnectionStats } from "./ConnectionStats";
|
||||||
import { RawEvent, RawReqFilter, TaggedRawEvent, u256 } from "./index";
|
import { RawEvent, RawReqFilter, ReqCommand, TaggedRawEvent, u256 } from "./index";
|
||||||
import { RelayInfo } from "./RelayInfo";
|
import { RelayInfo } from "./RelayInfo";
|
||||||
import Nips from "./Nips";
|
|
||||||
import { unwrap } from "./Util";
|
import { unwrap } from "./Util";
|
||||||
|
|
||||||
export type CustomHook = (state: Readonly<StateSnapshot>) => void;
|
export type CustomHook = (state: Readonly<StateSnapshot>) => void;
|
||||||
export type AuthHandler = (
|
export type AuthHandler = (challenge: string, relay: string) => Promise<RawEvent | undefined>;
|
||||||
challenge: string,
|
|
||||||
relay: string
|
|
||||||
) => Promise<NEvent | undefined>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relay settings
|
* Relay settings
|
||||||
*/
|
*/
|
||||||
export type RelaySettings = {
|
export interface RelaySettings {
|
||||||
read: boolean;
|
read: boolean;
|
||||||
write: boolean;
|
write: boolean;
|
||||||
};
|
};
|
||||||
@ -42,35 +35,36 @@ export type StateSnapshot = {
|
|||||||
export class Connection {
|
export class Connection {
|
||||||
Id: string;
|
Id: string;
|
||||||
Address: string;
|
Address: string;
|
||||||
Socket: WebSocket | null;
|
Socket: WebSocket | null = null;
|
||||||
Pending: Array<RawReqFilter>;
|
|
||||||
Subscriptions: Map<string, Subscriptions>;
|
PendingRaw: Array<object> = [];
|
||||||
|
PendingRequests: Array<ReqCommand> = [];
|
||||||
|
ActiveRequests: Set<string> = new Set();
|
||||||
|
|
||||||
Settings: RelaySettings;
|
Settings: RelaySettings;
|
||||||
Info?: RelayInfo;
|
Info?: RelayInfo;
|
||||||
ConnectTimeout: number;
|
ConnectTimeout: number = DefaultConnectTimeout;
|
||||||
Stats: ConnectionStats;
|
Stats: ConnectionStats = new ConnectionStats();
|
||||||
StateHooks: Map<string, CustomHook>;
|
StateHooks: Map<string, CustomHook> = new Map();
|
||||||
HasStateChange: boolean;
|
HasStateChange: boolean = true;
|
||||||
CurrentState: StateSnapshot;
|
CurrentState: StateSnapshot;
|
||||||
LastState: Readonly<StateSnapshot>;
|
LastState: Readonly<StateSnapshot>;
|
||||||
IsClosed: boolean;
|
IsClosed: boolean;
|
||||||
ReconnectTimer: ReturnType<typeof setTimeout> | null;
|
ReconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||||
EventsCallback: Map<u256, (msg: boolean[]) => void>;
|
EventsCallback: Map<u256, (msg: boolean[]) => void>;
|
||||||
|
OnConnected?: () => void;
|
||||||
|
OnEvent?: (sub: string, e: TaggedRawEvent) => void;
|
||||||
|
OnEose?: (sub: string) => void;
|
||||||
Auth?: AuthHandler;
|
Auth?: AuthHandler;
|
||||||
AwaitingAuth: Map<string, boolean>;
|
AwaitingAuth: Map<string, boolean>;
|
||||||
Authed: 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.Id = uuid();
|
||||||
this.Address = addr;
|
this.Address = addr;
|
||||||
this.Socket = null;
|
|
||||||
this.Pending = [];
|
|
||||||
this.Subscriptions = new Map();
|
|
||||||
this.Settings = options;
|
this.Settings = options;
|
||||||
this.ConnectTimeout = DefaultConnectTimeout;
|
|
||||||
this.Stats = new ConnectionStats();
|
|
||||||
this.StateHooks = new Map();
|
|
||||||
this.HasStateChange = true;
|
|
||||||
this.CurrentState = {
|
this.CurrentState = {
|
||||||
connected: false,
|
connected: false,
|
||||||
disconnects: 0,
|
disconnects: 0,
|
||||||
@ -87,13 +81,29 @@ export class Connection {
|
|||||||
this.AwaitingAuth = new Map();
|
this.AwaitingAuth = new Map();
|
||||||
this.Authed = false;
|
this.Authed = false;
|
||||||
this.Auth = auth;
|
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() {
|
async Connect() {
|
||||||
try {
|
try {
|
||||||
if (this.Info === undefined) {
|
if (this.Info === undefined) {
|
||||||
const u = new URL(this.Address);
|
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: {
|
headers: {
|
||||||
accept: "application/nostr+json",
|
accept: "application/nostr+json",
|
||||||
},
|
},
|
||||||
@ -101,7 +111,7 @@ export class Connection {
|
|||||||
if (rsp.ok) {
|
if (rsp.ok) {
|
||||||
const data = await rsp.json();
|
const data = await rsp.json();
|
||||||
for (const [k, v] of Object.entries(data)) {
|
for (const [k, v] of Object.entries(data)) {
|
||||||
if (v === "unset" || v === "") {
|
if (v === "unset" || v === "" || v === "~") {
|
||||||
data[k] = undefined;
|
data[k] = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -112,11 +122,6 @@ export class Connection {
|
|||||||
console.warn("Could not load relay information", e);
|
console.warn("Could not load relay information", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.IsClosed) {
|
|
||||||
this._UpdateState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.IsClosed = false;
|
this.IsClosed = false;
|
||||||
this.Socket = new WebSocket(this.Address);
|
this.Socket = new WebSocket(this.Address);
|
||||||
this.Socket.onopen = () => this.OnOpen();
|
this.Socket.onopen = () => this.OnOpen();
|
||||||
@ -132,17 +137,18 @@ export class Connection {
|
|||||||
this.ReconnectTimer = null;
|
this.ReconnectTimer = null;
|
||||||
}
|
}
|
||||||
this.Socket?.close();
|
this.Socket?.close();
|
||||||
this._UpdateState();
|
this.#UpdateState();
|
||||||
}
|
}
|
||||||
|
|
||||||
OnOpen() {
|
OnOpen() {
|
||||||
this.ConnectTimeout = DefaultConnectTimeout;
|
this.ConnectTimeout = DefaultConnectTimeout;
|
||||||
this._InitSubscriptions();
|
|
||||||
console.log(`[${this.Address}] Open!`);
|
console.log(`[${this.Address}] Open!`);
|
||||||
|
this.OnConnected?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
OnClose(e: CloseEvent) {
|
OnClose(e: CloseEvent) {
|
||||||
if (!this.IsClosed) {
|
if (!this.IsClosed) {
|
||||||
|
this.#ResetQueues();
|
||||||
this.ConnectTimeout = this.ConnectTimeout * 2;
|
this.ConnectTimeout = this.ConnectTimeout * 2;
|
||||||
console.log(
|
console.log(
|
||||||
`[${this.Address}] Closed (${e.reason}), trying again in ${(
|
`[${this.Address}] Closed (${e.reason}), trying again in ${(
|
||||||
@ -159,7 +165,7 @@ export class Connection {
|
|||||||
console.log(`[${this.Address}] Closed!`);
|
console.log(`[${this.Address}] Closed!`);
|
||||||
this.ReconnectTimer = null;
|
this.ReconnectTimer = null;
|
||||||
}
|
}
|
||||||
this._UpdateState();
|
this.#UpdateState();
|
||||||
}
|
}
|
||||||
|
|
||||||
OnMessage(e: MessageEvent) {
|
OnMessage(e: MessageEvent) {
|
||||||
@ -170,17 +176,17 @@ export class Connection {
|
|||||||
case "AUTH": {
|
case "AUTH": {
|
||||||
this._OnAuthAsync(msg[1]);
|
this._OnAuthAsync(msg[1]);
|
||||||
this.Stats.EventsReceived++;
|
this.Stats.EventsReceived++;
|
||||||
this._UpdateState();
|
this.#UpdateState();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "EVENT": {
|
case "EVENT": {
|
||||||
this._OnEvent(msg[1], msg[2]);
|
this.OnEvent?.(msg[1], msg[2]);
|
||||||
this.Stats.EventsReceived++;
|
this.Stats.EventsReceived++;
|
||||||
this._UpdateState();
|
this.#UpdateState();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "EOSE": {
|
case "EOSE": {
|
||||||
this._OnEnd(msg[1]);
|
this.OnEose?.(msg[1]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "OK": {
|
case "OK": {
|
||||||
@ -208,26 +214,26 @@ export class Connection {
|
|||||||
|
|
||||||
OnError(e: Event) {
|
OnError(e: Event) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
this._UpdateState();
|
this.#UpdateState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send event on this connection
|
* Send event on this connection
|
||||||
*/
|
*/
|
||||||
SendEvent(e: NEvent) {
|
SendEvent(e: RawEvent) {
|
||||||
if (!this.Settings.write) {
|
if (!this.Settings.write) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const req = ["EVENT", e.ToObject()];
|
const req = ["EVENT", e];
|
||||||
this._SendJson(req);
|
this.#SendJson(req);
|
||||||
this.Stats.EventsSent++;
|
this.Stats.EventsSent++;
|
||||||
this._UpdateState();
|
this.#UpdateState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send event on this connection and wait for OK response
|
* 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) => {
|
return new Promise<void>((resolve) => {
|
||||||
if (!this.Settings.write) {
|
if (!this.Settings.write) {
|
||||||
resolve();
|
resolve();
|
||||||
@ -236,53 +242,18 @@ export class Connection {
|
|||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
resolve();
|
resolve();
|
||||||
}, timeout);
|
}, timeout);
|
||||||
this.EventsCallback.set(e.Id, () => {
|
this.EventsCallback.set(e.id, () => {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
const req = ["EVENT", e.ToObject()];
|
const req = ["EVENT", e];
|
||||||
this._SendJson(req);
|
this.#SendJson(req);
|
||||||
this.Stats.EventsSent++;
|
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
|
* Hook status for connection
|
||||||
*/
|
*/
|
||||||
@ -312,80 +283,81 @@ export class Connection {
|
|||||||
return this.Info?.supported_nips?.some((a) => a === n) ?? false;
|
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.connected = this.Socket?.readyState === WebSocket.OPEN;
|
||||||
this.CurrentState.events.received = this.Stats.EventsReceived;
|
this.CurrentState.events.received = this.Stats.EventsReceived;
|
||||||
this.CurrentState.events.send = this.Stats.EventsSent;
|
this.CurrentState.events.send = this.Stats.EventsSent;
|
||||||
this.CurrentState.avgLatency =
|
this.CurrentState.avgLatency =
|
||||||
this.Stats.Latency.length > 0
|
this.Stats.Latency.length > 0
|
||||||
? this.Stats.Latency.reduce((acc, v) => acc + v, 0) /
|
? this.Stats.Latency.reduce((acc, v) => acc + v, 0) /
|
||||||
this.Stats.Latency.length
|
this.Stats.Latency.length
|
||||||
: 0;
|
: 0;
|
||||||
this.CurrentState.disconnects = this.Stats.Disconnects;
|
this.CurrentState.disconnects = this.Stats.Disconnects;
|
||||||
this.CurrentState.info = this.Info;
|
this.CurrentState.info = this.Info;
|
||||||
this.CurrentState.id = this.Id;
|
this.CurrentState.id = this.Id;
|
||||||
this.Stats.Latency = this.Stats.Latency.slice(-20); // trim
|
this.Stats.Latency = this.Stats.Latency.slice(-20); // trim
|
||||||
this.HasStateChange = true;
|
this.HasStateChange = true;
|
||||||
this._NotifyState();
|
this.#NotifyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
_NotifyState() {
|
#NotifyState() {
|
||||||
const state = this.GetState();
|
const state = this.GetState();
|
||||||
for (const [, h] of this.StateHooks) {
|
for (const [, h] of this.StateHooks) {
|
||||||
h(state);
|
h(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_InitSubscriptions() {
|
#SendJson(obj: object) {
|
||||||
// send pending
|
const authPending = !this.Authed && this.AwaitingAuth.size > 0;
|
||||||
for (const p of this.Pending) {
|
if (this.Socket?.readyState !== WebSocket.OPEN || authPending) {
|
||||||
this._SendJson(p);
|
this.PendingRaw.push(obj);
|
||||||
}
|
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const json = JSON.stringify(obj);
|
const json = JSON.stringify(obj);
|
||||||
this.Socket.send(json);
|
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> {
|
async _OnAuthAsync(challenge: string): Promise<void> {
|
||||||
const authCleanup = () => {
|
const authCleanup = () => {
|
||||||
this.AwaitingAuth.delete(challenge);
|
this.AwaitingAuth.delete(challenge);
|
||||||
@ -406,61 +378,23 @@ export class Connection {
|
|||||||
resolve();
|
resolve();
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
|
|
||||||
this.EventsCallback.set(authEvent.Id, (msg: boolean[]) => {
|
this.EventsCallback.set(authEvent.id, (msg: boolean[]) => {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
authCleanup();
|
authCleanup();
|
||||||
if (msg.length > 3 && msg[2] === true) {
|
if (msg.length > 3 && msg[2] === true) {
|
||||||
this.Authed = true;
|
this.Authed = true;
|
||||||
this._InitSubscriptions();
|
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
const req = ["AUTH", authEvent.ToObject()];
|
const req = ["AUTH", authEvent];
|
||||||
this._SendJson(req);
|
this.#SendJson(req);
|
||||||
this.Stats.EventsSent++;
|
this.Stats.EventsSent++;
|
||||||
this._UpdateState();
|
this.#UpdateState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_OnEnd(subId: string) {
|
get #maxSubscriptions() {
|
||||||
const sub = this.Subscriptions.get(subId);
|
return this.Info?.limitation?.max_subscriptions ?? 25;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* Websocket re-connect timeout
|
* Websocket re-connect timeout
|
||||||
*/
|
*/
|
||||||
export const DefaultConnectTimeout = 2000;
|
export const DefaultConnectTimeout = 2000;
|
||||||
|
@ -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,
|
Search = 50,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Nips;
|
|
||||||
|
@ -8,5 +8,8 @@ export interface RelayInfo {
|
|||||||
version?: string;
|
version?: string;
|
||||||
limitation?: {
|
limitation?: {
|
||||||
payment_required: boolean;
|
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 "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sanitizeRelayUrl(url: string) {
|
||||||
|
try {
|
||||||
|
return new URL(url).toString();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
@ -1,16 +1,16 @@
|
|||||||
export * from "./Connection";
|
export * from "./Connection";
|
||||||
export { default as EventKind } from "./EventKind";
|
export { default as EventKind } from "./EventKind";
|
||||||
export { Subscriptions } from "./Subscriptions";
|
|
||||||
export { default as Event } from "./Event";
|
|
||||||
export { default as Tag } from "./Tag";
|
export { default as Tag } from "./Tag";
|
||||||
export * from "./Links";
|
export * from "./Links";
|
||||||
|
export * from "./Nips";
|
||||||
|
|
||||||
|
import { RelaySettings } from ".";
|
||||||
export type RawEvent = {
|
export type RawEvent = {
|
||||||
id: u256;
|
id: u256;
|
||||||
pubkey: HexKey;
|
pubkey: HexKey;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
kind: number;
|
kind: number;
|
||||||
tags: string[][];
|
tags: Array<Array<string>>;
|
||||||
content: string;
|
content: string;
|
||||||
sig: string;
|
sig: string;
|
||||||
};
|
};
|
||||||
@ -37,6 +37,8 @@ export type MaybeHexKey = HexKey | undefined;
|
|||||||
*/
|
*/
|
||||||
export type u256 = string;
|
export type u256 = string;
|
||||||
|
|
||||||
|
export type ReqCommand = [cmd: "REQ", id: string, ...filters: Array<RawReqFilter>];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw REQ filter object
|
* Raw REQ filter object
|
||||||
*/
|
*/
|
||||||
@ -83,5 +85,5 @@ export enum Lists {
|
|||||||
|
|
||||||
export interface FullRelaySettings {
|
export interface FullRelaySettings {
|
||||||
url: string;
|
url: string;
|
||||||
settings: { read: boolean; write: boolean };
|
settings: RelaySettings;
|
||||||
}
|
}
|
||||||
|
11
yarn.lock
11
yarn.lock
@ -2166,9 +2166,9 @@
|
|||||||
"@types/istanbul-lib-report" "*"
|
"@types/istanbul-lib-report" "*"
|
||||||
|
|
||||||
"@types/jest@^29.2.5":
|
"@types/jest@^29.2.5":
|
||||||
version "29.4.0"
|
version "29.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.4.0.tgz#a8444ad1704493e84dbf07bb05990b275b3b9206"
|
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.0.tgz#337b90bbcfe42158f39c2fb5619ad044bbb518ac"
|
||||||
integrity sha512-VaywcGQ9tPorCX/Jkkni7RWGFfI11whqzs8dvxF41P17Z+z872thvEvlIbznjPJ02kl1HMX3LmLOonsj2n7HeQ==
|
integrity sha512-3Emr5VOl/aoBwnWcH/EFQvlSAmjV+XtV9GGu5mwdYew5vhQh0IUZx/60x0TzHDu09Bi7HMx10t/namdJw5QIcg==
|
||||||
dependencies:
|
dependencies:
|
||||||
expect "^29.0.0"
|
expect "^29.0.0"
|
||||||
pretty-format "^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"
|
resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.2.tgz#51a3fbb5e11ae72e2cf74861ed5c8020f89f29fe"
|
||||||
integrity sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==
|
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:
|
through@^2.3.8:
|
||||||
version "2.3.8"
|
version "2.3.8"
|
||||||
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
|
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user