This commit is contained in:
Kieran 2023-09-21 22:01:39 +01:00
parent 8244441929
commit 96d4e4bcc5
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
16 changed files with 480 additions and 384 deletions

View File

@ -9,9 +9,11 @@ export interface ModalProps {
} }
export default function Modal(props: ModalProps) { export default function Modal(props: ModalProps) {
return <div className={`modal${props.className ? ` ${props.className}` : ""}`} onClick={props.onClose}> return (
<div className="modal-body" onClick={e => e.stopPropagation()}> <div className={`modal${props.className ? ` ${props.className}` : ""}`} onClick={props.onClose}>
{props.children} <div className="modal-body" onClick={e => e.stopPropagation()}>
{props.children}
</div>
</div> </div>
</div>; );
} }

View File

@ -1,6 +1,14 @@
import "./NoteCreator.css"; import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder, tryParseNostrLink, NostrLink, NostrEvent } from "@snort/system"; import {
EventKind,
NostrPrefix,
TaggedNostrEvent,
EventBuilder,
tryParseNostrLink,
NostrLink,
NostrEvent,
} from "@snort/system";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import useEventPublisher from "Hooks/useEventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
@ -30,7 +38,7 @@ export function NoteCreator() {
async function buildNote() { async function buildNote() {
try { try {
note.update(v => v.error = ""); note.update(v => (v.error = ""));
if (note && publisher) { if (note && publisher) {
let extraTags: Array<Array<string>> | undefined; let extraTags: Array<Array<string>> | undefined;
if (note.zapSplits) { if (note.zapSplits) {
@ -99,7 +107,9 @@ export function NoteCreator() {
eb.kind(kind); eb.kind(kind);
return eb; return eb;
}; };
const ev = note.replyTo ? await publisher.reply(note.replyTo, note.note, hk) : await publisher.note(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) {
@ -131,7 +141,7 @@ export function NoteCreator() {
note.update(v => { note.update(v => {
v.reset(); v.reset();
v.show = false; v.show = false;
}) });
} }
} }
@ -181,7 +191,7 @@ export function NoteCreator() {
function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) { function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) {
const { value } = ev.target; const { value } = ev.target;
note.update(n => n.note = value); note.update(n => (n.note = value));
} }
function cancel() { function cancel() {
@ -198,10 +208,10 @@ export function NoteCreator() {
async function loadPreview() { async function loadPreview() {
if (note.preview) { if (note.preview) {
note.update(v => v.preview = undefined); note.update(v => (v.preview = undefined));
} else if (publisher) { } else if (publisher) {
const tmpNote = await buildNote(); const tmpNote = await buildNote();
note.update(v => v.preview = tmpNote); note.update(v => (v.preview = tmpNote));
} }
} }
@ -244,7 +254,7 @@ export function NoteCreator() {
</div> </div>
</div> </div>
))} ))}
<button onClick={() => note.update(v => v.pollOptions = [...(note.pollOptions ?? []), ""])}> <button onClick={() => note.update(v => (v.pollOptions = [...(note.pollOptions ?? []), ""]))}>
<Icon name="plus" size={14} /> <Icon name="plus" size={14} />
</button> </button>
</> </>
@ -256,7 +266,7 @@ export function NoteCreator() {
if (note.pollOptions) { if (note.pollOptions) {
const copy = [...note.pollOptions]; const copy = [...note.pollOptions];
copy[i] = v; copy[i] = v;
note.update(v => v.pollOptions = copy); note.update(v => (v.pollOptions = copy));
} }
} }
@ -264,7 +274,7 @@ export function NoteCreator() {
if (note.pollOptions) { if (note.pollOptions) {
const copy = [...note.pollOptions]; const copy = [...note.pollOptions];
copy.splice(i, 1); copy.splice(i, 1);
note.update(v => v.pollOptions = copy); note.update(v => (v.pollOptions = copy));
} }
} }
@ -281,15 +291,22 @@ export function NoteCreator() {
type="checkbox" type="checkbox"
checked={!note.selectedCustomRelays || note.selectedCustomRelays.includes(r)} checked={!note.selectedCustomRelays || note.selectedCustomRelays.includes(r)}
onChange={e => { onChange={e => {
note.update(v => v.selectedCustomRelays = ( note.update(
// set false if all relays selected v =>
e.target.checked && note.selectedCustomRelays && note.selectedCustomRelays.length == a.length - 1 (v.selectedCustomRelays =
? undefined // set false if all relays selected
: // otherwise return selectedCustomRelays with target relay added / removed e.target.checked &&
a.filter(el => el === r ? e.target.checked : !note.selectedCustomRelays || note.selectedCustomRelays.includes(el)) 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> </div>
</div> </div>
@ -330,146 +347,166 @@ export function NoteCreator() {
}; };
if (!note.show) return null; if (!note.show) return null;
return (<Modal id="note-creator" className="note-creator-modal" onClose={() => note.update(v => v.show = false)}> return (
{note.replyTo && ( <Modal id="note-creator" className="note-creator-modal" onClose={() => note.update(v => (v.show = false))}>
<Note {note.replyTo && (
data={note.replyTo} <Note
related={[]} data={note.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 && (
<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> {note.preview && getPreviewNote()}
)} {!note.preview && (
<div className="flex f-space"> <div onPaste={handlePaste} className={`note-creator${note.pollOptions ? " poll" : ""}`}>
<div className="flex g8"> <Textarea
<ProfileImage pubkey={login.publicKey ?? ""} className="note-creator-icon" link="" showUsername={false} showFollowingMark={false} /> autoFocus
{note.pollOptions === undefined && !note.replyTo && ( className={`textarea ${note.active ? "textarea--focused" : ""}`}
<div className="note-creator-icon"> onChange={c => onChange(c)}
<Icon name="pie-chart" onClick={() => note.update(v => v.pollOptions = ["A", "B"])} size={24} /> value={note.note}
</div> onFocus={() => note.update(v => (v.active = true))}
)} onKeyDown={e => {
<AsyncIcon iconName="image-plus" iconSize={24} onClick={attachFile} className="note-creator-icon" /> if (e.key === "Enter" && e.metaKey) {
<button className="secondary" onClick={() => note.update(v => v.advanced = !v.advanced)}> sendNote().catch(console.warn);
<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>
</div>
))}
<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"> {renderPollOptions()}
<FormattedMessage defaultMessage="Not all clients support this yet" />
</span>
</div> </div>
</> )}
)} <div className="flex f-space">
</Modal> <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>
)}
<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>
</div>
))}
<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,7 +1,7 @@
.pin-box { .pin-box {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 12px 16px; padding: 12px 16px;
font-size: 80px; font-size: 80px;
height: 1em; height: 1em;
border-radius: 12px; border-radius: 12px;
} }

View File

@ -11,120 +11,145 @@ import Modal from "./Modal";
import Spinner from "Icons/Spinner"; import Spinner from "Icons/Spinner";
const PinLen = 6; const PinLen = 6;
export function PinPrompt({ onResult, onCancel, subTitle }: { onResult: (v: string) => Promise<void>, onCancel: () => void, subTitle?: ReactNode }) { export function PinPrompt({
const [pin, setPin] = useState(""); onResult,
const [error, setError] = useState(""); onCancel,
const { formatMessage } = useIntl(); subTitle,
}: {
onResult: (v: string) => Promise<void>;
onCancel: () => void;
subTitle?: ReactNode;
}) {
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const { formatMessage } = useIntl();
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
console.debug(e); console.debug(e);
if (!isNaN(Number(e.key)) && pin.length < PinLen) { if (!isNaN(Number(e.key)) && pin.length < PinLen) {
setPin(s => s += e.key); setPin(s => (s += e.key));
} if (e.key === "Backspace") { }
setPin(s => s.slice(0, -1)); if (e.key === "Backspace") {
} else { setPin(s => s.slice(0, -1));
e.preventDefault(); } else {
} e.preventDefault();
}; }
const handler = (e: Event) => handleKey(e as KeyboardEvent); };
document.addEventListener("keydown", handler); const handler = (e: Event) => handleKey(e as KeyboardEvent);
return () => document.removeEventListener("keydown", handler); document.addEventListener("keydown", handler);
}, [pin]); return () => document.removeEventListener("keydown", handler);
}, [pin]);
useEffect(() => { useEffect(() => {
if (pin.length > 0) { if (pin.length > 0) {
setError(""); setError("");
}
if (pin.length === PinLen) {
onResult(pin).catch(e => {
console.error(e);
setPin("");
if (e instanceof InvalidPinError) {
setError(formatMessage({
defaultMessage: "Incorrect pin"
}));
} else if (e instanceof Error) {
setError(e.message);
}
})
}
}, [pin]);
const boxes = [];
if (pin.length === PinLen) {
boxes.push(<Spinner className="flex f-center f-1" />);
} else {
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"> if (pin.length === PinLen) {
<h2> onResult(pin).catch(e => {
<FormattedMessage defaultMessage="Enter Pin" /> console.error(e);
</h2> setPin("");
{subTitle} if (e instanceof InvalidPinError) {
<div className="flex g4"> setError(
{boxes} formatMessage({
</div> defaultMessage: "Incorrect pin",
{error && <b className="error">{error}</b>} }),
<div> );
<button type="button" onClick={() => onCancel()}> } else if (e instanceof Error) {
<FormattedMessage defaultMessage="Cancel" /> setError(e.message);
</button> }
</div> });
}
}, [pin]);
const boxes = [];
if (pin.length === PinLen) {
boxes.push(<Spinner className="flex f-center f-1" />);
} else {
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>
</div>
</Modal> </Modal>
);
} }
export function LoginUnlock() { export function LoginUnlock() {
const login = useLogin(); const login = useLogin();
const publisher = useEventPublisher(); const publisher = useEventPublisher();
async function encryptMigration(pin: string) { async function encryptMigration(pin: string) {
const k = unwrap(login.privateKey); const k = unwrap(login.privateKey);
const newPin = await PinEncrypted.create(k, pin); const newPin = await PinEncrypted.create(k, pin);
const pub = EventPublisher.privateKey(k); const pub = EventPublisher.privateKey(k);
if (login.preferences.pow) { if (login.preferences.pow) {
pub.pow(login.preferences.pow, DefaultPowWorker); pub.pow(login.preferences.pow, DefaultPowWorker);
}
LoginStore.setPublisher(login.id, pub);
LoginStore.updateSession({
...login,
privateKeyData: newPin,
privateKey: undefined
});
} }
LoginStore.setPublisher(login.id, pub);
LoginStore.updateSession({
...login,
privateKeyData: newPin,
privateKey: undefined,
});
}
async function unlockSession(pin: string) { async function unlockSession(pin: string) {
const key = new PinEncrypted(unwrap(login.privateKeyData) as PinEncryptedPayload); const key = new PinEncrypted(unwrap(login.privateKeyData) as PinEncryptedPayload);
await key.decrypt(pin); await key.decrypt(pin);
const pub = createPublisher(login, key); const pub = createPublisher(login, key);
if (pub) { if (pub) {
if (login.preferences.pow) { if (login.preferences.pow) {
pub.pow(login.preferences.pow, DefaultPowWorker); pub.pow(login.preferences.pow, DefaultPowWorker);
} }
LoginStore.setPublisher(login.id, pub); LoginStore.setPublisher(login.id, pub);
}
} }
}
if (login.publicKey && !publisher && sessionNeedsPin(login)) { if (login.publicKey && !publisher && sessionNeedsPin(login)) {
if (login.privateKey !== undefined) { if (login.privateKey !== undefined) {
return <PinPrompt subTitle={<p> return (
<FormattedMessage defaultMessage="Enter a pin to encrypt your private key, you must enter this pin every time you open Snort." /> <PinPrompt
</p>} onResult={encryptMigration} onCancel={() => { subTitle={
// nothing <p>
}} /> <FormattedMessage defaultMessage="Enter a pin to encrypt your private key, you must enter this pin every time you open Snort." />
} </p>
return <PinPrompt subTitle={<p> }
onResult={encryptMigration}
onCancel={() => {
// nothing
}}
/>
);
}
return (
<PinPrompt
subTitle={
<p>
<FormattedMessage defaultMessage="Enter pin to unlock private key" /> <FormattedMessage defaultMessage="Enter pin to unlock private key" />
</p>} onResult={unlockSession} onCancel={() => { </p>
//nothing }
}} /> onResult={unlockSession}
} onCancel={() => {
} //nothing
}}
/>
);
}
}

View File

@ -8,7 +8,7 @@ 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({ onClose, ev }: { onClose: () => void, ev: TaggedNostrEvent }) { export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: TaggedNostrEvent }) {
const [selected, setSelected] = useState<Array<string>>(); const [selected, setSelected] = useState<Array<string>>();
const publisher = useEventPublisher(); const publisher = useEventPublisher();
@ -44,10 +44,12 @@ export function ReBroadcaster({ onClose, ev }: { onClose: () => void, ev: Tagged
<input <input
type="checkbox" type="checkbox"
checked={!selected || selected.includes(r)} checked={!selected || selected.includes(r)}
onChange={e => setSelected( onChange={e =>
e.target.checked && selected && selected.length == a.length - 1 setSelected(
? undefined e.target.checked && selected && selected.length == a.length - 1
: a.filter(el => el === r ? e.target.checked : !selected || selected.includes(el))) ? undefined
: a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))),
)
} }
/> />
</div> </div>

View File

@ -7,7 +7,7 @@ import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { getNip05PubKey } from "Pages/LoginPage"; import { getNip05PubKey } from "Pages/LoginPage";
import { bech32ToHex } from "SnortUtils"; import { bech32ToHex } from "SnortUtils";
export class PinRequiredError extends Error { } export class PinRequiredError extends Error {}
export default function useLoginHandler() { export default function useLoginHandler() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();

View File

@ -172,12 +172,12 @@ export function sessionNeedsPin(l: LoginSession) {
export function createPublisher(l: LoginSession, pin?: PinEncrypted) { export function createPublisher(l: LoginSession, pin?: PinEncrypted) {
switch (l.type) { switch (l.type) {
case LoginSessionType.PrivateKey: { case LoginSessionType.PrivateKey: {
if(!pin) throw new PinRequiredError(); if (!pin) throw new PinRequiredError();
l.privateKeyData = pin; l.privateKeyData = pin;
return EventPublisher.privateKey(pin.value); return EventPublisher.privateKey(pin.value);
} }
case LoginSessionType.Nip46: { case LoginSessionType.Nip46: {
if(!pin) throw new PinRequiredError(); if (!pin) throw new PinRequiredError();
l.privateKeyData = pin; l.privateKeyData = pin;
const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`); const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`);
@ -194,4 +194,4 @@ export function createPublisher(l: LoginSession, pin?: PinEncrypted) {
} }
} }
} }
} }

View File

@ -1,6 +1,6 @@
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 { v4 as uuid } from "uuid";
import { HexKey, RelaySettings, PinEncrypted, EventPublisher } 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";
@ -117,7 +117,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
} as LoginSession; } as LoginSession;
const pub = createPublisher(newSession); const pub = createPublisher(newSession);
if(pub) { if (pub) {
this.setPublisher(newSession.id, pub); this.setPublisher(newSession.id, pub);
} }
this.#accounts.set(newSession.id, newSession); this.#accounts.set(newSession.id, newSession);
@ -188,7 +188,7 @@ 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 };
} }
#migrate() { #migrate() {
@ -229,8 +229,8 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
this.#activeAccount = this.#accounts.keys().next().value; this.#activeAccount = this.#accounts.keys().next().value;
} }
const toSave = [...this.#accounts.values()]; const toSave = [...this.#accounts.values()];
for(const v of toSave) { for (const v of toSave) {
if(v.privateKeyData instanceof PinEncrypted) { if (v.privateKeyData instanceof PinEncrypted) {
v.privateKeyData = v.privateKeyData.toPayload(); v.privateKeyData = v.privateKeyData.toPayload();
} }
} }

View File

@ -53,31 +53,35 @@ export default function Layout() {
} }
}, [location]); }, [location]);
return (<> return (
<div className={pageClass}> <>
{!shouldHideHeader && ( <div className={pageClass}>
<header className="main-content"> {!shouldHideHeader && (
<LogoHeader /> <header className="main-content">
<AccountHeader /> <LogoHeader />
</header> <AccountHeader />
)} </header>
<Outlet /> )}
<Outlet />
{!shouldHideNoteCreator && ( {!shouldHideNoteCreator && (
<> <>
<button className="primary note-create-button" onClick={() => note.update(v => { <button
v.replyTo = undefined; className="primary note-create-button"
v.show = true onClick={() =>
})}> note.update(v => {
<Icon name="plus" size={16} /> v.replyTo = undefined;
</button> v.show = true;
<NoteCreator /> })
</> }>
)} <Icon name="plus" size={16} />
<Toaster /> </button>
</div> <NoteCreator />
<LoginUnlock /> </>
</> )}
<Toaster />
</div>
<LoginUnlock />
</>
); );
} }
@ -138,7 +142,7 @@ const AccountHeader = () => {
<button type="button" onClick={() => navigate("/login")}> <button type="button" onClick={() => navigate("/login")}>
<FormattedMessage {...messages.Login} /> <FormattedMessage {...messages.Login} />
</button> </button>
) );
} }
return ( return (
<div className="header-actions"> <div className="header-actions">
@ -199,4 +203,4 @@ function LogoHeader() {
)} )}
</Link> </Link>
); );
} }

