This commit is contained in:
Kieran 2023-09-21 21:02:59 +01:00
parent 4b57d57f94
commit 71f7f728fd
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
69 changed files with 863 additions and 864 deletions

View File

@ -6,7 +6,6 @@
"@lightninglabs/lnc-web": "^0.2.3-alpha", "@lightninglabs/lnc-web": "^0.2.3-alpha",
"@noble/curves": "^1.0.0", "@noble/curves": "^1.0.0",
"@noble/hashes": "^1.2.0", "@noble/hashes": "^1.2.0",
"@reduxjs/toolkit": "^1.9.1",
"@scure/base": "^1.1.1", "@scure/base": "^1.1.1",
"@scure/bip32": "^1.3.0", "@scure/bip32": "^1.3.0",
"@scure/bip39": "^1.1.1", "@scure/bip39": "^1.1.1",
@ -27,7 +26,6 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-intersection-observer": "^9.4.1", "react-intersection-observer": "^9.4.1",
"react-intl": "^6.4.4", "react-intl": "^6.4.4",
"react-redux": "^8.0.5",
"react-router-dom": "^6.5.0", "react-router-dom": "^6.5.0",
"react-textarea-autosize": "^8.4.0", "react-textarea-autosize": "^8.4.0",
"react-twitter-embed": "^4.0.4", "react-twitter-embed": "^4.0.4",

View File

@ -2,7 +2,7 @@ import { EventKind, EventPublisher, RequestBuilder, TaggedNostrEvent } from "@sn
import { UnwrappedGift, db } from "Db"; import { UnwrappedGift, db } from "Db";
import { findTag, unwrap } from "SnortUtils"; import { findTag, unwrap } from "SnortUtils";
import { RefreshFeedCache } from "./RefreshFeedCache"; import { RefreshFeedCache } from "./RefreshFeedCache";
import { LoginSession } from "Login"; import { LoginSession, LoginSessionType } from "Login";
export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> { export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
constructor() { constructor() {
@ -15,7 +15,7 @@ export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
buildSub(session: LoginSession, rb: RequestBuilder): void { buildSub(session: LoginSession, rb: RequestBuilder): void {
const pubkey = session.publicKey; const pubkey = session.publicKey;
if (pubkey) { if (pubkey && session.type === LoginSessionType.PrivateKey) {
rb.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubkey]).since(this.newest()); rb.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubkey]).since(this.newest());
} }
} }

View File

