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) {
return <div className={`modal${props.className ? ` ${props.className}` : ""}`} onClick={props.onClose}>
<div className="modal-body" onClick={e => e.stopPropagation()}>
{props.children}
return (
<div className={`modal${props.className ? ` ${props.className}` : ""}`} onClick={props.onClose}>
<div className="modal-body" onClick={e => e.stopPropagation()}>
{props.children}
</div>
</div>
</div>;
);
}

View File

@ -1,6 +1,14 @@
import "./NoteCreator.css";
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 useEventPublisher from "Hooks/useEventPublisher";
@ -30,7 +38,7 @@ export function NoteCreator() {
async function buildNote() {
try {
note.update(v => v.error = "");
note.update(v => (v.error = ""));
if (note && publisher) {
let extraTags: Array<Array<string>> | undefined;
if (note.zapSplits) {
@ -99,7 +107,9 @@ export function NoteCreator() {
eb.kind(kind);
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;
}
} catch (e) {
@ -131,7 +141,7 @@ export function NoteCreator() {
note.update(v => {
v.reset();
v.show = false;
})
});
}
}
@ -181,7 +191,7 @@ export function NoteCreator() {
function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) {
const { value } = ev.target;
note.update(n => n.note = value);
note.update(n => (n.note = value));
}
function cancel() {
@ -198,10 +208,10 @@ export function NoteCreator() {
async function loadPreview() {
if (note.preview) {
note.update(v => v.preview = undefined);
note.update(v => (v.preview = undefined));
} else if (publisher) {
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>
))}
<button onClick={() => note.update(v => v.pollOptions = [...(note.pollOptions ?? []), ""])}>
<button onClick={() => note.update(v => (v.pollOptions = [...(note.pollOptions ?? []), ""]))}>
<Icon name="plus" size={14} />
</button>
</>
@ -256,7 +266,7 @@ export function NoteCreator() {
if (note.pollOptions) {
const copy = [...note.pollOptions];
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) {
const copy = [...note.pollOptions];
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"
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))
));
}
}
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>
</div>
@ -330,146 +347,166 @@ export function NoteCreator() {
};
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);
}
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,
}}
/>
{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>
)}
<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",
})}
)}
{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);
}
}}
/>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
</span>
{renderPollOptions()}
</div>
</>
)}
</Modal>
)}
<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>
)}
<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 {
border: 1px solid var(--border-color);
padding: 12px 16px;
font-size: 80px;
height: 1em;
border-radius: 12px;
}
border: 1px solid var(--border-color);
padding: 12px 16px;
font-size: 80px;
height: 1em;
border-radius: 12px;
}

View File

@ -11,120 +11,145 @@ import Modal from "./Modal";
import Spinner from "Icons/Spinner";
const PinLen = 6;
export function PinPrompt({ onResult, onCancel, subTitle }: { onResult: (v: string) => Promise<void>, onCancel: () => void, subTitle?: ReactNode }) {
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const { formatMessage } = useIntl();
export function PinPrompt({
onResult,
onCancel,
subTitle,
}: {
onResult: (v: string) => Promise<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(() => {
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(() => {
if (pin.length > 0) {
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>)
}
useEffect(() => {
if (pin.length > 0) {
setError("");
}
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>
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">
<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();
const login = useLogin();
const publisher = useEventPublisher();
async function encryptMigration(pin: string) {
const k = unwrap(login.privateKey);
const newPin = await PinEncrypted.create(k, pin);
async function encryptMigration(pin: string) {
const k = unwrap(login.privateKey);
const newPin = await 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,
privateKey: undefined
});
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,
privateKey: undefined,
});
}
async function unlockSession(pin: string) {
const key = new PinEncrypted(unwrap(login.privateKeyData) as PinEncryptedPayload);
await key.decrypt(pin);
const pub = createPublisher(login, key);
if (pub) {
if (login.preferences.pow) {
pub.pow(login.preferences.pow, DefaultPowWorker);
}
LoginStore.setPublisher(login.id, pub);
}
async function unlockSession(pin: string) {
const key = new PinEncrypted(unwrap(login.privateKeyData) as PinEncryptedPayload);
await key.decrypt(pin);
const pub = createPublisher(login, key);
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>
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
}} />
}
}
</p>
}
onResult={unlockSession}
onCancel={() => {
//nothing
}}
/>
);
}
}

View File

@ -8,7 +8,7 @@ import messages from "./messages";
import useLogin from "Hooks/useLogin";
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 publisher = useEventPublisher();
@ -44,10 +44,12 @@ export function ReBroadcaster({ onClose, ev }: { onClose: () => void, ev: Tagged
<input
type="checkbox"
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)))
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>

View File

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

View File