View File

@ -300,15 +300,23 @@ export default function LoginPage() {
<AsyncButton onClick={() => setPin(true)}> <AsyncButton onClick={() => setPin(true)}>
<FormattedMessage defaultMessage="Create Account" /> <FormattedMessage defaultMessage="Create Account" />
</AsyncButton> </AsyncButton>
{pin && <PinPrompt subTitle={<p> {pin && (
<FormattedMessage defaultMessage="Enter a pin to encrypt your private key, you must enter this pin every time you open Snort." /> <PinPrompt
</p>} onResult={async pin => { subTitle={
if (key) { <p>
await doLogin(pin); <FormattedMessage defaultMessage="Enter a pin to encrypt your private key, you must enter this pin every time you open Snort." />
} else { </p>
await makeRandomKey(pin); }
} onResult={async pin => {
}} onCancel={() => setPin(false)} />} if (key) {
await doLogin(pin);
} else {
await makeRandomKey(pin);
}
}}
onCancel={() => setPin(false)}
/>
)}
{altLogins()} {altLogins()}
</div> </div>
{installExtension()} {installExtension()}

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}

View File

@ -38,7 +38,7 @@ class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
update: (fn: (v: NoteCreatorDataSnapshot) => void) => { update: (fn: (v: NoteCreatorDataSnapshot) => void) => {
fn(this.#data); fn(this.#data);
this.notifyChange(); this.notifyChange();
} },
}; };
} }
@ -67,7 +67,7 @@ class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
fn(this.#data); fn(this.#data);
console.debug(this.#data); console.debug(this.#data);
this.notifyChange(); this.notifyChange();
} },
} as NoteCreatorDataSnapshot; } as NoteCreatorDataSnapshot;
return sn; return sn;
} }
@ -76,5 +76,8 @@ class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
const NoteCreatorState = new NoteCreatorStore(); const NoteCreatorState = new NoteCreatorStore();
export function useNoteCreator() { export function useNoteCreator() {
return useSyncExternalStore(c => NoteCreatorState.hook(c), () => NoteCreatorState.snapshot()); return useSyncExternalStore(
} c => NoteCreatorState.hook(c),
() => NoteCreatorState.snapshot(),
);
}