@ -35,7 +35,7 @@ export default function BadgeList({ badges }: { badges: TaggedNostrEvent[] }) {
))} ))}
</div> </div>
{showModal && ( {showModal && (
<Modal className="reactions-modal" onClose={() => setShowModal(false)}> <Modal id="badges" className="reactions-modal" onClose={() => setShowModal(false)}>
<div className="reactions-view"> <div className="reactions-view">
<div className="close" onClick={() => setShowModal(false)}> <div className="close" onClick={() => setShowModal(false)}>
<Icon name="close" /> <Icon name="close" />

View File

@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import NoteTime from "Element/NoteTime"; import NoteTime from "Element/NoteTime";
import Text from "Element/Text"; import Text from "Element/Text";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";

View File

@ -2,7 +2,7 @@ import { NostrLink } from "@snort/system";
import { useArticles } from "Feed/ArticlesFeed"; import { useArticles } from "Feed/ArticlesFeed";
import { orderDescending } from "SnortUtils"; import { orderDescending } from "SnortUtils";
import Note from "../Note"; import Note from "../Note";
import { useReactions } from "Feed/FeedReactions"; import { useReactions } from "Feed/Reactions";
export default function Articles() { export default function Articles() {
const data = useArticles(); const data = useArticles();

View File

@ -2,7 +2,7 @@ import "./FollowButton.css";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { HexKey } from "@snort/system"; import { HexKey } from "@snort/system";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { parseId } from "SnortUtils"; import { parseId } from "SnortUtils";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";

View File

@ -2,13 +2,16 @@ import { ReactNode } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { HexKey } from "@snort/system"; import { HexKey } from "@snort/system";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import ProfilePreview from "Element/ProfilePreview"; import ProfilePreview from "Element/ProfilePreview";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { System } from "index"; import { System } from "index";
import messages from "./messages"; import messages from "./messages";
import { FollowsFeed } from "Cache"; import { FollowsFeed } from "Cache";
import AsyncButton from "./AsyncButton";
import { setFollows } from "Login";
import { dedupe } from "@snort/shared";
export interface FollowListBaseProps { export interface FollowListBaseProps {
pubkeys: HexKey[]; pubkeys: HexKey[];
@ -30,13 +33,15 @@ export default function FollowListBase({
profileActions, profileActions,
}: FollowListBaseProps) { }: FollowListBaseProps) {
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const { follows, relays } = useLogin(); const login = useLogin();
async function followAll() { async function followAll() {
if (publisher) { if (publisher) {
const ev = await publisher.contactList([...pubkeys, ...follows.item], relays.item); const newFollows = dedupe([...pubkeys, ...login.follows.item]);
await FollowsFeed.backFill(System, pubkeys); const ev = await publisher.contactList(newFollows, login.relays.item);
System.BroadcastEvent(ev); System.BroadcastEvent(ev);
await FollowsFeed.backFill(System, pubkeys);
setFollows(login, newFollows, ev.created_at);
} }
} }
@ -46,9 +51,9 @@ export default function FollowListBase({
<div className="flex mt10 mb10"> <div className="flex mt10 mb10">
<div className="f-grow bold">{title}</div> <div className="f-grow bold">{title}</div>
{actions} {actions}
<button className="transparent" type="button" onClick={() => followAll()}> <AsyncButton className="transparent" type="button" onClick={() => followAll()}>
<FormattedMessage {...messages.FollowAll} /> <FormattedMessage {...messages.FollowAll} />
</button> </AsyncButton>
</div> </div>
)} )}
{pubkeys?.map(a => ( {pubkeys?.map(a => (

View File

@ -7,15 +7,15 @@ import messages from "./messages";
export default function LogoutButton() { export default function LogoutButton() {
const navigate = useNavigate(); const navigate = useNavigate();
const publicKey = useLogin().publicKey; const login = useLogin();
if (!publicKey) return; if (!login.publicKey) return;
return ( return (
<button <button
className="secondary" className="secondary"
type="button" type="button"
onClick={() => { onClick={() => {
logout(publicKey); logout(login.id);
navigate("/"); navigate("/");
}}> }}>
<FormattedMessage {...messages.Logout} /> <FormattedMessage {...messages.Logout} />

View File

@ -20,6 +20,7 @@
width: 500px; width: 500px;
margin-top: auto; margin-top: auto;
margin-bottom: auto; margin-bottom: auto;
--border-color: var(--gray);
} }
.modal-body button.secondary:hover { .modal-body button.secondary:hover {

View File

@ -1,26 +1,17 @@
import "./Modal.css"; import "./Modal.css";
import { useEffect, MouseEventHandler, ReactNode } from "react"; import { ReactNode } from "react";
export interface ModalProps { export interface ModalProps {
id: string;
className?: string; className?: string;
onClose?: MouseEventHandler; onClose?: () => void;
children: ReactNode; children: ReactNode;
} }
export default function Modal(props: ModalProps) { export default function Modal(props: ModalProps) {
const onClose = props.onClose || (() => undefined); return <div className={`modal${props.className ? ` ${props.className}` : ""}`} onClick={props.onClose}>
const className = props.className || ""; <div className="modal-body" onClick={e => e.stopPropagation()}>
{props.children}
useEffect(() => {
document.body.classList.add("scroll-lock");
return () => document.body.classList.remove("scroll-lock");
}, []);
return (
<div className={`modal ${className}`} onClick={onClose}>
<div className="modal-body" onClick={e => e.stopPropagation()}>
{props.children}
</div>
</div> </div>
); </div>;
} }

View File

@ -18,7 +18,7 @@ import AsyncButton from "Element/AsyncButton";
import SendSats from "Element/SendSats"; import SendSats from "Element/SendSats";
import Copy from "Element/Copy"; import Copy from "Element/Copy";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { debounce } from "SnortUtils"; import { debounce } from "SnortUtils";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import SnortServiceProvider from "Nip05/SnortServiceProvider"; import SnortServiceProvider from "Nip05/SnortServiceProvider";

View File

@ -6,7 +6,7 @@ import { useIntl, FormattedMessage } from "react-intl";
import { TaggedNostrEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt, parseZap, NostrLink } from "@snort/system"; import { TaggedNostrEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt, parseZap, NostrLink } from "@snort/system";
import { System } from "index"; import { System } from "index";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";
import Text from "Element/Text"; import Text from "Element/Text";

View File

@ -1,23 +1,17 @@
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { HexKey, Lists, NostrLink, TaggedNostrEvent } from "@snort/system"; import { HexKey, Lists, NostrLink, TaggedNostrEvent } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu"; import { Menu, MenuItem } from "@szhsin/react-menu";
import { useDispatch, useSelector } from "react-redux";
import { TranslateHost } from "Const"; import { TranslateHost } from "Const";
import { System } from "index"; import { System } from "index";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import { setPinned, setBookmarked } from "Login"; import { setPinned, setBookmarked } from "Login";
import {
setNote as setReBroadcastNote,
setShow as setReBroadcastShow,
reset as resetReBroadcast,
} from "State/ReBroadcast";
import messages from "Element/messages"; import messages from "Element/messages";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { RootState } from "State/Store";
import { ReBroadcaster } from "./ReBroadcaster"; import { ReBroadcaster } from "./ReBroadcaster";
import { useState } from "react";
export interface NoteTranslation { export interface NoteTranslation {
text: string; text: string;
@ -33,15 +27,12 @@ interface NosteContextMenuProps {
} }
export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) { export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
const dispatch = useDispatch();
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const login = useLogin(); const login = useLogin();
const { pinned, bookmarked, publicKey, preferences: prefs } = login; const { pinned, bookmarked, publicKey, preferences: prefs } = login;
const { mute, block } = useModeration(); const { mute, block } = useModeration();
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const showReBroadcastModal = useSelector((s: RootState) => s.reBroadcast.show); const [showBroadcast, setShowBroadcast] = useState(false);
const reBroadcastNote = useSelector((s: RootState) => s.reBroadcast.note);
const willRenderReBroadcast = showReBroadcastModal && reBroadcastNote && reBroadcastNote?.id === ev.id;
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",
@ -119,12 +110,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
} }
const handleReBroadcastButtonClick = () => { const handleReBroadcastButtonClick = () => {
if (reBroadcastNote?.id !== ev.id) { setShowBroadcast(true);
dispatch(resetReBroadcast());
}
dispatch(setReBroadcastNote(ev));
dispatch(setReBroadcastShow(!showReBroadcastModal));
}; };
function menuItems() { function menuItems() {
@ -214,7 +200,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
menuClassName="ctx-menu"> menuClassName="ctx-menu">
{menuItems()} {menuItems()}
</Menu> </Menu>
{willRenderReBroadcast && <ReBroadcaster />} {showBroadcast && <ReBroadcaster ev={ev} onClose={() => setShowBroadcast(false)} />}
</> </>
); );
} }

View File

@ -1,31 +1,15 @@
import "./NoteCreator.css"; import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useDispatch, useSelector } from "react-redux"; import { EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder, tryParseNostrLink, NostrLink, NostrEvent } from "@snort/system";
import { EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder, tryParseNostrLink, NostrLink } from "@snort/system";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { openFile } from "SnortUtils"; import { openFile } from "SnortUtils";
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 useFileUpload from "Upload"; import useFileUpload from "Upload";
import Note from "Element/Note"; import Note from "Element/Note";
import {
setShow,
setNote,
setError,
setActive,
setPreview,
setShowAdvanced,
setSelectedCustomRelays,
setZapSplits,
setSensitive,
reset,
setPollOptions,
setOtherEvents,
} from "State/NoteCreator";
import type { RootState } from "State/Store";
import { ClipboardEventHandler } from "react"; import { ClipboardEventHandler } from "react";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
@ -34,37 +18,24 @@ import AsyncButton from "Element/AsyncButton";
import { AsyncIcon } from "Element/AsyncIcon"; import { AsyncIcon } from "Element/AsyncIcon";
import { fetchNip05Pubkey } from "@snort/shared"; import { fetchNip05Pubkey } from "@snort/shared";
import { ZapTarget } from "Zapper"; import { ZapTarget } from "Zapper";
import { useNoteCreator } from "State/NoteCreator";
export function NoteCreator() { export function NoteCreator() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const uploader = useFileUpload(); const uploader = useFileUpload();
const {
note,
zapSplits,
sensitive,
pollOptions,
replyTo,
otherEvents,
preview,
active,
show,
showAdvanced,
selectedCustomRelays,
error,
} = useSelector((s: RootState) => s.noteCreator);
const dispatch = useDispatch();
const login = useLogin(); const login = useLogin();
const note = useNoteCreator();
const relays = login.relays; const relays = login.relays;
async function buildNote() { async function buildNote() {
try { try {
dispatch(setError("")); note.update(v => v.error = "");
if (note && publisher) { if (note && publisher) {
let extraTags: Array<Array<string>> | undefined; let extraTags: Array<Array<string>> | undefined;
if (zapSplits) { if (note.zapSplits) {
const parsedSplits = [] as Array<ZapTarget>; const parsedSplits = [] as Array<ZapTarget>;
for (const s of zapSplits) { for (const s of note.zapSplits) {
if (s.value.startsWith(NostrPrefix.PublicKey) || s.value.startsWith(NostrPrefix.Profile)) { if (s.value.startsWith(NostrPrefix.PublicKey) || s.value.startsWith(NostrPrefix.Profile)) {
const link = tryParseNostrLink(s.value); const link = tryParseNostrLink(s.value);
if (link) { if (link) {
@ -114,43 +85,53 @@ export function NoteCreator() {
extraTags = parsedSplits.map(v => ["zap", v.value, "", String(v.weight)]); extraTags = parsedSplits.map(v => ["zap", v.value, "", String(v.weight)]);
} }
if (sensitive) { if (note.sensitive) {
extraTags ??= []; extraTags ??= [];
extraTags.push(["content-warning", sensitive]); extraTags.push(["content-warning", note.sensitive]);
} }
const kind = pollOptions ? EventKind.Polls : EventKind.TextNote; const kind = note.pollOptions ? EventKind.Polls : EventKind.TextNote;
if (pollOptions) { if (note.pollOptions) {
extraTags ??= []; extraTags ??= [];
extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a])); extraTags.push(...note.pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
} }
const hk = (eb: EventBuilder) => { const hk = (eb: EventBuilder) => {
extraTags?.forEach(t => eb.tag(t)); extraTags?.forEach(t => eb.tag(t));
eb.kind(kind); eb.kind(kind);
return eb; return eb;
}; };
const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk); const ev = note.replyTo ? await publisher.reply(note.replyTo, note.note, hk) : await publisher.note(note.note, hk);
return ev; return ev;
} }
} catch (e) { } catch (e) {
if (e instanceof Error) { note.update(v => {
dispatch(setError(e.message)); if (e instanceof Error) {
} else { v.error = e.message;
dispatch(setError(e as string)); } else {
} v.error = e as string;
}
});
}
}
async function sendEventToRelays(ev: NostrEvent) {
if (note.selectedCustomRelays) {
await Promise.all(note.selectedCustomRelays.map(r => System.WriteOnceToRelay(r, ev)));
} else {
System.BroadcastEvent(ev);
} }
} }
async function sendNote() { async function sendNote() {
const ev = await buildNote(); const ev = await buildNote();
if (ev) { if (ev) {
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, ev)); await sendEventToRelays(ev);
else System.BroadcastEvent(ev); for (const oe of note.otherEvents ?? []) {
dispatch(reset()); await sendEventToRelays(oe);
for (const oe of otherEvents) {
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, oe));
else System.BroadcastEvent(oe);
} }
dispatch(reset()); note.update(v => {
v.reset();
v.show = false;
})
} }
} }
@ -160,10 +141,14 @@ export function NoteCreator() {
if (file) { if (file) {
uploadFile(file); uploadFile(file);
} }
} catch (error: unknown) { } catch (e) {
if (error instanceof Error) { note.update(v => {
dispatch(setError(error?.message)); if (e instanceof Error) {
} v.error = e.message;
} else {
v.error = e as string;
}
});
} }
} }
@ -171,35 +156,39 @@ export function NoteCreator() {
try { try {
if (file) { if (file) {
const rx = await uploader.upload(file, file.name); const rx = await uploader.upload(file, file.name);
if (rx.header) { note.update(v => {
const link = `nostr:${new NostrLink(NostrPrefix.Event, rx.header.id, rx.header.kind).encode()}`; if (rx.header) {
dispatch(setNote(`${note ? `${note}\n` : ""}${link}`)); const link = `nostr:${new NostrLink(NostrPrefix.Event, rx.header.id, rx.header.kind).encode()}`;
dispatch(setOtherEvents([...otherEvents, rx.header])); v.note = `${v.note ? `${v.note}\n` : ""}${link}`;
} else if (rx.url) { v.otherEvents = [...(v.otherEvents ?? []), rx.header];
dispatch(setNote(`${note ? `${note}\n` : ""}${rx.url}`)); } else if (rx.url) {
} else if (rx?.error) { v.note = `${v.note ? `${v.note}\n` : ""}${rx.url}`;
dispatch(setError(rx.error)); } else if (rx?.error) {
v.error = rx.error;
}
});
}
} catch (e) {
note.update(v => {
if (e instanceof Error) {
v.error = e.message;
} else {
v.error = e as string;
} }
} });
} catch (error: unknown) {
if (error instanceof Error) {
dispatch(setError(error?.message));
}
} }
} }
function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) { function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) {
const { value } = ev.target; const { value } = ev.target;
dispatch(setNote(value)); note.update(n => n.note = value);
if (value) {
dispatch(setActive(true));
} else {
dispatch(setActive(false));
}
} }
function cancel() { function cancel() {
dispatch(reset()); note.update(v => {
v.show = false;
v.reset();
});
} }
async function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) { async function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
@ -208,21 +197,19 @@ export function NoteCreator() {
} }
async function loadPreview() { async function loadPreview() {
if (preview) { if (note.preview) {
dispatch(setPreview(undefined)); note.update(v => v.preview = undefined);
} else if (publisher) { } else if (publisher) {
const tmpNote = await buildNote(); const tmpNote = await buildNote();
if (tmpNote) { note.update(v => v.preview = tmpNote);
dispatch(setPreview(tmpNote));
}
} }
} }
function getPreviewNote() { function getPreviewNote() {
if (preview) { if (note.preview) {
return ( return (
<Note <Note
data={preview as TaggedNostrEvent} data={note.preview as TaggedNostrEvent}
related={[]} related={[]}
options={{ options={{
showContextMenu: false, showContextMenu: false,
@ -236,13 +223,13 @@ export function NoteCreator() {
} }
function renderPollOptions() { function renderPollOptions() {
if (pollOptions) { if (note.pollOptions) {
return ( return (
<> <>
<h4> <h4>
<FormattedMessage defaultMessage="Poll Options" /> <FormattedMessage defaultMessage="Poll Options" />
</h4> </h4>
{pollOptions?.map((a, i) => ( {note.pollOptions?.map((a, i) => (
<div className="form-group w-max" key={`po-${i}`}> <div className="form-group w-max" key={`po-${i}`}>
<div> <div>
<FormattedMessage defaultMessage="Option: {n}" values={{ n: i + 1 }} /> <FormattedMessage defaultMessage="Option: {n}" values={{ n: i + 1 }} />
@ -257,7 +244,7 @@ export function NoteCreator() {
</div> </div>
</div> </div>
))} ))}
<button onClick={() => dispatch(setPollOptions([...pollOptions, ""]))}> <button onClick={() => note.update(v => v.pollOptions = [...(note.pollOptions ?? []), ""])}>
<Icon name="plus" size={14} /> <Icon name="plus" size={14} />
</button> </button>
</> </>
@ -266,18 +253,18 @@ export function NoteCreator() {
} }
function changePollOption(i: number, v: string) { function changePollOption(i: number, v: string) {
if (pollOptions) { if (note.pollOptions) {
const copy = [...pollOptions]; const copy = [...note.pollOptions];
copy[i] = v; copy[i] = v;
dispatch(setPollOptions(copy)); note.update(v => v.pollOptions = copy);
} }
} }
function removePollOption(i: number) { function removePollOption(i: number) {
if (pollOptions) { if (note.pollOptions) {
const copy = [...pollOptions]; const copy = [...note.pollOptions];
copy.splice(i, 1); copy.splice(i, 1);
dispatch(setPollOptions(copy)); note.update(v => v.pollOptions = copy);
} }
} }
@ -292,19 +279,16 @@ export function NoteCreator() {
<div> <div>
<input <input
type="checkbox" type="checkbox"
checked={!selectedCustomRelays || selectedCustomRelays.includes(r)} checked={!note.selectedCustomRelays || note.selectedCustomRelays.includes(r)}
onChange={e => onChange={e => {
dispatch( note.update(v => v.selectedCustomRelays = (
setSelectedCustomRelays( // set false if all relays selected
// set false if all relays selected e.target.checked && note.selectedCustomRelays && note.selectedCustomRelays.length == a.length - 1
e.target.checked && selectedCustomRelays && selectedCustomRelays.length == a.length - 1 ? undefined
? false : // otherwise return selectedCustomRelays with target relay added / removed
: // otherwise return selectedCustomRelays with target relay added / removed a.filter(el => el === r ? e.target.checked : !note.selectedCustomRelays || note.selectedCustomRelays.includes(el))
a.filter(el => ));
el === r ? e.target.checked : !selectedCustomRelays || selectedCustomRelays.includes(el), }
),
),
)
} }
/> />
</div> </div>
@ -345,163 +329,147 @@ export function NoteCreator() {
} }
}; };
return ( if (!note.show) return null;
<> return (<Modal id="note-creator" className="note-creator-modal" onClose={() => note.update(v => v.show = false)}>
{show && ( {note.replyTo && (
<Modal className="note-creator-modal" onClose={() => dispatch(setShow(false))}> <Note
{replyTo && ( data={note.replyTo}
<Note related={[]}
data={replyTo} options={{
related={[]} showFooter: false,
options={{ showContextMenu: false,
showFooter: false, showTime: false,
showContextMenu: false, canClick: false,
showTime: false, showMedia: false,
canClick: false, }}
showMedia: false, />
}} )}
/> {note.preview && getPreviewNote()}
)} {!note.preview && (
{preview && getPreviewNote()} <div onPaste={handlePaste} className={`note-creator${note.pollOptions ? " poll" : ""}`}>
{!preview && ( <Textarea
<div onPaste={handlePaste} className={`note-creator${pollOptions ? " poll" : ""}`}> autoFocus
<Textarea className={`textarea ${note.active ? "textarea--focused" : ""}`}
autoFocus onChange={c => onChange(c)}
className={`textarea ${active ? "textarea--focused" : ""}`} value={note.note}
onChange={onChange} onFocus={() => note.update(v => v.active = true)}
value={note} onKeyDown={e => {
onFocus={() => dispatch(setActive(true))} if (e.key === "Enter" && e.metaKey) {
onKeyDown={e => { sendNote().catch(console.warn);
if (e.key === "Enter" && e.metaKey) { }
sendNote().catch(console.warn); }}
} />
}} {renderPollOptions()}
/> </div>
{renderPollOptions()} )}
</div> <div className="flex f-space">
)} <div className="flex g8">
<div className="flex f-space"> <ProfileImage pubkey={login.publicKey ?? ""} className="note-creator-icon" link="" showUsername={false} showFollowingMark={false} />
<div className="flex g8"> {note.pollOptions === undefined && !note.replyTo && (
<ProfileImage pubkey={login.publicKey ?? ""} className="note-creator-icon" link="" showUsername={false} /> <div className="note-creator-icon">
{pollOptions === undefined && !replyTo && ( <Icon name="pie-chart" onClick={() => note.update(v => v.pollOptions = ["A", "B"])} size={24} />
<div className="note-creator-icon">
<Icon name="pie-chart" onClick={() => dispatch(setPollOptions(["A", "B"]))} size={24} />
</div>
)}
<AsyncIcon iconName="image-plus" iconSize={24} onClick={attachFile} className="note-creator-icon" />
<button className="secondary" onClick={() => dispatch(setShowAdvanced(!showAdvanced))}>
<FormattedMessage defaultMessage="Advanced" />
</button>
</div>
<div className="flex g8">
<button className="secondary" onClick={cancel}>
<FormattedMessage defaultMessage="Cancel" />
</button>
<AsyncButton className="primary" onClick={onSubmit}>
{replyTo ? <FormattedMessage defaultMessage="Reply" /> : <FormattedMessage defaultMessage="Send" />}
</AsyncButton>
</div>
</div> </div>
{error && <span className="error">{error}</span>} )}
{showAdvanced && ( <AsyncIcon iconName="image-plus" iconSize={24} onClick={attachFile} className="note-creator-icon" />
<> <button className="secondary" onClick={() => note.update(v => v.advanced = !v.advanced)}>
<button className="secondary" onClick={loadPreview}> <FormattedMessage defaultMessage="Advanced" />
<FormattedMessage defaultMessage="Toggle Preview" /> </button>
</button> </div>
<div> <div className="flex g8">
<h4> <button className="secondary" onClick={cancel}>
<FormattedMessage defaultMessage="Custom Relays" /> <FormattedMessage defaultMessage="Cancel" />
</h4> </button>
<p> <AsyncButton onClick={onSubmit}>
<FormattedMessage defaultMessage="Send note to a subset of your write relays" /> {note.replyTo ? <FormattedMessage defaultMessage="Reply" /> : <FormattedMessage defaultMessage="Send" />}
</p> </AsyncButton>
{renderRelayCustomisation()} </div>
</div> </div>
<div className="flex-column g8"> {note.error && <span className="error">{note.error}</span>}
<h4> {note.advanced && (
<FormattedMessage defaultMessage="Zap Splits" /> <>
</h4> <button className="secondary" onClick={loadPreview}>
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." /> <FormattedMessage defaultMessage="Toggle Preview" />
<div className="flex-column g8"> </button>
{[...(zapSplits ?? [])].map((v, i, arr) => ( <div>
<div className="flex f-center g8"> <h4>
<div className="flex-column f-4 g4"> <FormattedMessage defaultMessage="Custom Relays" />
<h4> </h4>
<FormattedMessage defaultMessage="Recipient" /> <p>
</h4> <FormattedMessage defaultMessage="Send note to a subset of your write relays" />
<input </p>
type="text" {renderRelayCustomisation()}
value={v.value} </div>
onChange={e => <div className="flex-column g8">
dispatch( <h4>
setZapSplits(arr.map((vv, ii) => (ii === i ? { ...vv, value: e.target.value } : vv))), <FormattedMessage defaultMessage="Zap Splits" />
) </h4>
} <FormattedMessage defaultMessage="Zaps on this note will be split to the following users." />
placeholder={formatMessage({ defaultMessage: "npub / nprofile / nostr address" })} <div className="flex-column g8">
/> {[...(note.zapSplits ?? [])].map((v, i, arr) => (
</div> <div className="flex f-center g8">
<div className="flex-column f-1 g4"> <div className="flex-column f-4 g4">
<h4> <h4>
<FormattedMessage defaultMessage="Weight" /> <FormattedMessage defaultMessage="Recipient" />
</h4> </h4>
<input <input
type="number" type="text"
min={0} value={v.value}
value={v.weight} onChange={e => note.update(v => v.zapSplits = arr.map((vv, ii) => (ii === i ? { ...vv, value: e.target.value } : vv)))}
onChange={e => placeholder={formatMessage({ defaultMessage: "npub / nprofile / nostr address" })}
dispatch( />
setZapSplits( </div>
arr.map((vv, ii) => (ii === i ? { ...vv, weight: Number(e.target.value) } : vv)), <div className="flex-column f-1 g4">
), <h4>
) <FormattedMessage defaultMessage="Weight" />
} </h4>
/> <input
</div> type="number"
<div className="flex-column f-shrink g4"> min={0}
<div>&nbsp;</div> value={v.weight}
<Icon onChange={e => note.update(v => v.zapSplits = arr.map((vv, ii) => (ii === i ? { ...vv, weight: Number(e.target.value) } : vv)))}
name="close" />
onClick={() => dispatch(setZapSplits((zapSplits ?? []).filter((_v, ii) => ii !== i)))} </div>
/> <div className="flex-column f-shrink g4">
</div> <div>&nbsp;</div>
</div> <Icon
))} name="close"
<button onClick={() => note.update(v => v.zapSplits = (v.zapSplits ?? []).filter((_v, ii) => ii !== i))}
type="button" />
onClick={() =>
dispatch(setZapSplits([...(zapSplits ?? []), { type: "pubkey", value: "", weight: 1 }]))
}>
<FormattedMessage defaultMessage="Add" />
</button>
</div> </div>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this, you may still receive some zaps as if zap splits was not configured" />
</span>
</div> </div>
<div className="flex-column g8"> ))}
<h4> <button
<FormattedMessage defaultMessage="Sensitive Content" /> type="button"
</h4> onClick={() => note.update(v => v.zapSplits = [...(v.zapSplits ?? []), { type: "pubkey", value: "", weight: 1 }])}>
<FormattedMessage defaultMessage="Users must accept the content warning to show the content of your note." /> <FormattedMessage defaultMessage="Add" />
<input </button>
className="w-max" </div>
type="text" <span className="warning">
value={sensitive} <FormattedMessage defaultMessage="Not all clients support this, you may still receive some zaps as if zap splits was not configured" />
onChange={e => dispatch(setSensitive(e.target.value))} </span>
maxLength={50} </div>
minLength={1} <div className="flex-column g8">
placeholder={formatMessage({ <h4>
defaultMessage: "Reason", <FormattedMessage defaultMessage="Sensitive Content" />
})} </h4>
/> <FormattedMessage defaultMessage="Users must accept the content warning to show the content of your note." />
<span className="warning"> <input
<FormattedMessage defaultMessage="Not all clients support this yet" /> className="w-max"
</span> type="text"
</div> value={note.sensitive}
</> onChange={e => note.update(v => v.sensitive = e.target.value)}
)} maxLength={50}
</Modal> minLength={1}
)} placeholder={formatMessage({
</> defaultMessage: "Reason",
})}
/>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
</span>
</div>
</>
)}
</Modal>
); );
} }

View File

@ -1,18 +1,15 @@
import React, { HTMLProps, useContext, useEffect, useState } from "react"; import React, { HTMLProps, useContext, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { useLongPress } from "use-long-press"; import { useLongPress } from "use-long-press";
import { TaggedNostrEvent, ParsedZap, countLeadingZeros, NostrLink } from "@snort/system"; import { TaggedNostrEvent, ParsedZap, countLeadingZeros, NostrLink } from "@snort/system";
import { SnortContext, useUserProfile } from "@snort/system-react"; import { SnortContext, useUserProfile } from "@snort/system-react";
import { formatShort } from "Number"; import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { delay, findTag, normalizeReaction } from "SnortUtils"; import { delay, findTag, normalizeReaction } from "SnortUtils";
import { NoteCreator } from "Element/NoteCreator"; import { NoteCreator } from "Element/NoteCreator";
import SendSats from "Element/SendSats"; import SendSats from "Element/SendSats";
import { ZapsSummary } from "Element/Zap"; import { ZapsSummary } from "Element/Zap";
import { RootState } from "State/Store";
import { setReplyTo, setShow, reset } from "State/NoteCreator";
import { AsyncIcon } from "Element/AsyncIcon"; import { AsyncIcon } from "Element/AsyncIcon";
import { useWallet } from "Wallet"; import { useWallet } from "Wallet";
@ -22,6 +19,7 @@ import { ZapPoolController } from "ZapPoolController";
import { System } from "index"; import { System } from "index";
import { Zapper, ZapTarget } from "Zapper"; import { Zapper, ZapTarget } from "Zapper";
import { getDisplayName } from "./ProfileImage"; import { getDisplayName } from "./ProfileImage";
import { useNoteCreator } from "State/NoteCreator";
import messages from "./messages"; import messages from "./messages";
@ -47,7 +45,6 @@ export interface NoteFooterProps {
export default function NoteFooter(props: NoteFooterProps) { export default function NoteFooter(props: NoteFooterProps) {
const { ev, positive, reposts, zaps } = props; const { ev, positive, reposts, zaps } = props;
const dispatch = useDispatch();
const system = useContext(SnortContext); const system = useContext(SnortContext);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const login = useLogin(); const login = useLogin();
@ -55,9 +52,8 @@ export default function NoteFooter(props: NoteFooterProps) {
const author = useUserProfile(ev.pubkey); const author = useUserProfile(ev.pubkey);
const interactionCache = useInteractionCache(publicKey, ev.id); const interactionCache = useInteractionCache(publicKey, ev.id);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show); const note = useNoteCreator();
const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo); const willRenderNoteCreator = note.show && note.replyTo?.id === ev.id;
const willRenderNoteCreator = showNoteCreatorModal && replyTo?.id === ev.id;
const [tip, setTip] = useState(false); const [tip, setTip] = useState(false);
const [zapping, setZapping] = useState(false); const [zapping, setZapping] = useState(false);
const walletState = useWallet(); const walletState = useWallet();
@ -238,7 +234,7 @@ export default function NoteFooter(props: NoteFooterProps) {
function replyIcon() { function replyIcon() {
return ( return (
<AsyncFooterIcon <AsyncFooterIcon
className={showNoteCreatorModal ? "reacted" : ""} className={note.show ? "reacted" : ""}
iconName="reply" iconName="reply"
title={formatMessage({ defaultMessage: "Reply" })} title={formatMessage({ defaultMessage: "Reply" })}
value={0} value={0}
@ -248,12 +244,13 @@ export default function NoteFooter(props: NoteFooterProps) {
} }
const handleReplyButtonClick = () => { const handleReplyButtonClick = () => {
if (replyTo?.id !== ev.id) { note.update(v => {
dispatch(reset()); if (v.replyTo?.id !== ev.id) {
} v.reset();
}
dispatch(setReplyTo(ev)); v.show = true;
dispatch(setShow(!showNoteCreatorModal)); v.replyTo = ev;
});
}; };
return ( return (

View File

@ -0,0 +1,7 @@
.pin-box {
border: 1px solid var(--border-color);
padding: 12px 16px;
font-size: 80px;
height: 1em;
border-radius: 12px;
}

View File

@ -0,0 +1,121 @@
import useLogin from "Hooks/useLogin";
import "./PinPrompt.css";
import { ReactNode, useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import useEventPublisher from "Hooks/useEventPublisher";
import { LoginStore, createPublisher, sessionNeedsPin } from "Login";
import { unwrap } from "@snort/shared";
import { EventPublisher, InvalidPinError, PinEncrypted } from "@snort/system";
import { DefaultPowWorker } from "index";
import Modal from "./Modal";
const PinLen = 6;
export function PinPrompt({ onResult, onCancel, subTitle }: { onResult: (v: string) => void, onCancel: () => void, subTitle?: ReactNode }) {
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const { formatMessage } = useIntl();
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
console.debug(e);
if (!isNaN(Number(e.key)) && pin.length < PinLen) {
setPin(s => s += e.key);
} if (e.key === "Backspace") {
setPin(s => s.slice(0, -1));
} else {
e.preventDefault();
}
};
const handler = (e: Event) => handleKey(e as KeyboardEvent);
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [pin]);
useEffect(() => {
setError("");
if (pin.length === PinLen) {
try {
onResult(pin);
} catch (e) {
console.error(e);
if (e instanceof InvalidPinError) {
setError(formatMessage({
defaultMessage: "Incorrect pin"
}));
} else if (e instanceof Error) {
setError(e.message);
}
}
}
}, [pin]);
const boxes = [];
for (let x = 0; x < PinLen; x++) {
boxes.push(<div className="pin-box flex f-center f-1">
{pin[x]}
</div>)
}
return <Modal id="pin" onClose={() => onCancel()}>
<div className="flex-column g12">
<h2>
<FormattedMessage defaultMessage="Enter Pin" />
</h2>
{subTitle}
<div className="flex g4">
{boxes}
</div>
{error && <b className="error">{error}</b>}
<div>
<button type="button" onClick={() => onCancel()}>
<FormattedMessage defaultMessage="Cancel" />
</button>
</div>
</div>
</Modal>
}
export function LoginUnlock() {
const login = useLogin();
const publisher = useEventPublisher();
function encryptMigration(pin: string) {
const k = unwrap(login.privateKey);
const newPin = PinEncrypted.create(k, pin);
const pub = EventPublisher.privateKey(k);
if (login.preferences.pow) {
pub.pow(login.preferences.pow, DefaultPowWorker);
}
LoginStore.setPublisher(login.id, pub);
LoginStore.updateSession({
...login,
privateKeyData: newPin.toPayload(),
privateKey: undefined
});
}
function unlockSession(pin: string) {
const pub = createPublisher(login, pin);
if (pub) {
if (login.preferences.pow) {
pub.pow(login.preferences.pow, DefaultPowWorker);
}
LoginStore.setPublisher(login.id, pub);
}
}
if (login.publicKey && !publisher && sessionNeedsPin(login)) {
if (login.privateKey !== undefined) {
return <PinPrompt subTitle={<p>
<FormattedMessage defaultMessage="Enter a pin to encrypt your private key, you must enter this pin every time you open Snort." />
</p>} onResult={encryptMigration} onCancel={() => {
// nothing
}} />
}
return <PinPrompt subTitle={<p>
<FormattedMessage defaultMessage="Enter pin to unlock private key" />
</p>} onResult={unlockSession} onCancel={() => {
//nothing
}} />
}
}

View File

@ -4,7 +4,7 @@ import { useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { useWallet } from "Wallet"; import { useWallet } from "Wallet";
import { unwrap } from "SnortUtils"; import { unwrap } from "SnortUtils";
import { formatShort } from "Number"; import { formatShort } from "Number";

View File

@ -10,7 +10,7 @@ import { Toastore } from "Toaster";
import { getDisplayName } from "Element/ProfileImage"; import { getDisplayName } from "Element/ProfileImage";
import { UserCache } from "Cache"; import { UserCache } from "Cache";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { WalletInvoiceState } from "Wallet"; import { WalletInvoiceState } from "Wallet";
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) { export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {

View File

@ -1,30 +1,27 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useDispatch, useSelector } from "react-redux"; import { TaggedNostrEvent } from "@snort/system";
import useEventPublisher from "Feed/EventPublisher";
import useEventPublisher from "Hooks/useEventPublisher";
import Modal from "Element/Modal"; import Modal from "Element/Modal";
import type { RootState } from "State/Store";
import { setShow, reset, setSelectedCustomRelays } from "State/ReBroadcast";
import messages from "./messages"; import messages from "./messages";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { System } from "index"; import { System } from "index";
export function ReBroadcaster() { export function ReBroadcaster({ onClose, ev }: { onClose: () => void, ev: TaggedNostrEvent }) {
const [selected, setSelected] = useState<Array<string>>();
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const { note, show, selectedCustomRelays } = useSelector((s: RootState) => s.reBroadcast);
const dispatch = useDispatch();
async function sendReBroadcast() { async function sendReBroadcast() {
if (note && publisher) { if (publisher) {
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, note)); if (selected) {
else System.BroadcastEvent(note); await Promise.all(selected.map(r => System.WriteOnceToRelay(r, ev)));
dispatch(reset()); } else {
System.BroadcastEvent(ev);
}
} }
} }
function cancel() {
dispatch(reset());
}
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) { function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
ev.stopPropagation(); ev.stopPropagation();
sendReBroadcast().catch(console.warn); sendReBroadcast().catch(console.warn);
@ -46,19 +43,11 @@ export function ReBroadcaster() {
<div> <div>
<input <input
type="checkbox" type="checkbox"
checked={!selectedCustomRelays || selectedCustomRelays.includes(r)} checked={!selected || selected.includes(r)}
onChange={e => onChange={e => setSelected(
dispatch( e.target.checked && selected && selected.length == a.length - 1
setSelectedCustomRelays( ? undefined
// set false if all relays selected : a.filter(el => el === r ? e.target.checked : !selected || selected.includes(el)))
e.target.checked && selectedCustomRelays && selectedCustomRelays.length == a.length - 1
? false
: // otherwise return selectedCustomRelays with target relay added / removed
a.filter(el =>
el === r ? e.target.checked : !selectedCustomRelays || selectedCustomRelays.includes(el),
),
),
)
} }
/> />
</div> </div>
@ -70,19 +59,17 @@ export function ReBroadcaster() {
return ( return (
<> <>
{show && ( <Modal id="broadcaster" className="note-creator-modal" onClose={onClose}>
<Modal className="note-creator-modal" onClose={() => dispatch(setShow(false))}> {renderRelayCustomisation()}
{renderRelayCustomisation()} <div className="note-creator-actions">
<div className="note-creator-actions"> <button className="secondary" onClick={onClose}>
<button className="secondary" onClick={cancel}> <FormattedMessage {...messages.Cancel} />
<FormattedMessage {...messages.Cancel} /> </button>
</button> <button onClick={onSubmit}>
<button onClick={onSubmit}> <FormattedMessage {...messages.ReBroadcast} />
<FormattedMessage {...messages.ReBroadcast} /> </button>
</button> </div>
</div> </Modal>
</Modal>
)}
</> </>
); );
} }

View File

@ -72,7 +72,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
}, [show]); }, [show]);
return show ? ( return show ? (
<Modal className="reactions-modal" onClose={onClose}> <Modal id="reactions" className="reactions-modal" onClose={onClose}>
<div className="close" onClick={onClose}> <div className="close" onClick={onClose}>
<Icon name="close" /> <Icon name="close" />
</div> </div>

View File

@ -8,7 +8,7 @@ import { LNURLSuccessAction } from "@snort/shared";
import { formatShort } from "Number"; import { formatShort } from "Number";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";
import Modal from "Element/Modal"; import Modal from "Element/Modal";
import QrCode from "Element/QrCode"; import QrCode from "Element/QrCode";
@ -180,7 +180,7 @@ export default function SendSats(props: SendSatsProps) {
if (!(props.show ?? false)) return null; if (!(props.show ?? false)) return null;
return ( return (
<Modal className="lnurl-modal" onClose={onClose}> <Modal id="send-sats" className="lnurl-modal" onClose={onClose}>
<div className="p flex-column g12"> <div className="p flex-column g12">
<div className="flex g12"> <div className="flex g12">
<div className="flex f-grow">{props.title || title()}</div> <div className="flex f-grow">{props.title || title()}</div>

View File

@ -55,7 +55,7 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
export function SpotlightMediaModal(props: SpotlightMediaProps) { export function SpotlightMediaModal(props: SpotlightMediaProps) {
return ( return (
<Modal onClose={props.onClose} className="spotlight"> <Modal id="spotlight" onClose={props.onClose} className="spotlight">
<SpotlightMedia {...props} /> <SpotlightMedia {...props} />
</Modal> </Modal>
); );

View File

@ -11,7 +11,7 @@ import Note from "Element/Note";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
import { FollowsFeed } from "Cache"; import { FollowsFeed } from "Cache";
import { LiveStreams } from "Element/LiveStreams"; import { LiveStreams } from "Element/LiveStreams";
import { useReactions } from "Feed/FeedReactions"; import { useReactions } from "Feed/Reactions";
import AsyncButton from "./AsyncButton"; import AsyncButton from "./AsyncButton";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";

View File

@ -4,7 +4,7 @@ import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import PageSpinner from "Element/PageSpinner"; import PageSpinner from "Element/PageSpinner";
import Note from "Element/Note"; import Note from "Element/Note";
import NostrBandApi from "External/NostrBand"; import NostrBandApi from "External/NostrBand";
import { useReactions } from "Feed/FeedReactions"; import { useReactions } from "Feed/Reactions";
export default function TrendingNotes() { export default function TrendingNotes() {
const [posts, setPosts] = useState<Array<NostrEvent>>(); const [posts, setPosts] = useState<Array<NostrEvent>>();

View File

@ -1,5 +1,5 @@
import { NostrPrefix, NostrEvent, NostrLink } from "@snort/system"; import { NostrPrefix, NostrEvent, NostrLink } from "@snort/system";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import Spinner from "Icons/Spinner"; import Spinner from "Icons/Spinner";
import { useState } from "react"; import { useState } from "react";

View File

@ -1,10 +0,0 @@
import useLogin from "Hooks/useLogin";
import { DefaultPowWorker } from "index";
export default function useEventPublisher() {
const { publisher, preferences } = useLogin();
if (preferences.pow) {
publisher?.pow(preferences.pow, DefaultPowWorker);
}
return publisher;
}

View File

@ -4,7 +4,7 @@ import { useRequestBuilder } from "@snort/system-react";
import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "SnortUtils"; import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "SnortUtils";
import { makeNotification, sendNotification } from "Notifications"; import { makeNotification, sendNotification } from "Notifications";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { getMutedKeys } from "Feed/MuteList"; import { getMutedKeys } from "Feed/MuteList";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
@ -28,9 +28,7 @@ export default function useLoginFeed() {
useRefreshFeedCache(Notifications, true); useRefreshFeedCache(Notifications, true);
useRefreshFeedCache(FollowsFeed, true); useRefreshFeedCache(FollowsFeed, true);
if (publisher?.supports("nip44")) { useRefreshFeedCache(GiftsCache, true);
useRefreshFeedCache(GiftsCache, true);
}
const subLogin = useMemo(() => { const subLogin = useMemo(() => {
if (!pubKey) return null; if (!pubKey) return null;

View File

@ -1,4 +1,4 @@
import { RequestBuilder, EventKind, NoteCollection, NostrLink, NostrPrefix } from "@snort/system"; import { RequestBuilder, EventKind, NoteCollection, NostrLink } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { useMemo } from "react"; import { useMemo } from "react";
@ -8,10 +8,8 @@ export function useReactions(subId: string, ids: Array<NostrLink>, others?: (rb:
const sub = useMemo(() => { const sub = useMemo(() => {
const rb = new RequestBuilder(subId); const rb = new RequestBuilder(subId);
const eTags = ids.filter(a => a.type === NostrPrefix.Note || a.type === NostrPrefix.Event);
const aTags = ids.filter(a => a.type === NostrPrefix.Address);
if (aTags.length > 0 || eTags.length > 0) { if (ids.length > 0) {
const f = rb const f = rb
.withFilter() .withFilter()
.kinds( .kinds(
@ -20,8 +18,7 @@ export function useReactions(subId: string, ids: Array<NostrLink>, others?: (rb:
: [EventKind.ZapReceipt, EventKind.Repost], : [EventKind.ZapReceipt, EventKind.Repost],
); );
aTags.forEach(v => f.replyToLink(v)); ids.forEach(v => f.replyToLink(v));
eTags.forEach(v => f.replyToLink(v));
} }
others?.(rb); others?.(rb);
return rb.numFilters > 0 ? rb : null; return rb.numFilters > 0 ? rb : null;

View File

@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { EventKind, NostrLink, RequestBuilder, NoteCollection } from "@snort/system"; import { EventKind, NostrLink, RequestBuilder, NoteCollection } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { useReactions } from "./FeedReactions"; import { useReactions } from "./Reactions";
export default function useThreadFeed(link: NostrLink) { export default function useThreadFeed(link: NostrLink) {
const [allEvents, setAllEvents] = useState<Array<NostrLink>>([]); const [allEvents, setAllEvents] = useState<Array<NostrLink>>([]);
@ -12,9 +12,10 @@ export default function useThreadFeed(link: NostrLink) {
sub.withOptions({ sub.withOptions({
leaveOpen: true, leaveOpen: true,
}); });
sub.withFilter().kinds([EventKind.TextNote]).link(link).replyToLink(link); sub.withFilter().link(link);
sub.withFilter().kinds([EventKind.TextNote]).replyToLink(link);
allEvents.forEach(x => { allEvents.forEach(x => {
sub.withFilter().kinds([EventKind.TextNote]).link(x).replyToLink(x); sub.withFilter().kinds([EventKind.TextNote]).replyToLink(x);
}); });
return sub; return sub;
}, [allEvents.length]); }, [allEvents.length]);
@ -23,8 +24,7 @@ export default function useThreadFeed(link: NostrLink) {
useEffect(() => { useEffect(() => {
if (store.data) { if (store.data) {
const mainNotes = store.data?.filter(a => a.kind === EventKind.TextNote || a.kind === EventKind.Polls) ?? []; const links = store.data
const links = mainNotes
.map(a => [ .map(a => [
NostrLink.fromEvent(a), NostrLink.fromEvent(a),
...a.tags.filter(a => a[0] === "e" || a[0] === "a").map(v => NostrLink.fromTag(v)), ...a.tags.filter(a => a[0] === "e" || a[0] === "a").map(v => NostrLink.fromTag(v)),

View File

@ -0,0 +1,21 @@
import useLogin from "Hooks/useLogin";
import { LoginStore, createPublisher, sessionNeedsPin } from "Login";
import { DefaultPowWorker } from "index";
export default function useEventPublisher() {
const login = useLogin();
let existing = LoginStore.getPublisher(login.id);
if (login.publicKey && !existing && !sessionNeedsPin(login)) {
existing = createPublisher(login);
if (existing) {
if (login.preferences.pow) {
existing.pow(login.preferences.pow, DefaultPowWorker);
}
LoginStore.setPublisher(login.id, existing);
}
}
return existing;
}

View File

@ -1,17 +1,19 @@
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { Nip46Signer, PinEncrypted } from "@snort/system";
import { EmailRegex, MnemonicRegex } from "Const"; import { EmailRegex, MnemonicRegex } from "Const";
import { LoginSessionType, LoginStore } from "Login"; import { LoginSessionType, LoginStore } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6"; import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { getNip05PubKey } from "Pages/LoginPage"; import { getNip05PubKey } from "Pages/LoginPage";
import { bech32ToHex } from "SnortUtils"; import { bech32ToHex } from "SnortUtils";
import { Nip46Signer } from "@snort/system";
export class PinRequiredError extends Error { }
export default function useLoginHandler() { export default function useLoginHandler() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const hasSubtleCrypto = window.crypto.subtle !== undefined; const hasSubtleCrypto = window.crypto.subtle !== undefined;
async function doLogin(key: string) { async function doLogin(key: string, pin?: string) {
const insecureMsg = formatMessage({ const insecureMsg = formatMessage({
defaultMessage: defaultMessage:
"Can't login with private key on an insecure connection, please use a Nostr key manager extension instead", "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead",
@ -23,7 +25,8 @@ export default function useLoginHandler() {
} }
const hexKey = bech32ToHex(key); const hexKey = bech32ToHex(key);
if (hexKey.length === 64) { if (hexKey.length === 64) {
LoginStore.loginWithPrivateKey(hexKey); if (!pin) throw new PinRequiredError();
LoginStore.loginWithPrivateKey(PinEncrypted.create(hexKey, pin));
} else { } else {
throw new Error("INVALID PRIVATE KEY"); throw new Error("INVALID PRIVATE KEY");
} }
@ -31,14 +34,16 @@ export default function useLoginHandler() {
if (!hasSubtleCrypto) { if (!hasSubtleCrypto) {
throw new Error(insecureMsg); throw new Error(insecureMsg);
} }
if (!pin) throw new PinRequiredError();
const ent = generateBip39Entropy(key); const ent = generateBip39Entropy(key);
const keyHex = entropyToPrivateKey(ent); const keyHex = entropyToPrivateKey(ent);
LoginStore.loginWithPrivateKey(keyHex); LoginStore.loginWithPrivateKey(PinEncrypted.create(keyHex, pin));
} else if (key.length === 64) { } else if (key.length === 64) {
if (!hasSubtleCrypto) { if (!hasSubtleCrypto) {
throw new Error(insecureMsg); throw new Error(insecureMsg);
} }
LoginStore.loginWithPrivateKey(key); if (!pin) throw new PinRequiredError();
LoginStore.loginWithPrivateKey(PinEncrypted.create(key, pin));
} }
// public key logins // public key logins

View File

@ -1,5 +1,5 @@
import { HexKey } from "@snort/system"; import { HexKey } from "@snort/system";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { setBlocked, setMuted } from "Login"; import { setBlocked, setMuted } from "Login";
import { appendDedupe } from "SnortUtils"; import { appendDedupe } from "SnortUtils";

View File

@ -1,17 +1,18 @@
import { SnortContext } from "@snort/system-react"; import { SnortContext } from "@snort/system-react";
import { useContext, useEffect, useMemo } from "react"; import { useContext, useEffect, useMemo } from "react";
import { NoopStore, RequestBuilder, TaggedNostrEvent } from "@snort/system"; import { NoopStore, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { unwrap } from "@snort/shared";
import { RefreshFeedCache } from "Cache/RefreshFeedCache"; import { RefreshFeedCache } from "Cache/RefreshFeedCache";
import useLogin from "./useLogin"; import useLogin from "./useLogin";
import useEventPublisher from "./useEventPublisher";
export function useRefreshFeedCache<T>(c: RefreshFeedCache<T>, leaveOpen = false) { export function useRefreshFeedCache<T>(c: RefreshFeedCache<T>, leaveOpen = false) {
const system = useContext(SnortContext); const system = useContext(SnortContext);
const login = useLogin(); const login = useLogin();
const publisher = useEventPublisher();
const sub = useMemo(() => { const sub = useMemo(() => {
if (login) { if (login.publicKey) {
const rb = new RequestBuilder(`using-${c.name}`); const rb = new RequestBuilder(`using-${c.name}`);
rb.withOptions({ rb.withOptions({
leaveOpen, leaveOpen,
@ -28,11 +29,11 @@ export function useRefreshFeedCache<T>(c: RefreshFeedCache<T>, leaveOpen = false
let t: ReturnType<typeof setTimeout> | undefined; let t: ReturnType<typeof setTimeout> | undefined;
let tBuf: Array<TaggedNostrEvent> = []; let tBuf: Array<TaggedNostrEvent> = [];
const releaseOnEvent = q.feed.onEvent(evs => { const releaseOnEvent = q.feed.onEvent(evs => {
if (!t) { if (!t && publisher) {
tBuf = [...evs]; tBuf = [...evs];
t = setTimeout(() => { t = setTimeout(() => {
t = undefined; t = undefined;
c.onEvent(tBuf, unwrap(login.publisher)); c.onEvent(tBuf, publisher);
}, 100); }, 100);
} else { } else {
tBuf.push(...evs); tBuf.push(...evs);

View File

@ -1,15 +1,17 @@
import { HexKey, RelaySettings, EventPublisher } from "@snort/system"; import { RelaySettings, EventPublisher, PinEncrypted, Nip46Signer, Nip7Signer, PrivateKeySigner } from "@snort/system";
import { unixNowMs } from "@snort/shared"; import { unixNowMs } from "@snort/shared";
import * as secp from "@noble/curves/secp256k1"; import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils"; import * as utils from "@noble/curves/abstract/utils";
import { DefaultRelays, SnortPubKey } from "Const"; import { DefaultRelays, SnortPubKey } from "Const";
import { LoginStore, UserPreferences, LoginSession } from "Login"; import { LoginStore, UserPreferences, LoginSession, LoginSessionType } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6"; import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unwrap } from "SnortUtils"; import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unwrap } from "SnortUtils";
import { SubscriptionEvent } from "Subscription"; import { SubscriptionEvent } from "Subscription";
import { System } from "index"; import { System } from "index";
import { Chats, FollowsFeed, GiftsCache, Notifications } from "Cache"; import { Chats, FollowsFeed, GiftsCache, Notifications } from "Cache";
import { PinRequiredError } from "Hooks/useLoginHandler";
import { Nip7OsSigner } from "./Nip7OsSigner";
export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) { export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) {
if (state.relays.timestamp >= createdAt) { if (state.relays.timestamp >= createdAt) {
@ -41,8 +43,8 @@ export function updatePreferences(state: LoginSession, p: UserPreferences) {
LoginStore.updateSession(state); LoginStore.updateSession(state);
} }
export function logout(k: HexKey) { export function logout(id: string) {
LoginStore.removeSession(k); LoginStore.removeSession(id);
GiftsCache.clear(); GiftsCache.clear();
Notifications.clear(); Notifications.clear();
FollowsFeed.clear(); FollowsFeed.clear();
@ -62,7 +64,7 @@ export function clearEntropy(state: LoginSession) {
/** /**
* Generate a new key and login with this generated key * Generate a new key and login with this generated key
*/ */
export async function generateNewLogin() { export async function generateNewLogin(pin: string) {
const ent = generateBip39Entropy(); const ent = generateBip39Entropy();
const entropy = utils.bytesToHex(ent); const entropy = utils.bytesToHex(ent);
const privateKey = entropyToPrivateKey(ent); const privateKey = entropyToPrivateKey(ent);
@ -88,7 +90,9 @@ export async function generateNewLogin() {
const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey], newRelays); const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey], newRelays);
System.BroadcastEvent(ev); System.BroadcastEvent(ev);
LoginStore.loginWithPrivateKey(privateKey, entropy, newRelays); const key = PinEncrypted.create(privateKey, pin);
key.decrypt(pin);
LoginStore.loginWithPrivateKey(key, entropy, newRelays);
} }
export function generateRandomKey() { export function generateRandomKey() {
@ -161,3 +165,38 @@ export function addSubscription(state: LoginSession, ...subs: SubscriptionEvent[
LoginStore.updateSession(state); LoginStore.updateSession(state);
} }
} }
export function sessionNeedsPin(l: LoginSession) {
return l.type === LoginSessionType.PrivateKey || l.type === LoginSessionType.Nip46;
}
export function createPublisher(l: LoginSession, pin?: string) {
switch (l.type) {
case LoginSessionType.PrivateKey: {
if(!pin) throw new PinRequiredError();
const v = l.privateKeyData instanceof PinEncrypted ? l.privateKeyData : new PinEncrypted(unwrap(l.privateKeyData));
v.decrypt(pin);
l.privateKeyData = v;
return EventPublisher.privateKey(v.value);
}
case LoginSessionType.Nip46: {
if(!pin) throw new PinRequiredError();
const v = l.privateKeyData instanceof PinEncrypted ? l.privateKeyData : new PinEncrypted(unwrap(l.privateKeyData));
v.decrypt(pin);
l.privateKeyData = v;
const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`);
const inner = new PrivateKeySigner(v.value);
const nip46 = new Nip46Signer(`bunker://${unwrap(l.publicKey)}?${[...relayArgs].join("&")}`, inner);
return new EventPublisher(nip46, unwrap(l.publicKey));
}
case LoginSessionType.Nip7os: {
return new EventPublisher(new Nip7OsSigner(), unwrap(l.publicKey));
}
default: {
if (l.publicKey) {
return new EventPublisher(new Nip7Signer(), l.publicKey);
}
}
}
}

View File

@ -1,4 +1,4 @@
import { HexKey, RelaySettings, u256, EventPublisher } from "@snort/system"; import { HexKey, RelaySettings, u256, PinEncrypted, PinEncryptedPayload } from "@snort/system";
import { UserPreferences } from "Login"; import { UserPreferences } from "Login";
import { SubscriptionEvent } from "Subscription"; import { SubscriptionEvent } from "Subscription";
@ -19,6 +19,11 @@ export enum LoginSessionType {
} }
export interface LoginSession { export interface LoginSession {
/**
* Unique ID to identify this session
*/
id: string;
/** /**
* Type of login session * Type of login session
*/ */
@ -26,9 +31,15 @@ export interface LoginSession {
/** /**
* Current user private key * Current user private key
* @deprecated Moving to pin encrypted storage
*/ */
privateKey?: HexKey; privateKey?: HexKey;
/**
* Encrypted private key
*/
privateKeyData?: PinEncrypted | PinEncryptedPayload;
/** /**
* BIP39-generated, hex-encoded entropy * BIP39-generated, hex-encoded entropy
*/ */
@ -98,9 +109,4 @@ export interface LoginSession {
* Remote signer relays (NIP-46) * Remote signer relays (NIP-46)
*/ */
remoteSignerRelays?: Array<string>; remoteSignerRelays?: Array<string>;
/**
* Instance event publisher
*/
publisher?: EventPublisher;
} }

View File

@ -1,16 +1,17 @@
import * as secp from "@noble/curves/secp256k1"; import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils"; import * as utils from "@noble/curves/abstract/utils";
import {v4 as uuid} from "uuid";
import { HexKey, RelaySettings, EventPublisher, Nip46Signer, Nip7Signer, PrivateKeySigner } from "@snort/system"; import { HexKey, RelaySettings, PinEncrypted, EventPublisher } from "@snort/system";
import { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared"; import { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared";
import { DefaultRelays } from "Const"; import { DefaultRelays } from "Const";
import { LoginSession, LoginSessionType } from "Login"; import { LoginSession, LoginSessionType, createPublisher } from "Login";
import { DefaultPreferences, UserPreferences } from "./Preferences"; import { DefaultPreferences } from "./Preferences";
import { Nip7OsSigner } from "./Nip7OsSigner";
const AccountStoreKey = "sessions"; const AccountStoreKey = "sessions";
const LoggedOut = { const LoggedOut = {
id: "default",
type: "public_key", type: "public_key",
preferences: DefaultPreferences, preferences: DefaultPreferences,
tags: { tags: {
@ -45,25 +46,18 @@ const LoggedOut = {
readNotifications: 0, readNotifications: 0,
subscriptions: [], subscriptions: [],
} as LoginSession; } as LoginSession;
const LegacyKeys = {
PrivateKeyItem: "secret",
PublicKeyItem: "pubkey",
NotificationsReadItem: "notifications-read",
UserPreferencesKey: "preferences",
RelayListKey: "last-relays",
FollowList: "last-follows",
};
export class MultiAccountStore extends ExternalStore<LoginSession> { export class MultiAccountStore extends ExternalStore<LoginSession> {
#activeAccount?: HexKey; #activeAccount?: HexKey;
#accounts: Map<string, LoginSession>; #accounts: Map<string, LoginSession>;
#publishers = new Map<string, EventPublisher>();
constructor() { constructor() {
super(); super();
const existing = window.localStorage.getItem(AccountStoreKey); const existing = window.localStorage.getItem(AccountStoreKey);
if (existing) { if (existing) {
const logins = JSON.parse(existing); const logins = JSON.parse(existing);
this.#accounts = new Map((logins as Array<LoginSession>).map(a => [unwrap(a.publicKey), a])); this.#accounts = new Map((logins as Array<LoginSession>).map(a => [a.id, a]));
} else { } else {
this.#accounts = new Map(); this.#accounts = new Map();
} }
@ -71,26 +65,32 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (!this.#activeAccount) { if (!this.#activeAccount) {
this.#activeAccount = this.#accounts.keys().next().value; this.#activeAccount = this.#accounts.keys().next().value;
} }
for (const [, v] of this.#accounts) {
v.publisher = this.#createPublisher(v);
}
} }
getSessions() { getSessions() {
return [...this.#accounts.keys()]; return [...this.#accounts.values()].map(v => unwrap(v.publicKey));
} }
allSubscriptions() { allSubscriptions() {
return [...this.#accounts.values()].map(a => a.subscriptions).flat(); return [...this.#accounts.values()].map(a => a.subscriptions).flat();
} }
switchAccount(pk: string) { switchAccount(id: string) {
if (this.#accounts.has(pk)) { if (this.#accounts.has(id)) {
this.#activeAccount = pk; this.#activeAccount = id;
this.#save(); this.#save();
} }
} }
getPublisher(id: string) {
return this.#publishers.get(id);
}
setPublisher(id: string, pub: EventPublisher) {
this.#publishers.set(id, pub);
this.notifyChange();
}
loginWithPubkey( loginWithPubkey(
key: HexKey, key: HexKey,
type: LoginSessionType, type: LoginSessionType,
@ -104,6 +104,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
const initRelays = this.decideInitRelays(relays); const initRelays = this.decideInitRelays(relays);
const newSession = { const newSession = {
...LoggedOut, ...LoggedOut,
id: uuid(),
type, type,
publicKey: key, publicKey: key,
relays: { relays: {
@ -114,10 +115,13 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
remoteSignerRelays, remoteSignerRelays,
privateKey, privateKey,
} as LoginSession; } as LoginSession;
newSession.publisher = this.#createPublisher(newSession);
this.#accounts.set(key, newSession); const pub = createPublisher(newSession);
this.#activeAccount = key; if(pub) {
this.setPublisher(newSession.id, pub);
}
this.#accounts.set(newSession.id, newSession);
this.#activeAccount = newSession.id;
this.#save(); this.#save();
return newSession; return newSession;
} }
@ -129,16 +133,17 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
return Object.fromEntries(DefaultRelays.entries()); return Object.fromEntries(DefaultRelays.entries());
} }
loginWithPrivateKey(key: HexKey, entropy?: string, relays?: Record<string, RelaySettings>) { loginWithPrivateKey(key: PinEncrypted, entropy?: string, relays?: Record<string, RelaySettings>) {
const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(key)); const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(key.value));
if (this.#accounts.has(pubKey)) { if (this.#accounts.has(pubKey)) {
throw new Error("Already logged in with this pubkey"); throw new Error("Already logged in with this pubkey");
} }
const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries()); const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries());
const newSession = { const newSession = {
...LoggedOut, ...LoggedOut,
id: uuid(),
type: LoginSessionType.PrivateKey, type: LoginSessionType.PrivateKey,
privateKey: key, privateKeyData: key,
publicKey: pubKey, publicKey: pubKey,
generatedEntropy: entropy, generatedEntropy: entropy,
relays: { relays: {
@ -149,30 +154,30 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
} as LoginSession; } as LoginSession;
if ("nostr_os" in window && window.nostr_os) { if ("nostr_os" in window && window.nostr_os) {
window.nostr_os.saveKey(key); window.nostr_os.saveKey(key.value);
newSession.type = LoginSessionType.Nip7os; newSession.type = LoginSessionType.Nip7os;
newSession.privateKey = undefined; newSession.privateKeyData = undefined;
} }
newSession.publisher = this.#createPublisher(newSession); const pub = EventPublisher.privateKey(key.value);
this.setPublisher(newSession.id, pub);
this.#accounts.set(pubKey, newSession); this.#accounts.set(newSession.id, newSession);
this.#activeAccount = pubKey; this.#activeAccount = newSession.id;
this.#save(); this.#save();
return newSession; return newSession;
} }
updateSession(s: LoginSession) { updateSession(s: LoginSession) {
const pk = unwrap(s.publicKey); if (this.#accounts.has(s.id)) {
if (this.#accounts.has(pk)) { this.#accounts.set(s.id, s);
this.#accounts.set(pk, s);
console.debug("SET SESSION", s); console.debug("SET SESSION", s);
this.#save(); this.#save();
} }
} }
removeSession(k: string) { removeSession(id: string) {
if (this.#accounts.delete(k)) { if (this.#accounts.delete(id)) {
if (this.#activeAccount === k) { if (this.#activeAccount === id) {
this.#activeAccount = undefined; this.#activeAccount = undefined;
} }
this.#save(); this.#save();
@ -183,65 +188,11 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
const s = this.#activeAccount ? this.#accounts.get(this.#activeAccount) : undefined; const s = this.#activeAccount ? this.#accounts.get(this.#activeAccount) : undefined;
if (!s) return LoggedOut; if (!s) return LoggedOut;
return { ...s }; return {...s};
}
#createPublisher(l: LoginSession) {
switch (l.type) {
case LoginSessionType.PrivateKey: {
return EventPublisher.privateKey(unwrap(l.privateKey));
}
case LoginSessionType.Nip46: {
const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`);
const inner = new PrivateKeySigner(unwrap(l.privateKey));
const nip46 = new Nip46Signer(`bunker://${unwrap(l.publicKey)}?${[...relayArgs].join("&")}`, inner);
return new EventPublisher(nip46, unwrap(l.publicKey));
}
case LoginSessionType.Nip7os: {
return new EventPublisher(new Nip7OsSigner(), unwrap(l.publicKey));
}
default: {
if (l.publicKey) {
return new EventPublisher(new Nip7Signer(), l.publicKey);
}
}
}
} }
#migrate() { #migrate() {
let didMigrate = false; let didMigrate = false;
const oldPreferences = window.localStorage.getItem(LegacyKeys.UserPreferencesKey);
const pref: UserPreferences = oldPreferences ? JSON.parse(oldPreferences) : deepClone(DefaultPreferences);
window.localStorage.removeItem(LegacyKeys.UserPreferencesKey);
const privKey = window.localStorage.getItem(LegacyKeys.PrivateKeyItem);
if (privKey) {
const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(privKey));
this.#accounts.set(pubKey, {
...LoggedOut,
privateKey: privKey,
publicKey: pubKey,
preferences: pref,
} as LoginSession);
window.localStorage.removeItem(LegacyKeys.PrivateKeyItem);
window.localStorage.removeItem(LegacyKeys.PublicKeyItem);
didMigrate = true;
}
const pubKey = window.localStorage.getItem(LegacyKeys.PublicKeyItem);
if (pubKey) {
this.#accounts.set(pubKey, {
...LoggedOut,
publicKey: pubKey,
preferences: pref,
} as LoginSession);
window.localStorage.removeItem(LegacyKeys.PublicKeyItem);
didMigrate = true;
}
window.localStorage.removeItem(LegacyKeys.RelayListKey);
window.localStorage.removeItem(LegacyKeys.FollowList);
window.localStorage.removeItem(LegacyKeys.NotificationsReadItem);
// replace default tab with notes // replace default tab with notes
for (const [, v] of this.#accounts) { for (const [, v] of this.#accounts) {
@ -259,6 +210,14 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
} }
} }
// add ids
for (const [, v] of this.#accounts) {
if ((v.id?.length ?? 0) === 0) {
v.id = uuid();
didMigrate = true;
}
}
if (didMigrate) { if (didMigrate) {
console.debug("Finished migration to MultiAccountStore"); console.debug("Finished migration to MultiAccountStore");
this.#save(); this.#save();
@ -267,9 +226,16 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
#save() { #save() {
if (!this.#activeAccount && this.#accounts.size > 0) { if (!this.#activeAccount && this.#accounts.size > 0) {
this.#activeAccount = [...this.#accounts.keys()][0]; this.#activeAccount = this.#accounts.keys().next().value;
} }
window.localStorage.setItem(AccountStoreKey, JSON.stringify([...this.#accounts.values()])); const toSave = [...this.#accounts.values()];
for(const v of toSave) {
if(v.privateKeyData instanceof PinEncrypted) {
v.privateKeyData = v.privateKeyData.toPayload();
}
}
window.localStorage.setItem(AccountStoreKey, JSON.stringify(toSave));
this.notifyChange(); this.notifyChange();
} }
} }

View File

@ -1,4 +1,5 @@
import { MultiAccountStore } from "./MultiAccountStore"; import { MultiAccountStore } from "./MultiAccountStore";
export const LoginStore = new MultiAccountStore(); export const LoginStore = new MultiAccountStore();
export interface Nip7os { export interface Nip7os {

View File

@ -70,7 +70,7 @@ export function SnortDeckLayout() {
</div> </div>
{deckScope.thread && ( {deckScope.thread && (
<> <>
<Modal onClose={() => deckScope.setThread(undefined)} className="thread-overlay"> <Modal id="thread-overlay" onClose={() => deckScope.setThread(undefined)} className="thread-overlay">
<ThreadContextWrapper link={deckScope.thread}> <ThreadContextWrapper link={deckScope.thread}>
<SpotlightFromThread onClose={() => deckScope.setThread(undefined)} /> <SpotlightFromThread onClose={() => deckScope.setThread(undefined)} />
<div> <div>

View File

@ -3,7 +3,7 @@ import { useParams } from "react-router-dom";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import Timeline from "Element/Timeline"; import Timeline from "Element/Timeline";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { setTags } from "Login"; import { setTags } from "Login";
import { System } from "index"; import { System } from "index";

View File

@ -1,6 +1,5 @@
import "./Layout.css"; import "./Layout.css";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
@ -9,8 +8,6 @@ import { NostrLink, NostrPrefix, tryParseNostrLink } from "@snort/system";
import messages from "./messages"; import messages from "./messages";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import { RootState } from "State/Store";
import { setShow, reset } from "State/NoteCreator";
import useLoginFeed from "Feed/LoginFeed"; import useLoginFeed from "Feed/LoginFeed";
import { NoteCreator } from "Element/NoteCreator"; import { NoteCreator } from "Element/NoteCreator";
import { mapPlanName } from "./subscribe"; import { mapPlanName } from "./subscribe";
@ -23,29 +20,19 @@ import Spinner from "Icons/Spinner";
import { fetchNip05Pubkey } from "Nip05/Verifier"; import { fetchNip05Pubkey } from "Nip05/Verifier";
import { useTheme } from "Hooks/useTheme"; import { useTheme } from "Hooks/useTheme";
import { useLoginRelays } from "Hooks/useLoginRelays"; import { useLoginRelays } from "Hooks/useLoginRelays";
import { useNoteCreator } from "State/NoteCreator";
import { LoginUnlock } from "Element/PinPrompt";
export default function Layout() { export default function Layout() {
const location = useLocation(); const location = useLocation();
const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo); const note = useNoteCreator();
const isNoteCreatorShowing = useSelector((s: RootState) => s.noteCreator.show); const isReplyNoteCreatorShowing = note.replyTo && note.show;
const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing;
const dispatch = useDispatch();
const navigate = useNavigate();
const { publicKey, subscriptions } = useLogin();
const currentSubscription = getCurrentSubscription(subscriptions);
const [pageClass, setPageClass] = useState("page"); const [pageClass, setPageClass] = useState("page");
useLoginFeed(); useLoginFeed();
useTheme(); useTheme();
useLoginRelays(); useLoginRelays();
const handleNoteCreatorButtonClick = () => {
if (replyTo) {
dispatch(reset());
}
dispatch(setShow(true));
};
const shouldHideNoteCreator = useMemo(() => { const shouldHideNoteCreator = useMemo(() => {
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/e", "/subscribe"]; const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/e", "/subscribe"];
return isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a)); return isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a));
@ -66,34 +53,22 @@ export default function Layout() {
} }
}, [location]); }, [location]);
return ( return (<>
<div className={pageClass}> <div className={pageClass}>
{!shouldHideHeader && ( {!shouldHideHeader && (
<header className="main-content"> <header className="main-content">
<Link to="/" className="logo"> <LogoHeader />
<h1>Snort</h1> <AccountHeader />
{currentSubscription && (
<small className="flex">
<Icon name="diamond" size={10} className="mr5" />
{mapPlanName(currentSubscription.type)}
</small>
)}
</Link>
{publicKey ? (
<AccountHeader />
) : (
<button type="button" onClick={() => navigate("/login")}>
<FormattedMessage {...messages.Login} />
</button>
)}
</header> </header>
)} )}
<Outlet /> <Outlet />
{!shouldHideNoteCreator && ( {!shouldHideNoteCreator && (
<> <>
<button className="primary note-create-button" onClick={handleNoteCreatorButtonClick}> <button className="primary note-create-button" onClick={() => note.update(v => {
v.replyTo = undefined;
v.show = true
})}>
<Icon name="plus" size={16} /> <Icon name="plus" size={16} />
</button> </button>
<NoteCreator /> <NoteCreator />
@ -101,6 +76,8 @@ export default function Layout() {
)} )}
<Toaster /> <Toaster />
</div> </div>
<LoginUnlock />
</>
); );
} }
@ -156,6 +133,13 @@ const AccountHeader = () => {
} }
} }
if (!publicKey) {
return (
<button type="button" onClick={() => navigate("/login")}>
<FormattedMessage {...messages.Login} />
</button>
)
}
return ( return (
<div className="header-actions"> <div className="header-actions">
{!location.pathname.startsWith("/search") && ( {!location.pathname.startsWith("/search") && (
@ -199,3 +183,20 @@ const AccountHeader = () => {
</div> </div>
); );
}; };
function LogoHeader() {
const { subscriptions } = useLogin();
const currentSubscription = getCurrentSubscription(subscriptions);
return (
<Link to="/" className="logo">
<h1>Snort</h1>
{currentSubscription && (
<small className="flex">
<Icon name="diamond" size={10} className="mr5" />
{mapPlanName(currentSubscription.type)}
</small>
)}
</Link>
);
}

View File

@ -12,13 +12,14 @@ import Icon from "Icons/Icon";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { generateNewLogin, LoginSessionType, LoginStore } from "Login"; import { generateNewLogin, LoginSessionType, LoginStore } from "Login";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import useLoginHandler from "Hooks/useLoginHandler"; import useLoginHandler, { PinRequiredError } from "Hooks/useLoginHandler";
import { secp256k1 } from "@noble/curves/secp256k1"; import { secp256k1 } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/curves/abstract/utils"; import { bytesToHex } from "@noble/curves/abstract/utils";
import Modal from "Element/Modal"; import Modal from "Element/Modal";
import QrCode from "Element/QrCode"; import QrCode from "Element/QrCode";
import Copy from "Element/Copy"; import Copy from "Element/Copy";
import { delay } from "SnortUtils"; import { delay } from "SnortUtils";
import { PinPrompt } from "Element/PinPrompt";
declare global { declare global {
interface Window { interface Window {
@ -78,6 +79,7 @@ export default function LoginPage() {
const login = useLogin(); const login = useLogin();
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [pin, setPin] = useState(false);
const [art, setArt] = useState<ArtworkEntry>(); const [art, setArt] = useState<ArtworkEntry>();
const [isMasking, setMasking] = useState(true); const [isMasking, setMasking] = useState(true);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
@ -99,10 +101,13 @@ export default function LoginPage() {
setArt({ ...ret, link: url }); setArt({ ...ret, link: url });
}, []); }, []);
async function doLogin() { async function doLogin(pin?: string) {
try { try {
await loginHandler.doLogin(key); await loginHandler.doLogin(key, pin);
} catch (e) { } catch (e) {
if (e instanceof PinRequiredError) {
setPin(true);
}
if (e instanceof Error) { if (e instanceof Error) {
setError(e.message); setError(e.message);
} else { } else {
@ -116,10 +121,16 @@ export default function LoginPage() {
} }
} }
async function makeRandomKey() { async function makeRandomKey(pin: string) {
await generateNewLogin(); try {
window.plausible?.("Generate Account"); await generateNewLogin(pin);
navigate("/new"); window.plausible?.("Generate Account");
navigate("/new");
} catch (e) {
if (e instanceof Error) {
setError(e.message);
}
}
} }
async function doNip07Login() { async function doNip07Login() {
@ -157,7 +168,7 @@ export default function LoginPage() {
<FormattedMessage defaultMessage="Nostr Connect (NIP-46)" description="Login button for NIP-46 signer app" /> <FormattedMessage defaultMessage="Nostr Connect (NIP-46)" description="Login button for NIP-46 signer app" />
</AsyncButton> </AsyncButton>
{nostrConnect && ( {nostrConnect && (
<Modal onClose={() => setNostrConnect("")}> <Modal id="nostr-connect" onClose={() => setNostrConnect("")}>
<div className="flex f-col"> <div className="flex f-col">
<QrCode data={nostrConnect} /> <QrCode data={nostrConnect} />
<Copy text={nostrConnect} /> <Copy text={nostrConnect} />
@ -283,12 +294,19 @@ export default function LoginPage() {
/> />
</p> </p>
<div dir="auto" className="login-actions"> <div dir="auto" className="login-actions">
<AsyncButton type="button" onClick={doLogin}> <AsyncButton type="button" onClick={() => doLogin()}>
<FormattedMessage defaultMessage="Login" description="Login button" /> <FormattedMessage defaultMessage="Login" description="Login button" />
</AsyncButton> </AsyncButton>
<AsyncButton onClick={() => makeRandomKey()}> <AsyncButton onClick={() => setPin(true)}>
<FormattedMessage defaultMessage="Create Account" /> <FormattedMessage defaultMessage="Create Account" />
</AsyncButton> </AsyncButton>
{pin && <PinPrompt onResult={pin => {
if (key) {
doLogin(pin);
} else {
makeRandomKey(pin);
}
}} onCancel={() => setPin(false)} />}
{altLogins()} {altLogins()}
</div> </div>
{installExtension()} {installExtension()}

View File

@ -210,7 +210,7 @@ function NewChatWindow() {
<Icon name="plus" size={16} /> <Icon name="plus" size={16} />
</button> </button>
{show && ( {show && (
<Modal onClose={() => setShow(false)} className="new-chat-modal"> <Modal id="new-chat" onClose={() => setShow(false)} className="new-chat-modal">
<div className="flex-column g16"> <div className="flex-column g16">
<div className="flex f-space"> <div className="flex f-space">
<h2> <h2>

View File

@ -326,14 +326,14 @@ export default function ProfilePage() {
targets={ targets={
lnurl?.lnurl && id lnurl?.lnurl && id
? [ ? [
{ {
type: "lnurl", type: "lnurl",
value: lnurl?.lnurl, value: lnurl?.lnurl,
weight: 1, weight: 1,
name: user?.display_name || user?.name, name: user?.display_name || user?.name,
zap: { pubkey: id }, zap: { pubkey: id },
} as ZapTarget, } as ZapTarget,
] ]
: undefined : undefined
} }
show={showLnQr} show={showLnQr}
@ -447,7 +447,7 @@ export default function ProfilePage() {
<Icon name="qr" size={16} /> <Icon name="qr" size={16} />
</IconButton> </IconButton>
{showProfileQr && ( {showProfileQr && (
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}> <Modal id="profile-qr" className="qr-modal" onClose={() => setShowProfileQr(false)}>
<ProfileImage pubkey={id} /> <ProfileImage pubkey={id} />
<QrCode data={link} className="m10 align-center" /> <QrCode data={link} className="m10 align-center" />
<Copy text={link} className="align-center" /> <Copy text={link} className="align-center" />

View File

@ -5,7 +5,7 @@ import { mapEventToProfile } from "@snort/system";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import Logo from "Element/Logo"; import Logo from "Element/Logo";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { UserCache } from "Cache"; import { UserCache } from "Cache";
import AvatarEditor from "Element/AvatarEditor"; import AvatarEditor from "Element/AvatarEditor";

View File

@ -6,7 +6,7 @@ import { mapEventToProfile } from "@snort/system";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { System } from "index"; import { System } from "index";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { openFile } from "SnortUtils"; import { openFile } from "SnortUtils";
import useFileUpload from "Upload"; import useFileUpload from "Upload";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";

View File

@ -4,7 +4,7 @@ import { unixNowMs } from "@snort/shared";
import { randomSample } from "SnortUtils"; import { randomSample } from "SnortUtils";
import Relay from "Element/Relay"; import Relay from "Element/Relay";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { System } from "index"; import { System } from "index";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { setRelays } from "Login"; import { setRelays } from "Login";

View File

@ -5,7 +5,6 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import { LoginStore, logout } from "Login"; import { LoginStore, logout } from "Login";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { unwrap } from "SnortUtils";
import { getCurrentSubscription } from "Subscription"; import { getCurrentSubscription } from "Subscription";
import messages from "./messages"; import messages from "./messages";
@ -19,7 +18,7 @@ const SettingsIndex = () => {
const sub = getCurrentSubscription(LoginStore.allSubscriptions()); const sub = getCurrentSubscription(LoginStore.allSubscriptions());
function handleLogout() { function handleLogout() {
logout(unwrap(login.publicKey)); logout(login.id);
navigate("/"); navigate("/");
} }

View File

@ -4,7 +4,7 @@ import { LNURL } from "@snort/shared";
import { ApiHost } from "Const"; import { ApiHost } from "Const";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider"; import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
export default function LNForwardAddress({ handle }: { handle: ManageHandle }) { export default function LNForwardAddress({ handle }: { handle: ManageHandle }) {

View File

@ -3,7 +3,7 @@ import { FormattedMessage } from "react-intl";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ApiHost } from "Const"; import { ApiHost } from "Const";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider"; import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
export default function ListHandles() { export default function ListHandles() {

View File

@ -1,6 +1,6 @@
import { ApiHost } from "Const"; import { ApiHost } from "Const";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { ServiceError } from "Nip05/ServiceProvider"; import { ServiceError } from "Nip05/ServiceProvider";
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider"; import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
import { useState } from "react"; import { useState } from "react";

View File

@ -3,7 +3,7 @@ import { FormattedMessage } from "react-intl";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import PageSpinner from "Element/PageSpinner"; import PageSpinner from "Element/PageSpinner";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import SnortApi, { Subscription, SubscriptionError } from "SnortApi"; import SnortApi, { Subscription, SubscriptionError } from "SnortApi";
import { mapSubscriptionErrorCode } from "."; import { mapSubscriptionErrorCode } from ".";
import SubscriptionCard from "./SubscriptionCard"; import SubscriptionCard from "./SubscriptionCard";

View File

@ -5,7 +5,7 @@ import SnortApi, { Subscription, SubscriptionError } from "SnortApi";
import { mapPlanName, mapSubscriptionErrorCode } from "."; import { mapPlanName, mapSubscriptionErrorCode } from ".";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import SendSats from "Element/SendSats"; import SendSats from "Element/SendSats";
import Nip5Service from "Element/Nip5Service"; import Nip5Service from "Element/Nip5Service";
import { SnortNostrAddressService } from "Pages/NostrAddressPage"; import { SnortNostrAddressService } from "Pages/NostrAddressPage";

View File

@ -8,7 +8,7 @@ import { formatShort } from "Number";
import { LockedFeatures, Plans, SubscriptionType } from "Subscription"; import { LockedFeatures, Plans, SubscriptionType } from "Subscription";
import ManageSubscriptionPage from "Pages/subscribe/ManageSubscription"; import ManageSubscriptionPage from "Pages/subscribe/ManageSubscription";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import SnortApi, { SubscriptionError, SubscriptionErrorCode } from "SnortApi"; import SnortApi, { SubscriptionError, SubscriptionErrorCode } from "SnortApi";
import SendSats from "Element/SendSats"; import SendSats from "Element/SendSats";

View File

@ -1,91 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { NostrEvent, TaggedNostrEvent } from "@snort/system";
import { ZapTarget } from "Zapper";
interface NoteCreatorStore {
show: boolean;
note: string;
error: string;
active: boolean;
preview?: NostrEvent;
replyTo?: TaggedNostrEvent;
showAdvanced: boolean;
selectedCustomRelays: false | Array<string>;
zapSplits?: Array<ZapTarget>;
sensitive: string;
pollOptions?: Array<string>;
otherEvents: Array<NostrEvent>;
}
const InitState: NoteCreatorStore = {
show: false,
note: "",
error: "",
active: false,
showAdvanced: false,
selectedCustomRelays: false,
sensitive: "",
otherEvents: [],
};
const NoteCreatorSlice = createSlice({
name: "NoteCreator",
initialState: InitState,
reducers: {
setShow: (state, action: PayloadAction<boolean>) => {
state.show = action.payload;
},
setNote: (state, action: PayloadAction<string>) => {
state.note = action.payload;
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload;
},
setActive: (state, action: PayloadAction<boolean>) => {
state.active = action.payload;
},
setPreview: (state, action: PayloadAction<NostrEvent | undefined>) => {
state.preview = action.payload;
},
setReplyTo: (state, action: PayloadAction<TaggedNostrEvent | undefined>) => {
state.replyTo = action.payload;
},
setShowAdvanced: (state, action: PayloadAction<boolean>) => {
state.showAdvanced = action.payload;
},
setSelectedCustomRelays: (state, action: PayloadAction<false | Array<string>>) => {
state.selectedCustomRelays = action.payload;
},
setSensitive: (state, action: PayloadAction<string>) => {
state.sensitive = action.payload;
},
setPollOptions: (state, action: PayloadAction<Array<string> | undefined>) => {
state.pollOptions = action.payload;
},
setOtherEvents: (state, action: PayloadAction<Array<NostrEvent>>) => {
state.otherEvents = action.payload;
},
setZapSplits: (state, action: PayloadAction<Array<ZapTarget>>) => {
state.zapSplits = action.payload;
},
reset: () => InitState,
},
});
export const {
setShow,
setNote,
setError,
setActive,
setPreview,
setReplyTo,
setShowAdvanced,
setSelectedCustomRelays,
setZapSplits,
setSensitive,
setPollOptions,
setOtherEvents,
reset,
} = NoteCreatorSlice.actions;
export const reducer = NoteCreatorSlice.reducer;

View File

@ -0,0 +1,80 @@
import { ExternalStore } from "@snort/shared";
import { NostrEvent, TaggedNostrEvent } from "@snort/system";
import { ZapTarget } from "Zapper";
import { useSyncExternalStore } from "react";
interface NoteCreatorDataSnapshot {
show: boolean;
note: string;
error: string;
active: boolean;
advanced: boolean;
preview?: NostrEvent;
replyTo?: TaggedNostrEvent;
selectedCustomRelays?: Array<string>;
zapSplits?: Array<ZapTarget>;
sensitive?: string;
pollOptions?: Array<string>;
otherEvents?: Array<NostrEvent>;
reset: () => void;
update: (fn: (v: NoteCreatorDataSnapshot) => void) => void;
}
class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
#data: NoteCreatorDataSnapshot;
constructor() {
super();
this.#data = {
show: false,
note: "",
error: "",
active: false,
advanced: false,
reset: () => {
this.#reset(this.#data);
this.notifyChange();
},
update: (fn: (v: NoteCreatorDataSnapshot) => void) => {
fn(this.#data);
this.notifyChange();
}
};
}
#reset(d: NoteCreatorDataSnapshot) {
d.show = false;
d.note = "";
d.error = "";
d.active = false;
d.advanced = false;
d.preview = undefined;
d.replyTo = undefined;
d.selectedCustomRelays = undefined;
d.zapSplits = undefined;
d.sensitive = undefined;
d.pollOptions = undefined;
d.otherEvents = undefined;
}
takeSnapshot(): NoteCreatorDataSnapshot {
const sn = {
...this.#data,
reset: () => {
this.#reset(this.#data);
},
update: (fn: (v: NoteCreatorDataSnapshot) => void) => {
fn(this.#data);
console.debug(this.#data);
this.notifyChange();
}
} as NoteCreatorDataSnapshot;
return sn;
}
}
const NoteCreatorState = new NoteCreatorStore();
export function useNoteCreator() {
return useSyncExternalStore(c => NoteCreatorState.hook(c), () => NoteCreatorState.snapshot());
}

View File

@ -1,34 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { NostrEvent } from "@snort/system";
interface ReBroadcastStore {
show: boolean;
selectedCustomRelays: false | Array<string>;
note?: NostrEvent;
}
const InitState: ReBroadcastStore = {
show: false,
selectedCustomRelays: false,
};
const ReBroadcastSlice = createSlice({
name: "ReBroadcast",
initialState: InitState,
reducers: {
setShow: (state, action: PayloadAction<boolean>) => {
state.show = action.payload;
},
setNote: (state, action: PayloadAction<NostrEvent>) => {
state.note = action.payload;
},
setSelectedCustomRelays: (state, action: PayloadAction<false | Array<string>>) => {
state.selectedCustomRelays = action.payload;
},
reset: () => InitState,
},
});
export const { setShow, setNote, setSelectedCustomRelays, reset } = ReBroadcastSlice.actions;
export const reducer = ReBroadcastSlice.reducer;

View File

@ -1,15 +0,0 @@
import { configureStore } from "@reduxjs/toolkit";
import { reducer as NoteCreatorReducer } from "State/NoteCreator";
import { reducer as ReBroadcastReducer } from "State/ReBroadcast";
const store = configureStore({
reducer: {
noteCreator: NoteCreatorReducer,
reBroadcast: ReBroadcastReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;

View File

@ -7,7 +7,6 @@ import WasmPath from "@snort/system-query/pkg/system_query_bg.wasm";
import { StrictMode } from "react"; import { StrictMode } from "react";
import * as ReactDOM from "react-dom/client"; import * as ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { import {
EventPublisher, EventPublisher,
@ -24,7 +23,6 @@ import { SnortContext } from "@snort/system-react";
import * as serviceWorkerRegistration from "serviceWorkerRegistration"; import * as serviceWorkerRegistration from "serviceWorkerRegistration";
import { IntlProvider } from "IntlProvider"; import { IntlProvider } from "IntlProvider";
import { unwrap } from "SnortUtils"; import { unwrap } from "SnortUtils";
import Store from "State/Store";
import Layout from "Pages/Layout"; import Layout from "Pages/Layout";
import LoginPage from "Pages/LoginPage"; import LoginPage from "Pages/LoginPage";
import ProfilePage from "Pages/ProfilePage"; import ProfilePage from "Pages/ProfilePage";
@ -218,12 +216,10 @@ export const router = createBrowserRouter([
const root = ReactDOM.createRoot(unwrap(document.getElementById("root"))); const root = ReactDOM.createRoot(unwrap(document.getElementById("root")));
root.render( root.render(
<StrictMode> <StrictMode>
<Provider store={Store}> <IntlProvider>
<IntlProvider> <SnortContext.Provider value={System}>
<SnortContext.Provider value={System}> <RouterProvider router={router} />
<RouterProvider router={router} /> </SnortContext.Provider>
</SnortContext.Provider> </IntlProvider>
</IntlProvider>
</Provider>
</StrictMode>, </StrictMode>,
); );

View File

@ -9,7 +9,7 @@ export interface HookFilter<TSnapshot> {
*/ */
export abstract class ExternalStore<TSnapshot> { export abstract class ExternalStore<TSnapshot> {
#hooks: Array<HookFilter<TSnapshot>> = []; #hooks: Array<HookFilter<TSnapshot>> = [];
#snapshot: Readonly<TSnapshot> = {} as Readonly<TSnapshot>; #snapshot: TSnapshot = {} as TSnapshot;
#changed = true; #changed = true;
hook(fn: HookFn<TSnapshot>) { hook(fn: HookFn<TSnapshot>) {
@ -35,7 +35,9 @@ export abstract class ExternalStore<TSnapshot> {
protected notifyChange(sn?: TSnapshot) { protected notifyChange(sn?: TSnapshot) {
this.#changed = true; this.#changed = true;
if (this.#hooks.length > 0) { if (this.#hooks.length > 0) {
this.#hooks.forEach(h => h.fn(sn)); queueMicrotask(() => {
this.#hooks.forEach(h => h.fn(sn));
});
} }
} }

View File

@ -0,0 +1,70 @@
import { pbkdf2 } from "@noble/hashes/pbkdf2";
import { sha256 } from '@noble/hashes/sha256';
import {hmac} from "@noble/hashes/hmac";
import { bytesToHex, hexToBytes, randomBytes } from "@noble/hashes/utils";
import { base64 } from "@scure/base";
import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
export class InvalidPinError extends Error {
constructor(){
super();
}
}
/**
* Pin protected data
*/
export class PinEncrypted {
static readonly #opts = {c: 32, dkLen: 32}
#decrypted?: Uint8Array
#encrypted: PinEncryptedPayload
constructor(enc: PinEncryptedPayload) {
this.#encrypted = enc;
}
get value() {
if(!this.#decrypted) throw new Error("Content has not been decrypted yet");
return bytesToHex(this.#decrypted);
}
decrypt(pin: string) {
const key = pbkdf2(sha256, pin, base64.decode(this.#encrypted.salt), PinEncrypted.#opts);
const ciphertext = base64.decode(this.#encrypted.ciphertext);
const nonce = base64.decode(this.#encrypted.iv);
const plaintext = xchacha20(key, nonce, ciphertext, ciphertext);
if(plaintext.length !== 32) throw new InvalidPinError();
const mac = base64.encode(hmac(sha256, key, plaintext));
if(mac !== this.#encrypted.mac) throw new InvalidPinError();
this.#decrypted = plaintext;
}
toPayload() {
return this.#encrypted;
}
static create(content: string, pin: string) {
const salt = randomBytes(24);
const nonce = randomBytes(24);
const plaintext = hexToBytes(content);
const key = pbkdf2(sha256, pin, salt, PinEncrypted.#opts);
const mac = base64.encode(hmac(sha256, key, plaintext));
const ciphertext = xchacha20(key, nonce, plaintext, plaintext);
const ret = new PinEncrypted({
salt: base64.encode(salt),
ciphertext: base64.encode(ciphertext),
iv: base64.encode(nonce),
mac
});
ret.#decrypted = plaintext;
return ret;
}
}
export interface PinEncryptedPayload {
salt: string, // for KDF
ciphertext: string
iv: string,
mac: string
}

View File

@ -185,8 +185,9 @@ export class EventPublisher {
const thread = EventExt.extractThread(replyTo); const thread = EventExt.extractThread(replyTo);
if (thread) { if (thread) {
if (thread.root || thread.replyTo) { const rootOrReplyAsRoot = thread.root || thread.replyTo;
eb.tag(["e", thread.root?.value ?? thread.replyTo?.value ?? "", "", "root"]); if (rootOrReplyAsRoot) {
eb.tag(["e", rootOrReplyAsRoot?.value ?? "", rootOrReplyAsRoot?.relay ?? "", "root"]);
} }
eb.tag(["e", replyTo.id, replyTo.relays?.[0] ?? "", "reply"]); eb.tag(["e", replyTo.id, replyTo.relays?.[0] ?? "", "reply"]);

View File

@ -1,6 +1,5 @@
import { MessageEncryptor, MessageEncryptorPayload, MessageEncryptorVersion } from "index"; import { MessageEncryptor, MessageEncryptorPayload, MessageEncryptorVersion } from "index";
import { base64 } from "@scure/base";
import { randomBytes } from "@noble/hashes/utils"; import { randomBytes } from "@noble/hashes/utils";
import { streamXOR as xchacha20 } from "@stablelib/xchacha20"; import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
import { secp256k1 } from "@noble/curves/secp256k1"; import { secp256k1 } from "@noble/curves/secp256k1";

View File

@ -27,6 +27,7 @@ export * from "./text";
export * from "./pow"; export * from "./pow";
export * from "./pow-util"; export * from "./pow-util";
export * from "./query-optimizer"; export * from "./query-optimizer";
export * from "./encrypted";
export * from "./impl/nip4"; export * from "./impl/nip4";
export * from "./impl/nip44"; export * from "./impl/nip44";

View File

@ -300,7 +300,7 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
{ {
filters: [{ ...f, ids: [...resultIds] }], filters: [{ ...f, ids: [...resultIds] }],
strategy: RequestStrategy.ExplicitRelays, strategy: RequestStrategy.ExplicitRelays,
relay: "", relay: qSend.relay,
}, },
cacheResults as Array<TaggedNostrEvent>, cacheResults as Array<TaggedNostrEvent>,
); );

View File

@ -202,7 +202,7 @@ export class Query implements QueryBase {
*/ */
insertCompletedTrace(subq: BuiltRawReqFilter, data: Readonly<Array<TaggedNostrEvent>>) { insertCompletedTrace(subq: BuiltRawReqFilter, data: Readonly<Array<TaggedNostrEvent>>) {
const qt = new QueryTrace( const qt = new QueryTrace(
"", subq.relay,
subq.filters, subq.filters,
"", "",
() => { () => {

View File

@ -113,7 +113,7 @@ export class RequestBuilder {
const diff = system.QueryOptimizer.getDiff(prev, this.buildRaw()); const diff = system.QueryOptimizer.getDiff(prev, this.buildRaw());
const ts = unixNowMs() - start; const ts = unixNowMs() - start;
this.#log("buildDiff %s %d ms +%d %O=>%O", this.id, ts, diff.length, prev, this.buildRaw()); this.#log("buildDiff %s %d ms +%d", this.id, ts, diff.length);
if (diff.length > 0) { if (diff.length > 0) {
return splitFlatByWriteRelays(system.RelayCache, diff).map(a => { return splitFlatByWriteRelays(system.RelayCache, diff).map(a => {
return { return {

View File

@ -6,19 +6,8 @@ import { ReqFilter } from "nostr";
export function trimFilters(filters: Array<ReqFilter>) { export function trimFilters(filters: Array<ReqFilter>) {
const fNew = []; const fNew = [];
for (const f of filters) { for (const f of filters) {
let arrays = 0; const ent = Object.entries(f).filter(([,v]) => Array.isArray(v));
for (const [k, v] of Object.entries(f)) { if(ent.every(([,v]) => (v as Array<string | number>).length > 0)) {
if (Array.isArray(v)) {
arrays++;
if (v.length === 0) {
delete f[k];
}
}
}
if (arrays > 0 && Object.entries(f).some(v => Array.isArray(v))) {
fNew.push(f);
} else if (arrays === 0) {
fNew.push(f); fNew.push(f);
} }
} }

104
yarn.lock
View File

@ -1376,7 +1376,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": "@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.8.4":
version: 7.22.11 version: 7.22.11
resolution: "@babel/runtime@npm:7.22.11" resolution: "@babel/runtime@npm:7.22.11"
dependencies: dependencies:
@ -2507,26 +2507,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@reduxjs/toolkit@npm:^1.9.1":
version: 1.9.5
resolution: "@reduxjs/toolkit@npm:1.9.5"
dependencies:
immer: ^9.0.21
redux: ^4.2.1
redux-thunk: ^2.4.2
reselect: ^4.1.8
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18
react-redux: ^7.2.1 || ^8.0.2
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
checksum: 54672c5593d05208af577e948a338f23128d3aa01ef056ab0d40bcfa14400cf6566be99e11715388f12c1d7655cdf7c5c6b63cb92eb0fecf996c454a46a3914c
languageName: node
linkType: hard
"@remix-run/router@npm:1.8.0": "@remix-run/router@npm:1.8.0":
version: 1.8.0 version: 1.8.0
resolution: "@remix-run/router@npm:1.8.0" resolution: "@remix-run/router@npm:1.8.0"
@ -2705,7 +2685,6 @@ __metadata:
"@lightninglabs/lnc-web": ^0.2.3-alpha "@lightninglabs/lnc-web": ^0.2.3-alpha
"@noble/curves": ^1.0.0 "@noble/curves": ^1.0.0
"@noble/hashes": ^1.2.0 "@noble/hashes": ^1.2.0
"@reduxjs/toolkit": ^1.9.1
"@scure/base": ^1.1.1 "@scure/base": ^1.1.1
"@scure/bip32": ^1.3.0 "@scure/bip32": ^1.3.0
"@scure/bip39": ^1.1.1 "@scure/bip39": ^1.1.1
@ -2751,7 +2730,6 @@ __metadata:
react-dom: ^18.2.0 react-dom: ^18.2.0
react-intersection-observer: ^9.4.1 react-intersection-observer: ^9.4.1
react-intl: ^6.4.4 react-intl: ^6.4.4
react-redux: ^8.0.5
react-router-dom: ^6.5.0 react-router-dom: ^6.5.0
react-textarea-autosize: ^8.4.0 react-textarea-autosize: ^8.4.0
react-twitter-embed: ^4.0.4 react-twitter-embed: ^4.0.4
@ -3590,13 +3568,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/use-sync-external-store@npm:^0.0.3":
version: 0.0.3
resolution: "@types/use-sync-external-store@npm:0.0.3"
checksum: 161ddb8eec5dbe7279ac971531217e9af6b99f7783213566d2b502e2e2378ea19cf5e5ea4595039d730aa79d3d35c6567d48599f69773a02ffcff1776ec2a44e
languageName: node
linkType: hard
"@types/uuid@npm:^9.0.2": "@types/uuid@npm:^9.0.2":
version: 9.0.2 version: 9.0.2
resolution: "@types/uuid@npm:9.0.2" resolution: "@types/uuid@npm:9.0.2"
@ -7614,13 +7585,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"immer@npm:^9.0.21":
version: 9.0.21
resolution: "immer@npm:9.0.21"
checksum: 70e3c274165995352f6936695f0ef4723c52c92c92dd0e9afdfe008175af39fa28e76aafb3a2ca9d57d1fb8f796efc4dd1e1cc36f18d33fa5b74f3dfb0375432
languageName: node
linkType: hard
"import-fresh@npm:^3.2.1": "import-fresh@npm:^3.2.1":
version: 3.3.0 version: 3.3.0
resolution: "import-fresh@npm:3.3.0" resolution: "import-fresh@npm:3.3.0"
@ -11273,38 +11237,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-redux@npm:^8.0.5":
version: 8.1.2
resolution: "react-redux@npm:8.1.2"
dependencies:
"@babel/runtime": ^7.12.1
"@types/hoist-non-react-statics": ^3.3.1
"@types/use-sync-external-store": ^0.0.3
hoist-non-react-statics: ^3.3.2
react-is: ^18.0.0
use-sync-external-store: ^1.0.0
peerDependencies:
"@types/react": ^16.8 || ^17.0 || ^18.0
"@types/react-dom": ^16.8 || ^17.0 || ^18.0
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
react-native: ">=0.59"
redux: ^4 || ^5.0.0-beta.0
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
react-dom:
optional: true
react-native:
optional: true
redux:
optional: true
checksum: 4d5976b0f721e4148475871fcabce2fee875cc7f70f9a292f3370d63b38aa1dd474eb303c073c5555f3e69fc732f3bac05303def60304775deb28361e3f4b7cc
languageName: node
linkType: hard
"react-router-dom@npm:^6.5.0": "react-router-dom@npm:^6.5.0":
version: 6.15.0 version: 6.15.0
resolution: "react-router-dom@npm:6.15.0" resolution: "react-router-dom@npm:6.15.0"
@ -11522,24 +11454,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"redux-thunk@npm:^2.4.2":
version: 2.4.2
resolution: "redux-thunk@npm:2.4.2"
peerDependencies:
redux: ^4
checksum: c7f757f6c383b8ec26152c113e20087818d18ed3edf438aaad43539e9a6b77b427ade755c9595c4a163b6ad3063adf3497e5fe6a36c68884eb1f1cfb6f049a5c
languageName: node
linkType: hard
"redux@npm:^4.2.1":
version: 4.2.1
resolution: "redux@npm:4.2.1"
dependencies:
"@babel/runtime": ^7.9.2
checksum: f63b9060c3a1d930ae775252bb6e579b42415aee7a23c4114e21a0b4ba7ec12f0ec76936c00f546893f06e139819f0e2855e0d55ebfce34ca9c026241a6950dd
languageName: node
linkType: hard
"regenerate-unicode-properties@npm:^10.1.0": "regenerate-unicode-properties@npm:^10.1.0":
version: 10.1.0 version: 10.1.0
resolution: "regenerate-unicode-properties@npm:10.1.0" resolution: "regenerate-unicode-properties@npm:10.1.0"
@ -11670,13 +11584,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"reselect@npm:^4.1.8":
version: 4.1.8
resolution: "reselect@npm:4.1.8"
checksum: a4ac87cedab198769a29be92bc221c32da76cfdad6911eda67b4d3e7136dca86208c3b210e31632eae31ebd2cded18596f0dd230d3ccc9e978df22f233b5583e
languageName: node
linkType: hard
"resolve-cwd@npm:^3.0.0": "resolve-cwd@npm:^3.0.0":
version: 3.0.0 version: 3.0.0
resolution: "resolve-cwd@npm:3.0.0" resolution: "resolve-cwd@npm:3.0.0"
@ -13424,15 +13331,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"use-sync-external-store@npm:^1.0.0":
version: 1.2.0
resolution: "use-sync-external-store@npm:1.2.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 5c639e0f8da3521d605f59ce5be9e094ca772bd44a4ce7322b055a6f58eeed8dda3c94cabd90c7a41fb6fa852210092008afe48f7038792fd47501f33299116a
languageName: node
linkType: hard
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": "util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
version: 1.0.2 version: 1.0.2
resolution: "util-deprecate@npm:1.0.2" resolution: "util-deprecate@npm:1.0.2"