@ -172,12 +172,12 @@ export function sessionNeedsPin(l: LoginSession) {
export function createPublisher(l: LoginSession, pin?: PinEncrypted) {
switch (l.type) {
case LoginSessionType.PrivateKey: {
if(!pin) throw new PinRequiredError();
if (!pin) throw new PinRequiredError();
l.privateKeyData = pin;
return EventPublisher.privateKey(pin.value);
}
case LoginSessionType.Nip46: {
if(!pin) throw new PinRequiredError();
if (!pin) throw new PinRequiredError();
l.privateKeyData = pin;
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 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 { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared";
@ -117,7 +117,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
} as LoginSession;
const pub = createPublisher(newSession);
if(pub) {
if (pub) {
this.setPublisher(newSession.id, pub);
}
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;
if (!s) return LoggedOut;
return {...s};
return { ...s };
}
#migrate() {
@ -229,8 +229,8 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
this.#activeAccount = this.#accounts.keys().next().value;
}
const toSave = [...this.#accounts.values()];
for(const v of toSave) {
if(v.privateKeyData instanceof PinEncrypted) {
for (const v of toSave) {
if (v.privateKeyData instanceof PinEncrypted) {
v.privateKeyData = v.privateKeyData.toPayload();
}
}

View File

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

View File

@ -300,15 +300,23 @@ export default function LoginPage() {
<AsyncButton onClick={() => setPin(true)}>
<FormattedMessage defaultMessage="Create Account" />
</AsyncButton>
{pin && <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={async pin => {
if (key) {
await doLogin(pin);
} else {
await makeRandomKey(pin);
}
}} onCancel={() => setPin(false)} />}
{pin && (
<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={async pin => {
if (key) {
await doLogin(pin);
} else {
await makeRandomKey(pin);
}
}}
onCancel={() => setPin(false)}
/>
)}
{altLogins()}
</div>
{installExtension()}

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}

View File

@ -38,7 +38,7 @@ class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
update: (fn: (v: NoteCreatorDataSnapshot) => void) => {
fn(this.#data);
this.notifyChange();
}
},
};
}
@ -67,7 +67,7 @@ class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
fn(this.#data);
console.debug(this.#data);
this.notifyChange();
}
},
} as NoteCreatorDataSnapshot;
return sn;
}
@ -76,5 +76,8 @@ class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
const NoteCreatorState = new NoteCreatorStore();
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": {
"defaultMessage": "Banner"
},
"27jzYm": {
"defaultMessage": "Enter pin to unlock private key"
},
"2IFGap": {
"defaultMessage": "Donate"
},
@ -539,6 +542,9 @@
"KoFlZg": {
"defaultMessage": "Enter mint URL"
},
"KtsyO0": {
"defaultMessage": "Enter Pin"
},
"LF5kYT": {
"defaultMessage": "Other Connections"
},
@ -887,6 +893,9 @@
"defaultMessage": "Install 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": {
"defaultMessage": "If you have an enquiry about your NIP-05 order please DM {link}"
},
@ -1230,6 +1239,9 @@
"qtWLmt": {
"defaultMessage": "Like"
},
"qz9fty": {
"defaultMessage": "Incorrect pin"
},
"r3C4x/": {
"defaultMessage": "Software"
},

View File

@ -32,6 +32,7 @@
"1udzha": "Conversations",
"2/2yg+": "Add",
"25V4l1": "Banner",
"27jzYm": "Enter pin to unlock private key",
"2IFGap": "Donate",
"2LbrkB": "Enter password",
"2a2YiP": "{n} Bookmarks",
@ -177,6 +178,7 @@
"KWuDfz": "I have saved my keys, continue",
"KahimY": "Unknown event kind: {kind}",
"KoFlZg": "Enter mint URL",
"KtsyO0": "Enter Pin",
"LF5kYT": "Other Connections",
"LXxsbk": "Anonymous",
"LgbKvU": "Comment",
@ -290,6 +292,7 @@
"brAXSu": "Pick a username",
"bxv59V": "Just now",
"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}",
"c3g2hL": "Broadcast Again",
"cFbU1B": "Using Alby? Go to {link} to get your NWC config!",
@ -402,6 +405,7 @@
"qkvYUb": "Add to Profile",
"qmJ8kD": "Translation failed",
"qtWLmt": "Like",
"qz9fty": "Incorrect pin",
"r3C4x/": "Software",
"r5srDR": "Enter wallet password",
"rT14Ow": "Add Relays",

View File

@ -1,12 +1,12 @@
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 { 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(){
constructor() {
super();
}
}
@ -15,56 +15,55 @@ export class InvalidPinError extends Error {
* Pin protected data
*/
export class PinEncrypted {
static readonly #opts = {N: 2**20, r: 8, p: 1, 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);
}
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;
}
static readonly #opts = { N: 2 ** 20, r: 8, p: 1, dkLen: 32 };
#decrypted?: Uint8Array;
#encrypted: PinEncryptedPayload;
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;
}
constructor(enc: PinEncryptedPayload) {
this.#encrypted = enc;
}
export interface PinEncryptedPayload {
salt: string, // for KDF
ciphertext: string
iv: string,
mac: string
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() {
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>) {
const fNew = [];
for (const f of filters) {
const ent = Object.entries(f).filter(([,v]) => Array.isArray(v));
if(ent.every(([,v]) => (v as Array<string | number>).length > 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);
}
}