View File

@ -99,6 +99,9 @@
"25V4l1": { "25V4l1": {
"defaultMessage": "Banner" "defaultMessage": "Banner"
}, },
"27jzYm": {
"defaultMessage": "Enter pin to unlock private key"
},
"2IFGap": { "2IFGap": {
"defaultMessage": "Donate" "defaultMessage": "Donate"
}, },
@ -539,6 +542,9 @@
"KoFlZg": { "KoFlZg": {
"defaultMessage": "Enter mint URL" "defaultMessage": "Enter mint URL"
}, },
"KtsyO0": {
"defaultMessage": "Enter Pin"
},
"LF5kYT": { "LF5kYT": {
"defaultMessage": "Other Connections" "defaultMessage": "Other Connections"
}, },
@ -887,6 +893,9 @@
"defaultMessage": "Install Extension", "defaultMessage": "Install Extension",
"description": "Heading for install key manager extension" "description": "Heading for install key manager extension"
}, },
"c2DTVd": {
"defaultMessage": "Enter a pin to encrypt your private key, you must enter this pin every time you open Snort."
},
"c35bj2": { "c35bj2": {
"defaultMessage": "If you have an enquiry about your NIP-05 order please DM {link}" "defaultMessage": "If you have an enquiry about your NIP-05 order please DM {link}"
}, },
@ -1230,6 +1239,9 @@
"qtWLmt": { "qtWLmt": {
"defaultMessage": "Like" "defaultMessage": "Like"
}, },
"qz9fty": {
"defaultMessage": "Incorrect pin"
},
"r3C4x/": { "r3C4x/": {
"defaultMessage": "Software" "defaultMessage": "Software"
}, },

