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",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.2.0",
"@reduxjs/toolkit": "^1.9.1",
"@scure/base": "^1.1.1",
"@scure/bip32": "^1.3.0",
"@scure/bip39": "^1.1.1",
@ -27,7 +26,6 @@
"react-dom": "^18.2.0",
"react-intersection-observer": "^9.4.1",
"react-intl": "^6.4.4",
"react-redux": "^8.0.5",
"react-router-dom": "^6.5.0",
"react-textarea-autosize": "^8.4.0",
"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 { findTag, unwrap } from "SnortUtils";
import { RefreshFeedCache } from "./RefreshFeedCache";
import { LoginSession } from "Login";
import { LoginSession, LoginSessionType } from "Login";
export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
constructor() {
@ -15,7 +15,7 @@ export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
buildSub(session: LoginSession, rb: RequestBuilder): void {
const pubkey = session.publicKey;
if (pubkey) {
if (pubkey && session.type === LoginSessionType.PrivateKey) {
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>
{showModal && (
<Modal className="reactions-modal" onClose={() => setShowModal(false)}>
<Modal id="badges" className="reactions-modal" onClose={() => setShowModal(false)}>
<div className="reactions-view">
<div className="close" onClick={() => setShowModal(false)}>
<Icon name="close" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@ import AsyncButton from "Element/AsyncButton";
import SendSats from "Element/SendSats";
import Copy from "Element/Copy";
import { useUserProfile } from "@snort/system-react";
import useEventPublisher from "Feed/EventPublisher";
import useEventPublisher from "Hooks/useEventPublisher";
import { debounce } from "SnortUtils";
import useLogin from "Hooks/useLogin";
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 { System } from "index";
import useEventPublisher from "Feed/EventPublisher";
import useEventPublisher from "Hooks/useEventPublisher";
import Icon from "Icons/Icon";
import ProfileImage from "Element/ProfileImage";
import Text from "Element/Text";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { NostrPrefix, NostrEvent, NostrLink } from "@snort/system";
import useEventPublisher from "Feed/EventPublisher";
import useEventPublisher from "Hooks/useEventPublisher";
import Icon from "Icons/Icon";
import Spinner from "Icons/Spinner";
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 { makeNotification, sendNotification } from "Notifications";
import useEventPublisher from "Feed/EventPublisher";
import useEventPublisher from "Hooks/useEventPublisher";
import { getMutedKeys } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
import useLogin from "Hooks/useLogin";
@ -28,9 +28,7 @@ export default function useLoginFeed() {
useRefreshFeedCache(Notifications, true);
useRefreshFeedCache(FollowsFeed, true);
if (publisher?.supports("nip44")) {
useRefreshFeedCache(GiftsCache, true);
}
useRefreshFeedCache(GiftsCache, true);
const subLogin = useMemo(() => {
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 useLogin from "Hooks/useLogin";
import { useMemo } from "react";
@ -8,10 +8,8 @@ export function useReactions(subId: string, ids: Array<NostrLink>, others?: (rb:
const sub = useMemo(() => {
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
.withFilter()
.kinds(
@ -20,8 +18,7 @@ export function useReactions(subId: string, ids: Array<NostrLink>, others?: (rb:
: [EventKind.ZapReceipt, EventKind.Repost],
);
aTags.forEach(v => f.replyToLink(v));
eTags.forEach(v => f.replyToLink(v));
ids.forEach(v => f.replyToLink(v));
}
others?.(rb);
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 { useRequestBuilder } from "@snort/system-react";
import { useReactions } from "./FeedReactions";
import { useReactions } from "./Reactions";
export default function useThreadFeed(link: NostrLink) {
const [allEvents, setAllEvents] = useState<Array<NostrLink>>([]);
@ -12,9 +12,10 @@ export default function useThreadFeed(link: NostrLink) {
sub.withOptions({
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 => {
sub.withFilter().kinds([EventKind.TextNote]).link(x).replyToLink(x);
sub.withFilter().kinds([EventKind.TextNote]).replyToLink(x);
});
return sub;
}, [allEvents.length]);
@ -23,8 +24,7 @@ export default function useThreadFeed(link: NostrLink) {
useEffect(() => {
if (store.data) {
const mainNotes = store.data?.filter(a => a.kind === EventKind.TextNote || a.kind === EventKind.Polls) ?? [];
const links = mainNotes
const links = store.data
.map(a => [
NostrLink.fromEvent(a),
...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 { Nip46Signer, PinEncrypted } from "@snort/system";
import { EmailRegex, MnemonicRegex } from "Const";
import { LoginSessionType, LoginStore } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { getNip05PubKey } from "Pages/LoginPage";
import { bech32ToHex } from "SnortUtils";
import { Nip46Signer } from "@snort/system";
export class PinRequiredError extends Error { }
export default function useLoginHandler() {
const { formatMessage } = useIntl();
const hasSubtleCrypto = window.crypto.subtle !== undefined;
async function doLogin(key: string) {
async function doLogin(key: string, pin?: string) {
const insecureMsg = formatMessage({
defaultMessage:
"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);
if (hexKey.length === 64) {
LoginStore.loginWithPrivateKey(hexKey);
if (!pin) throw new PinRequiredError();
LoginStore.loginWithPrivateKey(PinEncrypted.create(hexKey, pin));
} else {
throw new Error("INVALID PRIVATE KEY");
}
@ -31,14 +34,16 @@ export default function useLoginHandler() {
if (!hasSubtleCrypto) {
throw new Error(insecureMsg);
}
if (!pin) throw new PinRequiredError();
const ent = generateBip39Entropy(key);
const keyHex = entropyToPrivateKey(ent);
LoginStore.loginWithPrivateKey(keyHex);
LoginStore.loginWithPrivateKey(PinEncrypted.create(keyHex, pin));
} else if (key.length === 64) {
if (!hasSubtleCrypto) {
throw new Error(insecureMsg);
}
LoginStore.loginWithPrivateKey(key);
if (!pin) throw new PinRequiredError();
LoginStore.loginWithPrivateKey(PinEncrypted.create(key, pin));
}
// public key logins

View File

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

View File

@ -1,17 +1,18 @@
import { SnortContext } from "@snort/system-react";
import { useContext, useEffect, useMemo } from "react";
import { NoopStore, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { unwrap } from "@snort/shared";
import { RefreshFeedCache } from "Cache/RefreshFeedCache";
import useLogin from "./useLogin";
import useEventPublisher from "./useEventPublisher";
export function useRefreshFeedCache<T>(c: RefreshFeedCache<T>, leaveOpen = false) {
const system = useContext(SnortContext);
const login = useLogin();
const publisher = useEventPublisher();
const sub = useMemo(() => {
if (login) {
if (login.publicKey) {
const rb = new RequestBuilder(`using-${c.name}`);
rb.withOptions({
leaveOpen,
@ -28,11 +29,11 @@ export function useRefreshFeedCache<T>(c: RefreshFeedCache<T>, leaveOpen = false
let t: ReturnType<typeof setTimeout> | undefined;
let tBuf: Array<TaggedNostrEvent> = [];
const releaseOnEvent = q.feed.onEvent(evs => {
if (!t) {
if (!t && publisher) {
tBuf = [...evs];
t = setTimeout(() => {
t = undefined;
c.onEvent(tBuf, unwrap(login.publisher));
c.onEvent(tBuf, publisher);
}, 100);
} else {
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 * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
import { DefaultRelays, SnortPubKey } from "Const";
import { LoginStore, UserPreferences, LoginSession } from "Login";
import { LoginStore, UserPreferences, LoginSession, LoginSessionType } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unwrap } from "SnortUtils";
import { SubscriptionEvent } from "Subscription";
import { System } from "index";
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) {
if (state.relays.timestamp >= createdAt) {
@ -41,8 +43,8 @@ export function updatePreferences(state: LoginSession, p: UserPreferences) {
LoginStore.updateSession(state);
}
export function logout(k: HexKey) {
LoginStore.removeSession(k);
export function logout(id: string) {
LoginStore.removeSession(id);
GiftsCache.clear();
Notifications.clear();
FollowsFeed.clear();
@ -62,7 +64,7 @@ export function clearEntropy(state: LoginSession) {
/**
* Generate a new key and login with this generated key
*/
export async function generateNewLogin() {
export async function generateNewLogin(pin: string) {
const ent = generateBip39Entropy();
const entropy = utils.bytesToHex(ent);
const privateKey = entropyToPrivateKey(ent);
@ -88,7 +90,9 @@ export async function generateNewLogin() {
const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey], newRelays);
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() {
@ -161,3 +165,38 @@ export function addSubscription(state: LoginSession, ...subs: SubscriptionEvent[
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 { SubscriptionEvent } from "Subscription";
@ -19,6 +19,11 @@ export enum LoginSessionType {
}
export interface LoginSession {
/**
* Unique ID to identify this session
*/
id: string;
/**
* Type of login session
*/
@ -26,9 +31,15 @@ export interface LoginSession {
/**
* Current user private key
* @deprecated Moving to pin encrypted storage
*/
privateKey?: HexKey;
/**
* Encrypted private key
*/
privateKeyData?: PinEncrypted | PinEncryptedPayload;
/**
* BIP39-generated, hex-encoded entropy
*/
@ -98,9 +109,4 @@ export interface LoginSession {
* Remote signer relays (NIP-46)
*/
remoteSignerRelays?: Array<string>;
/**
* Instance event publisher
*/
publisher?: EventPublisher;
}

View File

@ -1,16 +1,17 @@
import * as secp from "@noble/curves/secp256k1";
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 { DefaultRelays } from "Const";
import { LoginSession, LoginSessionType } from "Login";
import { DefaultPreferences, UserPreferences } from "./Preferences";
import { Nip7OsSigner } from "./Nip7OsSigner";
import { LoginSession, LoginSessionType, createPublisher } from "Login";
import { DefaultPreferences } from "./Preferences";
const AccountStoreKey = "sessions";
const LoggedOut = {
id: "default",
type: "public_key",
preferences: DefaultPreferences,
tags: {
@ -45,25 +46,18 @@ const LoggedOut = {
readNotifications: 0,
subscriptions: [],
} 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> {
#activeAccount?: HexKey;
#accounts: Map<string, LoginSession>;
#publishers = new Map<string, EventPublisher>();
constructor() {
super();
const existing = window.localStorage.getItem(AccountStoreKey);
if (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 {
this.#accounts = new Map();
}
@ -71,26 +65,32 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (!this.#activeAccount) {
this.#activeAccount = this.#accounts.keys().next().value;
}
for (const [, v] of this.#accounts) {
v.publisher = this.#createPublisher(v);
}
}
getSessions() {
return [...this.#accounts.keys()];
return [...this.#accounts.values()].map(v => unwrap(v.publicKey));
}
allSubscriptions() {
return [...this.#accounts.values()].map(a => a.subscriptions).flat();
}
switchAccount(pk: string) {
if (this.#accounts.has(pk)) {
this.#activeAccount = pk;
switchAccount(id: string) {
if (this.#accounts.has(id)) {
this.#activeAccount = id;
this.#save();
}
}
getPublisher(id: string) {
return this.#publishers.get(id);
}
setPublisher(id: string, pub: EventPublisher) {
this.#publishers.set(id, pub);
this.notifyChange();
}
loginWithPubkey(
key: HexKey,
type: LoginSessionType,
@ -104,6 +104,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
const initRelays = this.decideInitRelays(relays);
const newSession = {
...LoggedOut,
id: uuid(),
type,
publicKey: key,
relays: {
@ -114,10 +115,13 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
remoteSignerRelays,
privateKey,
} as LoginSession;
newSession.publisher = this.#createPublisher(newSession);
this.#accounts.set(key, newSession);
this.#activeAccount = key;
const pub = createPublisher(newSession);
if(pub) {
this.setPublisher(newSession.id, pub);
}
this.#accounts.set(newSession.id, newSession);
this.#activeAccount = newSession.id;
this.#save();
return newSession;
}
@ -129,16 +133,17 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
return Object.fromEntries(DefaultRelays.entries());
}
loginWithPrivateKey(key: HexKey, entropy?: string, relays?: Record<string, RelaySettings>) {
const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(key));
loginWithPrivateKey(key: PinEncrypted, entropy?: string, relays?: Record<string, RelaySettings>) {
const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(key.value));
if (this.#accounts.has(pubKey)) {
throw new Error("Already logged in with this pubkey");
}
const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries());
const newSession = {
...LoggedOut,
id: uuid(),
type: LoginSessionType.PrivateKey,
privateKey: key,
privateKeyData: key,
publicKey: pubKey,
generatedEntropy: entropy,
relays: {
@ -149,30 +154,30 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
} as LoginSession;
if ("nostr_os" in window && window.nostr_os) {
window.nostr_os.saveKey(key);
window.nostr_os.saveKey(key.value);
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.#activeAccount = pubKey;
this.#accounts.set(newSession.id, newSession);
this.#activeAccount = newSession.id;
this.#save();
return newSession;
}
updateSession(s: LoginSession) {
const pk = unwrap(s.publicKey);
if (this.#accounts.has(pk)) {
this.#accounts.set(pk, s);
if (this.#accounts.has(s.id)) {
this.#accounts.set(s.id, s);
console.debug("SET SESSION", s);
this.#save();
}
}
removeSession(k: string) {
if (this.#accounts.delete(k)) {
if (this.#activeAccount === k) {
removeSession(id: string) {
if (this.#accounts.delete(id)) {
if (this.#activeAccount === id) {
this.#activeAccount = undefined;
}
this.#save();
@ -183,65 +188,11 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
const s = this.#activeAccount ? this.#accounts.get(this.#activeAccount) : undefined;
if (!s) return LoggedOut;
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);
}
}
}
return {...s};
}
#migrate() {
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
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) {
console.debug("Finished migration to MultiAccountStore");
this.#save();
@ -267,9 +226,16 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
#save() {
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();
}
}

View File

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

View File

@ -70,7 +70,7 @@ export function SnortDeckLayout() {
</div>
{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}>
<SpotlightFromThread onClose={() => deckScope.setThread(undefined)} />
<div>

View File

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

View File

@ -1,6 +1,5 @@
import "./Layout.css";
import { useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import { useUserProfile } from "@snort/system-react";
@ -9,8 +8,6 @@ import { NostrLink, NostrPrefix, tryParseNostrLink } from "@snort/system";
import messages from "./messages";
import Icon from "Icons/Icon";
import { RootState } from "State/Store";
import { setShow, reset } from "State/NoteCreator";
import useLoginFeed from "Feed/LoginFeed";
import { NoteCreator } from "Element/NoteCreator";
import { mapPlanName } from "./subscribe";
@ -23,29 +20,19 @@ import Spinner from "Icons/Spinner";
import { fetchNip05Pubkey } from "Nip05/Verifier";
import { useTheme } from "Hooks/useTheme";
import { useLoginRelays } from "Hooks/useLoginRelays";
import { useNoteCreator } from "State/NoteCreator";
import { LoginUnlock } from "Element/PinPrompt";
export default function Layout() {
const location = useLocation();
const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo);
const isNoteCreatorShowing = useSelector((s: RootState) => s.noteCreator.show);
const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing;
const dispatch = useDispatch();
const navigate = useNavigate();
const { publicKey, subscriptions } = useLogin();
const currentSubscription = getCurrentSubscription(subscriptions);
const note = useNoteCreator();
const isReplyNoteCreatorShowing = note.replyTo && note.show;
const [pageClass, setPageClass] = useState("page");
useLoginFeed();
useTheme();
useLoginRelays();
const handleNoteCreatorButtonClick = () => {
if (replyTo) {
dispatch(reset());
}
dispatch(setShow(true));
};
const shouldHideNoteCreator = useMemo(() => {
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/e", "/subscribe"];
return isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a));
@ -66,34 +53,22 @@ export default function Layout() {
}
}, [location]);
return (
return (<>
<div className={pageClass}>
{!shouldHideHeader && (
<header className="main-content">
<Link to="/" className="logo">
<h1>Snort</h1>
{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>
)}
<LogoHeader />
<AccountHeader />
</header>
)}
<Outlet />
{!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} />
</button>
<NoteCreator />
@ -101,6 +76,8 @@ export default function Layout() {
)}
<Toaster />
</div>
<LoginUnlock />
</>
);
}
@ -156,6 +133,13 @@ const AccountHeader = () => {
}
}
if (!publicKey) {
return (
<button type="button" onClick={() => navigate("/login")}>
<FormattedMessage {...messages.Login} />
</button>
)
}
return (
<div className="header-actions">
{!location.pathname.startsWith("/search") && (
@ -199,3 +183,20 @@ const AccountHeader = () => {
</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 { generateNewLogin, LoginSessionType, LoginStore } from "Login";
import AsyncButton from "Element/AsyncButton";
import useLoginHandler from "Hooks/useLoginHandler";
import useLoginHandler, { PinRequiredError } from "Hooks/useLoginHandler";
import { secp256k1 } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/curves/abstract/utils";
import Modal from "Element/Modal";
import QrCode from "Element/QrCode";
import Copy from "Element/Copy";
import { delay } from "SnortUtils";
import { PinPrompt } from "Element/PinPrompt";
declare global {
interface Window {
@ -78,6 +79,7 @@ export default function LoginPage() {
const login = useLogin();
const [key, setKey] = useState("");
const [error, setError] = useState("");
const [pin, setPin] = useState(false);
const [art, setArt] = useState<ArtworkEntry>();
const [isMasking, setMasking] = useState(true);
const { formatMessage } = useIntl();
@ -99,10 +101,13 @@ export default function LoginPage() {
setArt({ ...ret, link: url });
}, []);
async function doLogin() {
async function doLogin(pin?: string) {
try {
await loginHandler.doLogin(key);
await loginHandler.doLogin(key, pin);
} catch (e) {
if (e instanceof PinRequiredError) {
setPin(true);
}
if (e instanceof Error) {
setError(e.message);
} else {
@ -116,10 +121,16 @@ export default function LoginPage() {
}
}
async function makeRandomKey() {
await generateNewLogin();
window.plausible?.("Generate Account");
navigate("/new");
async function makeRandomKey(pin: string) {
try {
await generateNewLogin(pin);
window.plausible?.("Generate Account");
navigate("/new");
} catch (e) {
if (e instanceof Error) {
setError(e.message);
}
}
}
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" />
</AsyncButton>
{nostrConnect && (
<Modal onClose={() => setNostrConnect("")}>
<Modal id="nostr-connect" onClose={() => setNostrConnect("")}>
<div className="flex f-col">
<QrCode data={nostrConnect} />
<Copy text={nostrConnect} />
@ -283,12 +294,19 @@ export default function LoginPage() {
/>
</p>
<div dir="auto" className="login-actions">
<AsyncButton type="button" onClick={doLogin}>
<AsyncButton type="button" onClick={() => doLogin()}>
<FormattedMessage defaultMessage="Login" description="Login button" />
</AsyncButton>
<AsyncButton onClick={() => makeRandomKey()}>
<AsyncButton onClick={() => setPin(true)}>
<FormattedMessage defaultMessage="Create Account" />
</AsyncButton>
{pin && <PinPrompt onResult={pin => {
if (key) {
doLogin(pin);
} else {
makeRandomKey(pin);
}
}} onCancel={() => setPin(false)} />}
{altLogins()}
</div>
{installExtension()}

View File

@ -210,7 +210,7 @@ function NewChatWindow() {
<Icon name="plus" size={16} />
</button>
{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 f-space">
<h2>

View File

@ -326,14 +326,14 @@ export default function ProfilePage() {
targets={
lnurl?.lnurl && id
? [
{
type: "lnurl",
value: lnurl?.lnurl,
weight: 1,
name: user?.display_name || user?.name,
zap: { pubkey: id },
} as ZapTarget,
]
{
type: "lnurl",
value: lnurl?.lnurl,
weight: 1,
name: user?.display_name || user?.name,
zap: { pubkey: id },
} as ZapTarget,
]
: undefined
}
show={showLnQr}
@ -447,7 +447,7 @@ export default function ProfilePage() {
<Icon name="qr" size={16} />
</IconButton>
{showProfileQr && (
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}>
<Modal id="profile-qr" className="qr-modal" onClose={() => setShowProfileQr(false)}>
<ProfileImage pubkey={id} />
<QrCode data={link} className="m10 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 Logo from "Element/Logo";
import useEventPublisher from "Feed/EventPublisher";
import useEventPublisher from "Hooks/useEventPublisher";
import useLogin from "Hooks/useLogin";
import { UserCache } from "Cache";
import AvatarEditor from "Element/AvatarEditor";

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { LNURL } from "@snort/shared";
import { ApiHost } from "Const";
import AsyncButton from "Element/AsyncButton";
import useEventPublisher from "Feed/EventPublisher";
import useEventPublisher from "Hooks/useEventPublisher";
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
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 { ApiHost } from "Const";
import useEventPublisher from "Feed/EventPublisher";
import useEventPublisher from "Hooks/useEventPublisher";
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
export default function ListHandles() {

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import { formatShort } from "Number";
import { LockedFeatures, Plans, SubscriptionType } from "Subscription";
import ManageSubscriptionPage from "Pages/subscribe/ManageSubscription";
import AsyncButton from "Element/AsyncButton";
import useEventPublisher from "Feed/EventPublisher";
import useEventPublisher from "Hooks/useEventPublisher";
import SnortApi, { SubscriptionError, SubscriptionErrorCode } from "SnortApi";
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 * as ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import {
EventPublisher,
@ -24,7 +23,6 @@ import { SnortContext } from "@snort/system-react";
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
import { IntlProvider } from "IntlProvider";
import { unwrap } from "SnortUtils";
import Store from "State/Store";
import Layout from "Pages/Layout";
import LoginPage from "Pages/LoginPage";
import ProfilePage from "Pages/ProfilePage";
@ -218,12 +216,10 @@ export const router = createBrowserRouter([
const root = ReactDOM.createRoot(unwrap(document.getElementById("root")));
root.render(
<StrictMode>
<Provider store={Store}>
<IntlProvider>
<SnortContext.Provider value={System}>
<RouterProvider router={router} />
</SnortContext.Provider>
</IntlProvider>
</Provider>
<IntlProvider>
<SnortContext.Provider value={System}>
<RouterProvider router={router} />
</SnortContext.Provider>
</IntlProvider>
</StrictMode>,
);

View File

@ -9,7 +9,7 @@ export interface HookFilter<TSnapshot> {
*/
export abstract class ExternalStore<TSnapshot> {
#hooks: Array<HookFilter<TSnapshot>> = [];
#snapshot: Readonly<TSnapshot> = {} as Readonly<TSnapshot>;
#snapshot: TSnapshot = {} as TSnapshot;
#changed = true;
hook(fn: HookFn<TSnapshot>) {
@ -35,7 +35,9 @@ export abstract class ExternalStore<TSnapshot> {
protected notifyChange(sn?: TSnapshot) {
this.#changed = true;
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);
if (thread) {
if (thread.root || thread.replyTo) {
eb.tag(["e", thread.root?.value ?? thread.replyTo?.value ?? "", "", "root"]);
const rootOrReplyAsRoot = thread.root || thread.replyTo;
if (rootOrReplyAsRoot) {
eb.tag(["e", rootOrReplyAsRoot?.value ?? "", rootOrReplyAsRoot?.relay ?? "", "root"]);
}
eb.tag(["e", replyTo.id, replyTo.relays?.[0] ?? "", "reply"]);

View File

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

View File

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

View File

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

View File

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

View File

@ -113,7 +113,7 @@ export class RequestBuilder {
const diff = system.QueryOptimizer.getDiff(prev, this.buildRaw());
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) {
return splitFlatByWriteRelays(system.RelayCache, diff).map(a => {
return {

View File

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

104
yarn.lock
View File

@ -1376,7 +1376,7 @@ __metadata:
languageName: node
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
resolution: "@babel/runtime@npm:7.22.11"
dependencies:
@ -2507,26 +2507,6 @@ __metadata:
languageName: node
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":
version: 1.8.0
resolution: "@remix-run/router@npm:1.8.0"
@ -2705,7 +2685,6 @@ __metadata:
"@lightninglabs/lnc-web": ^0.2.3-alpha
"@noble/curves": ^1.0.0
"@noble/hashes": ^1.2.0
"@reduxjs/toolkit": ^1.9.1
"@scure/base": ^1.1.1
"@scure/bip32": ^1.3.0
"@scure/bip39": ^1.1.1
@ -2751,7 +2730,6 @@ __metadata:
react-dom: ^18.2.0
react-intersection-observer: ^9.4.1
react-intl: ^6.4.4
react-redux: ^8.0.5
react-router-dom: ^6.5.0
react-textarea-autosize: ^8.4.0
react-twitter-embed: ^4.0.4
@ -3590,13 +3568,6 @@ __metadata:
languageName: node
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":
version: 9.0.2
resolution: "@types/uuid@npm:9.0.2"
@ -7614,13 +7585,6 @@ __metadata:
languageName: node
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":
version: 3.3.0
resolution: "import-fresh@npm:3.3.0"
@ -11273,38 +11237,6 @@ __metadata:
languageName: node
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":
version: 6.15.0
resolution: "react-router-dom@npm:6.15.0"
@ -11522,24 +11454,6 @@ __metadata:
languageName: node
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":
version: 10.1.0
resolution: "regenerate-unicode-properties@npm:10.1.0"
@ -11670,13 +11584,6 @@ __metadata:
languageName: node
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":
version: 3.0.0
resolution: "resolve-cwd@npm:3.0.0"
@ -13424,15 +13331,6 @@ __metadata:
languageName: node
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":
version: 1.0.2
resolution: "util-deprecate@npm:1.0.2"