View File

@ -32,6 +32,7 @@
"1udzha": "Conversations", "1udzha": "Conversations",
"2/2yg+": "Add", "2/2yg+": "Add",
"25V4l1": "Banner", "25V4l1": "Banner",
"27jzYm": "Enter pin to unlock private key",
"2IFGap": "Donate", "2IFGap": "Donate",
"2LbrkB": "Enter password", "2LbrkB": "Enter password",
"2a2YiP": "{n} Bookmarks", "2a2YiP": "{n} Bookmarks",
@ -177,6 +178,7 @@
"KWuDfz": "I have saved my keys, continue", "KWuDfz": "I have saved my keys, continue",
"KahimY": "Unknown event kind: {kind}", "KahimY": "Unknown event kind: {kind}",
"KoFlZg": "Enter mint URL", "KoFlZg": "Enter mint URL",
"KtsyO0": "Enter Pin",
"LF5kYT": "Other Connections", "LF5kYT": "Other Connections",
"LXxsbk": "Anonymous", "LXxsbk": "Anonymous",
"LgbKvU": "Comment", "LgbKvU": "Comment",
@ -290,6 +292,7 @@
"brAXSu": "Pick a username", "brAXSu": "Pick a username",
"bxv59V": "Just now", "bxv59V": "Just now",
"c+oiJe": "Install Extension", "c+oiJe": "Install Extension",
"c2DTVd": "Enter a pin to encrypt your private key, you must enter this pin every time you open Snort.",
"c35bj2": "If you have an enquiry about your NIP-05 order please DM {link}", "c35bj2": "If you have an enquiry about your NIP-05 order please DM {link}",
"c3g2hL": "Broadcast Again", "c3g2hL": "Broadcast Again",
"cFbU1B": "Using Alby? Go to {link} to get your NWC config!", "cFbU1B": "Using Alby? Go to {link} to get your NWC config!",
@ -402,6 +405,7 @@
"qkvYUb": "Add to Profile", "qkvYUb": "Add to Profile",
"qmJ8kD": "Translation failed", "qmJ8kD": "Translation failed",
"qtWLmt": "Like", "qtWLmt": "Like",
"qz9fty": "Incorrect pin",
"r3C4x/": "Software", "r3C4x/": "Software",
"r5srDR": "Enter wallet password", "r5srDR": "Enter wallet password",
"rT14Ow": "Add Relays", "rT14Ow": "Add Relays",

View File

@ -1,12 +1,12 @@
import { scryptAsync } from "@noble/hashes/scrypt"; import { scryptAsync } from "@noble/hashes/scrypt";
import { sha256 } from '@noble/hashes/sha256'; import { sha256 } from "@noble/hashes/sha256";
import { hmac } from "@noble/hashes/hmac"; import { hmac } from "@noble/hashes/hmac";
import { bytesToHex, hexToBytes, randomBytes } from "@noble/hashes/utils"; import { bytesToHex, hexToBytes, randomBytes } from "@noble/hashes/utils";
import { base64 } from "@scure/base"; import { base64 } from "@scure/base";
import { streamXOR as xchacha20 } from "@stablelib/xchacha20"; import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
export class InvalidPinError extends Error { export class InvalidPinError extends Error {
constructor(){ constructor() {
super(); super();
} }
} }
@ -15,56 +15,55 @@ export class InvalidPinError extends Error {
* Pin protected data * Pin protected data
*/ */
export class PinEncrypted { export class PinEncrypted {
static readonly #opts = {N: 2**20, r: 8, p: 1, dkLen: 32} static readonly #opts = { N: 2 ** 20, r: 8, p: 1, dkLen: 32 };
#decrypted?: Uint8Array #decrypted?: Uint8Array;
#encrypted: PinEncryptedPayload #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);
}
async decrypt(pin: string) {
const key = await scryptAsync(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, new Uint8Array(32));
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() { constructor(enc: PinEncryptedPayload) {
return this.#encrypted; this.#encrypted = enc;
}
static async create(content: string, pin: string) {
const salt = randomBytes(24);
const nonce = randomBytes(24);
const plaintext = hexToBytes(content);
const key = await scryptAsync(pin, salt, PinEncrypted.#opts);
const mac = base64.encode(hmac(sha256, key, plaintext));
const ciphertext = xchacha20(key, nonce, plaintext, new Uint8Array(32));
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 { get value() {
salt: string, // for KDF if (!this.#decrypted) throw new Error("Content has not been decrypted yet");
ciphertext: string return bytesToHex(this.#decrypted);
iv: string,
mac: string
} }
async decrypt(pin: string) {
const key = await scryptAsync(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, new Uint8Array(32));
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 async create(content: string, pin: string) {
const salt = randomBytes(24);
const nonce = randomBytes(24);
const plaintext = hexToBytes(content);
const key = await scryptAsync(pin, salt, PinEncrypted.#opts);
const mac = base64.encode(hmac(sha256, key, plaintext));
const ciphertext = xchacha20(key, nonce, plaintext, new Uint8Array(32));
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

@ -6,8 +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) {
const ent = Object.entries(f).filter(([,v]) => Array.isArray(v)); const ent = Object.entries(f).filter(([, v]) => Array.isArray(v));
if(ent.every(([,v]) => (v as Array<string | number>).length > 0)) { if (ent.every(([, v]) => (v as Array<string | number>).length > 0)) {
fNew.push(f); fNew.push(f);
} }
} }