pin #635

Merged
Kieran merged 14 commits from pin into main 2023-09-22 16:00:26 +00:00
92 changed files with 1661 additions and 1155 deletions

View File

@ -6,7 +6,8 @@
"scripts": {
"build": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/system-react build && yarn workspace @snort/app build",
"start": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/system-react build && yarn workspace @snort/app start",
"test": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/app test && yarn workspace @snort/system test"
"test": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/app test && yarn workspace @snort/system test",
"pre:commit": "yarn workspace @snort/app intl-extract && yarn workspace @snort/app intl-compile && yarn prettier --write ."
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20230307.0",

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",
@ -15,6 +14,7 @@
"@snort/system-query": "workspace:*",
"@snort/system-react": "workspace:*",
"@szhsin/react-menu": "^3.3.1",
"@types/use-sync-external-store": "^0.0.4",
"@void-cat/api": "^1.0.4",
"debug": "^4.3.4",
"dexie": "^3.2.4",
@ -27,11 +27,11 @@
"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",
"use-long-press": "^2.0.3",
"use-sync-external-store": "^1.2.0",
"uuid": "^9.0.0",
"workbox-core": "^6.4.2",
"workbox-precaching": "^7.0.0",

View File

@ -330,6 +330,15 @@
<path d="M9 6.5V9.5M9 12.5H9.0075M16.5 9.5C16.5 13.6421 13.1421 17 9 17C4.85786 17 1.5 13.6421 1.5 9.5C1.5 5.35786 4.85786 2 9 2C13.1421 2 16.5 5.35786 16.5 9.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</symbol>
<symbol id="signal-01" viewBox="0 0 24 25" fill="none">
<g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.5355 7.55101C15.9261 7.16048 16.5592 7.16048 16.9497 7.55101C19.6834 10.2847 19.6834 14.7168 16.9497 17.4505C16.5592 17.841 15.9261 17.841 15.5355 17.4505C15.145 17.06 15.145 16.4268 15.5355 16.0363C17.4882 14.0837 17.4882 10.9178 15.5355 8.96522C15.145 8.5747 15.145 7.94153 15.5355 7.55101Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.46447 7.55097C8.85499 7.9415 8.85499 8.57466 8.46447 8.96519C6.51184 10.9178 6.51184 14.0836 8.46447 16.0363C8.85499 16.4268 8.85499 17.0599 8.46447 17.4505C8.07394 17.841 7.44078 17.841 7.05025 17.4505C4.31658 14.7168 4.31658 10.2846 7.05025 7.55097C7.44078 7.16045 8.07394 7.16045 8.46447 7.55097Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.63604 4.72258C6.02656 5.11311 6.02656 5.74627 5.63604 6.13679C2.12132 9.65151 2.12132 15.35 5.63604 18.8647C6.02656 19.2552 6.02656 19.8884 5.63604 20.2789C5.24551 20.6695 4.61235 20.6695 4.22183 20.2789C-0.0739419 15.9832 -0.0739419 9.01835 4.22183 4.72258C4.61235 4.33206 5.24551 4.33206 5.63604 4.72258Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.364 4.72263C18.7545 4.3321 19.3877 4.3321 19.7782 4.72263C24.0739 9.01839 24.0739 15.9832 19.7782 20.279C19.3877 20.6695 18.7545 20.6695 18.364 20.279C17.9734 19.8885 17.9734 19.2553 18.364 18.8648C21.8787 15.35 21.8787 9.65156 18.364 6.13684C17.9734 5.74632 17.9734 5.11315 18.364 4.72263Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 12.5008C9 10.8439 10.3431 9.50076 12 9.50076C13.6569 9.50076 15 10.8439 15 12.5008C15 14.1576 13.6569 15.5008 12 15.5008C10.3431 15.5008 9 14.1576 9 12.5008Z" fill="currentColor"/>
</g>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 90 KiB

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

@ -21,10 +21,11 @@ const Avatar = ({ pubkey, user, size, onClick, image, imageOverlay, icons }: Ava
const [url, setUrl] = useState("");
const { proxy } = useImgProxy();
const s = size ?? 120;
useEffect(() => {
const url = image ?? user?.picture;
if (url) {
const proxyUrl = proxy(url, size ?? 120);
const proxyUrl = proxy(url, s);
setUrl(proxyUrl);
} else {
setUrl(defaultAvatar(pubkey));
@ -33,6 +34,10 @@ const Avatar = ({ pubkey, user, size, onClick, image, imageOverlay, icons }: Ava
const backgroundImage = `url(${url})`;
const style = { "--img-url": backgroundImage } as CSSProperties;
if (size) {
style.width = `${s}px`;
style.height = `${s}px`;
}
const domain = user?.nip05 && user.nip05.split("@")[1];
return (
<div

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

@ -0,0 +1,8 @@
.cashu {
background: var(--cashu-gradient);
}
.cashu h1 {
font-size: 44px;
line-height: 1em;
}

View File

@ -1,8 +1,10 @@
import "./CashuNuts.css";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { useUserProfile } from "@snort/system-react";
import useLogin from "Hooks/useLogin";
import { useUserProfile } from "@snort/system-react";
import Icon from "Icons/Icon";
interface Token {
token: Array<{
@ -48,33 +50,87 @@ export default function CashuNuts({ token }: { token: string }) {
if (!cashu) return <>{token}</>;
const amount = cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
return (
<div className="note-invoice">
<div className="flex f-between">
<div>
<h4>
<FormattedMessage defaultMessage="Cashu token" />
</h4>
<p>
<FormattedMessage
defaultMessage="Amount: {amount} sats"
values={{
amount: cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0),
}}
/>
</p>
<small className="xs">
<FormattedMessage defaultMessage="Mint: {url}" values={{ url: cashu.token[0].mint }} />
</small>
</div>
<div>
<button onClick={e => copyToken(e, token)} className="mr5">
<FormattedMessage defaultMessage="Copy" description="Button: Copy Cashu token" />
</button>
<button onClick={e => redeemToken(e, token)}>
<FormattedMessage defaultMessage="Redeem" description="Button: Redeem Cashu token" />
</button>
<div className="cashu flex f-space p24 br">
<div className="flex-column g8 f-ellipsis">
<div className="flex f-center g16">
<svg width="30" height="39" viewBox="0 0 30 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 47711">
<path
id="Rectangle 585"
d="M29.3809 2.47055L29.3809 11.7277L26.7913 11.021C23.8493 10.2181 20.727 10.3835 17.8863 11.4929C15.5024 12.4238 12.9113 12.6933 10.3869 12.2728L7.11501 11.7277L7.11501 2.47054L10.3869 3.01557C12.9113 3.43607 15.5024 3.1666 17.8863 2.23566C20.727 1.12632 23.8493 0.960876 26.7913 1.7638L29.3809 2.47055Z"
fill="url(#paint0_linear_1976_19241)"
/>
<path
id="Rectangle 587"
d="M29.3809 27.9803L29.3809 37.2375L26.7913 36.5308C23.8493 35.7278 20.727 35.8933 17.8863 37.0026C15.5024 37.9336 12.9113 38.203 10.3869 37.7825L7.11501 37.2375L7.11501 27.9803L10.3869 28.5253C12.9113 28.9458 15.5024 28.6764 17.8863 27.7454C20.727 26.6361 23.8493 26.4706 26.7913 27.2736L29.3809 27.9803Z"
fill="url(#paint1_linear_1976_19241)"
/>
<path
id="Rectangle 586"
d="M8.494e-08 15.2069L4.89585e-07 24.4641L2.5896 23.7573C5.53159 22.9544 8.6539 23.1198 11.4946 24.2292C13.8784 25.1601 16.4695 25.4296 18.9939 25.0091L22.2658 24.4641L22.2658 15.2069L18.9939 15.7519C16.4695 16.1724 13.8784 15.9029 11.4946 14.972C8.6539 13.8627 5.53159 13.6972 2.5896 14.5001L8.494e-08 15.2069Z"
fill="url(#paint2_linear_1976_19241)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_1976_19241"
x1="29.3809"
y1="6.7213"
x2="7.11501"
y2="6.7213"
gradientUnits="userSpaceOnUse">
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
<linearGradient
id="paint1_linear_1976_19241"
x1="29.3809"
y1="32.2311"
x2="7.11501"
y2="32.2311"
gradientUnits="userSpaceOnUse">
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
<linearGradient
id="paint2_linear_1976_19241"
x1="2.70746e-07"
y1="19.4576"
x2="22.2658"
y2="19.4576"
gradientUnits="userSpaceOnUse">
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
</defs>
</svg>
<FormattedMessage
defaultMessage="<h1>{n}</h1> Cashu sats"
values={{
h1: c => <h1>{c}</h1>,
n: <FormattedNumber value={amount} />,
}}
/>
</div>
<small className="xs w-max">
<FormattedMessage
defaultMessage="<b>Mint:</b> {url}"
values={{
b: c => <b>{c}</b>,
url: new URL(cashu.token[0].mint).hostname,
}}
/>
</small>
</div>
<div className="flex g8">
<button onClick={e => copyToken(e, token)}>
<Icon name="copy" />
</button>
<button onClick={e => redeemToken(e, token)}>
<FormattedMessage defaultMessage="Redeem" description="Button: Redeem Cashu token" />
</button>
</div>
</div>
);

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

@ -1,24 +1,84 @@
import { NostrEvent, NostrLink } from "@snort/system";
import { findTag } from "SnortUtils";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { findTag } from "SnortUtils";
import ProfileImage from "./ProfileImage";
import Icon from "Icons/Icon";
export function LiveEvent({ ev }: { ev: NostrEvent }) {
const title = findTag(ev, "title");
return (
<div className="text">
<div className="flex card">
<div className="f-grow">
<h3>{title}</h3>
</div>
<div>
<Link to={`https://zap.stream/${NostrLink.fromEvent(ev).encode()}`}>
<button className="primary" type="button">
<FormattedMessage defaultMessage="Watch Live!" />
const status = findTag(ev, "status");
const starts = Number(findTag(ev, "starts"));
const host = ev.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;
function statusLine() {
switch (status) {
case "live": {
return (
<div className="flex g4">
<Icon name="signal-01" />
<b className="uppercase">
<FormattedMessage defaultMessage="Live" />
</b>
</div>
);
}
case "ended": {
return (
<b className="uppercase">
<FormattedMessage defaultMessage="Ended" />
</b>
);
}
case "planned": {
return (
<b className="uppercase">
{new Intl.DateTimeFormat(undefined, { dateStyle: "full", timeStyle: "short" }).format(
new Date(starts * 1000),
)}
</b>
);
}
}
}
function cta() {
const link = `https://zap.stream/${NostrLink.fromEvent(ev).encode()}`;
switch (status) {
case "live": {
return (
<Link to={link} target="_blank">
<button type="button">
<FormattedMessage defaultMessage="Join Stream" />
</button>
</Link>
);
}
case "ended": {
if (findTag(ev, "recording")) {
return (
<Link to={link} target="_blank">
<button type="button">
<FormattedMessage defaultMessage="Watch Replay" />
</button>
</Link>
);
}
}
}
}
return (
<div className="flex f-space br p24 bg-primary">
<div className="flex g12">
<ProfileImage pubkey={host} showUsername={false} size={56} />
<div>
<h2>{title}</h2>
{statusLine()}
</div>
</div>
<div>{cta()}</div>
</div>
);
}

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,23 +1,21 @@
import "./Modal.css";
import { useEffect, MouseEventHandler, ReactNode } from "react";
import { ReactNode, useEffect } 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${props.className ? ` ${props.className}` : ""}`} onClick={props.onClose}>
<div className="modal-body" onClick={e => e.stopPropagation()}>
{props.children}
</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,23 @@
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 +26,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 +93,55 @@ 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 +151,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 +166,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 +207,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 +233,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 +254,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 +263,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,20 +289,24 @@ 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>
</div>
@ -345,163 +346,167 @@ export function NoteCreator() {
}
};
if (!note.show) return null;
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>
</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>
</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>
<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>
)}
<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,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(n => ({ show: n.show, replyTo: n.replyTo, update: n.update }));
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 (
@ -266,7 +263,7 @@ export default function NoteFooter(props: NoteFooterProps) {
{tipButton()}
{powIcon()}
</div>
{willRenderNoteCreator && <NoteCreator />}
{willRenderNoteCreator && <NoteCreator key={`note-creator-${ev.id}`} />}
<SendSats targets={getZapTarget()} onClose={() => setTip(false)} show={tip} note={ev.id} allocatePool={true} />
</div>
<ZapsSummary zaps={zaps} />

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,159 @@
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, PinEncryptedPayload } from "@snort/system";
import { DefaultPowWorker } from "index";
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();
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>);
}
}
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();
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,
});
}
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);
LoginStore.updateSession({
...login,
privateKeyData: key,
});
}
}
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 your 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,18 +43,12 @@ export function ReBroadcaster() {
<div>
<input
type="checkbox"
checked={!selectedCustomRelays || selectedCustomRelays.includes(r)}
checked={!selected || selected.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),
),
),
setSelected(
e.target.checked && selected && selected.length == a.length - 1
? undefined
: a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))),
)
}
/>
@ -70,19 +61,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

@ -9,7 +9,7 @@ import BackButton from "Element/BackButton";
import Note from "Element/Note";
import NoteGhost from "Element/NoteGhost";
import Collapsed from "Element/Collapsed";
import { ThreadContext, ThreadContextWrapper } from "Hooks/useThreadContext";
import { ThreadContext, ThreadContextWrapper, chainKey } from "Hooks/useThreadContext";
import messages from "./messages";
@ -297,14 +297,35 @@ export function Thread(props: { onBack?: () => void }) {
description: "Navigate back button on threads view",
});
const debug = window.location.search.includes("debug=true");
return (
<>
{debug && (
<div className="main-content p xs">
<h1>Chains</h1>
<pre>
{JSON.stringify(
Object.fromEntries([...thread.chains.entries()].map(([k, v]) => [k, v.map(c => c.id)])),
undefined,
" ",
)}
</pre>
<h1>Current</h1>
<pre>{JSON.stringify(thread.current)}</pre>
<h1>Root</h1>
<pre>{JSON.stringify(thread.root, undefined, " ")}</pre>
<h1>Data</h1>
<pre>{JSON.stringify(thread.data, undefined, " ")}</pre>
<h1>Reactions</h1>
<pre>{JSON.stringify(thread.reactions, undefined, " ")}</pre>
</div>
)}
<div className="main-content p">
<BackButton onClick={goBack} text={parent ? parentText : backText} />
</div>
<div className="main-content">
{thread.root && renderRoot(thread.root)}
{thread.root && renderChain(thread.root.id)}
{thread.root && renderChain(chainKey(thread.root))}
</div>
</>
);

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

@ -1,31 +0,0 @@
import { RequestBuilder, EventKind, NoteCollection, NostrLink, NostrPrefix } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import useLogin from "Hooks/useLogin";
import { useMemo } from "react";
export function useReactions(subId: string, ids: Array<NostrLink>, others?: (rb: RequestBuilder) => void) {
const { preferences: pref } = useLogin();
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) {
const f = rb
.withFilter()
.kinds(
pref.enableReactions
? [EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]
: [EventKind.ZapReceipt, EventKind.Repost],
);
aTags.forEach(v => f.replyToLink(v));
eTags.forEach(v => f.replyToLink(v));
}
others?.(rb);
return rb.numFilters > 0 ? rb : null;
}, [ids]);
return useRequestBuilder(NoteCollection, sub);
}

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

@ -0,0 +1,37 @@
import { RequestBuilder, EventKind, NoteCollection, NostrLink } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import useLogin from "Hooks/useLogin";
import { useMemo } from "react";
export function useReactions(subId: string, ids: Array<NostrLink>, others?: (rb: RequestBuilder) => void) {
const { preferences: pref } = useLogin();
const sub = useMemo(() => {
const rb = new RequestBuilder(subId);
if (ids.length > 0) {
const grouped = ids.reduce(
(acc, v) => {
acc[v.type] ??= [];
acc[v.type].push(v);
return acc;
},
{} as Record<string, Array<NostrLink>>,
);
for (const [, v] of Object.entries(grouped)) {
rb.withFilter()
.kinds(
pref.enableReactions
? [EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]
: [EventKind.ZapReceipt, EventKind.Repost],
)
.replyToLink(v);
}
}
others?.(rb);
return rb.numFilters > 0 ? rb : null;
}, [ids]);
return useRequestBuilder(NoteCollection, sub);
}

View File

@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { EventKind, NostrLink, RequestBuilder, NoteCollection } from "@snort/system";
import { EventKind, NostrLink, RequestBuilder, NoteCollection, EventExt } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useReactions } from "./FeedReactions";
import { useReactions } from "./Reactions";
export default function useThreadFeed(link: NostrLink) {
const [root, setRoot] = useState<NostrLink>();
const [allEvents, setAllEvents] = useState<Array<NostrLink>>([]);
const sub = useMemo(() => {
@ -12,10 +13,22 @@ export default function useThreadFeed(link: NostrLink) {
sub.withOptions({
leaveOpen: true,
});
sub.withFilter().kinds([EventKind.TextNote]).link(link).replyToLink(link);
allEvents.forEach(x => {
sub.withFilter().kinds([EventKind.TextNote]).link(x).replyToLink(x);
});
sub.withFilter().link(link);
if (root) {
sub.withFilter().link(root);
}
const grouped = [link, ...allEvents].reduce(
(acc, v) => {
acc[v.type] ??= [];
acc[v.type].push(v);
return acc;
},
{} as Record<string, Array<NostrLink>>,
);
for (const [, v] of Object.entries(grouped)) {
sub.withFilter().kinds([EventKind.TextNote]).replyToLink(v);
}
return sub;
}, [allEvents.length]);
@ -23,14 +36,31 @@ 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)),
])
.flat();
setAllEvents(links);
const current = store.data.find(a => link.matchesEvent(a));
if (current) {
const t = EventExt.extractThread(current);
if (t) {
const rootOrReplyAsRoot = t?.root ?? t?.replyTo;
if (rootOrReplyAsRoot) {
setRoot(
NostrLink.fromTag([
rootOrReplyAsRoot.key,
rootOrReplyAsRoot.value ?? "",
rootOrReplyAsRoot.relay ?? "",
...(rootOrReplyAsRoot.marker ?? []),
]),
);
}
}
}
}
}, [store.data?.length]);

View File

@ -7,7 +7,7 @@ export default function useZapsFeed(link?: NostrLink) {
const sub = useMemo(() => {
if (!link) return null;
const b = new RequestBuilder(`zaps:${link.encode()}`);
b.withFilter().kinds([EventKind.ZapReceipt]).replyToLink(link);
b.withFilter().kinds([EventKind.ZapReceipt]).replyToLink([link]);
return b;
}, [link]);

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,20 @@
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";
import { unwrap } from "@snort/shared";
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 +26,8 @@ export default function useLoginHandler() {
}
const hexKey = bech32ToHex(key);
if (hexKey.length === 64) {
LoginStore.loginWithPrivateKey(hexKey);
if (!pin) throw new PinRequiredError();
LoginStore.loginWithPrivateKey(await PinEncrypted.create(hexKey, pin));
} else {
throw new Error("INVALID PRIVATE KEY");
}
@ -31,14 +35,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(await 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(await PinEncrypted.create(key, pin));
}
// public key logins
@ -49,11 +55,18 @@ export default function useLoginHandler() {
const hexKey = await getNip05PubKey(key);
LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey);
} else if (key.startsWith("bunker://")) {
if (!pin) throw new PinRequiredError();
const nip46 = new Nip46Signer(key);
await nip46.init();
const loginPubkey = await nip46.getPubKey();
LoginStore.loginWithPubkey(loginPubkey, LoginSessionType.Nip46, undefined, nip46.relays, nip46.privateKey);
LoginStore.loginWithPubkey(
loginPubkey,
LoginSessionType.Nip46,
undefined,
nip46.relays,
await PinEncrypted.create(unwrap(nip46.privateKey), pin),
);
nip46.close();
} else {
throw new Error("INVALID PRIVATE KEY");

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,7 +1,7 @@
/* eslint-disable no-debugger */
import { unwrap } from "@snort/shared";
import { EventExt, NostrLink, NostrPrefix, TaggedNostrEvent, u256, Thread as ThreadInfo } from "@snort/system";
import { EventExt, NostrLink, TaggedNostrEvent, u256 } from "@snort/system";
import useThreadFeed from "Feed/ThreadFeed";
import { findTag } from "SnortUtils";
import { ReactNode, createContext, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
@ -10,14 +10,31 @@ export interface ThreadContext {
root?: TaggedNostrEvent;
chains: Map<string, Array<TaggedNostrEvent>>;
data: Array<TaggedNostrEvent>;
reactions: Array<TaggedNostrEvent>;
setCurrent: (i: string) => void;
}
export const ThreadContext = createContext({} as ThreadContext);
/**
* Get the chain key as a reply event
*/
export function replyChainKey(ev: TaggedNostrEvent) {
const t = EventExt.extractThread(ev);
return t?.replyTo?.value ?? t?.root?.value;
}
/**
* Get the chain key of this event
*/
export function chainKey(ev: TaggedNostrEvent) {
const link = NostrLink.fromEvent(ev);
return unwrap(link.toEventTag())[1];
}
export function ThreadContextWrapper({ link, children }: { link: NostrLink; children?: ReactNode }) {
const location = useLocation();
const [currentId, setCurrentId] = useState(link.id);
const [currentId, setCurrentId] = useState(unwrap(link.toEventTag())[1]);
const feed = useThreadFeed(link);
const chains = useMemo(() => {
@ -26,21 +43,12 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
feed.thread
?.sort((a, b) => b.created_at - a.created_at)
.forEach(v => {
const t = EventExt.extractThread(v);
if (t) {
let replyTo = t.replyTo?.value ?? t.root?.value;
if (t.root?.key === "a" && t.root?.value) {
const parsed = t.root.value.split(":");
replyTo = feed.thread?.find(
a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2],
)?.id;
}
if (replyTo) {
if (!chains.has(replyTo)) {
chains.set(replyTo, [v]);
} else {
unwrap(chains.get(replyTo)).push(v);
}
const replyTo = replyChainKey(v);
if (replyTo) {
if (!chains.has(replyTo)) {
chains.set(replyTo, [v]);
} else {
unwrap(chains.get(replyTo)).push(v);
}
}
});
@ -51,58 +59,27 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
// Root is the parent of the current note or the current note if its a root note or the root of the thread
const root = useMemo(() => {
const currentNote =
feed.thread?.find(
ne =>
ne.id === currentId ||
(link.type === NostrPrefix.Address && findTag(ne, "d") === currentId && ne.pubkey === link.author),
) ?? (location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined);
feed.thread?.find(a => chainKey(a) === currentId) ??
(location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined);
if (currentNote) {
const currentThread = EventExt.extractThread(currentNote);
const isRoot = (ne?: ThreadInfo) => ne === undefined;
if (isRoot(currentThread)) {
const key = replyChainKey(currentNote);
if (key) {
return feed.thread?.find(a => chainKey(a) === key);
} else {
return currentNote;
}
const replyTo = currentThread?.replyTo ?? currentThread?.root;
// sometimes the root event ID is missing, and we can only take the happy path if the root event ID exists
if (replyTo) {
if (replyTo.key === "a" && replyTo.value) {
const parsed = replyTo.value.split(":");
return feed.thread?.find(
a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2],
);
}
if (replyTo.value) {
return feed.thread?.find(a => a.id === replyTo.value);
}
}
const possibleRoots = feed.thread?.filter(a => {
const thread = EventExt.extractThread(a);
return isRoot(thread);
});
if (possibleRoots) {
// worst case we need to check every possible root to see which one contains the current note as a child
for (const ne of possibleRoots) {
const children = chains.get(ne.id) ?? [];
if (children.find(ne => ne.id === currentId)) {
return ne;
}
}
}
}
}, [feed.thread.length, currentId, location]);
const ctxValue = useMemo(() => {
const ctxValue = useMemo<ThreadContext>(() => {
return {
current: currentId,
root,
chains,
data: feed.reactions,
reactions: feed.reactions,
data: feed.thread,
setCurrent: v => setCurrentId(v),
} as ThreadContext;
};
}, [root, chains]);
return <ThreadContext.Provider value={ctxValue}>{children}</ThreadContext.Provider>;

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,8 @@ export async function generateNewLogin() {
const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey], newRelays);
System.BroadcastEvent(ev);
LoginStore.loginWithPrivateKey(privateKey, entropy, newRelays);
const key = await PinEncrypted.create(privateKey, pin);
LoginStore.loginWithPrivateKey(key, entropy, newRelays);
}
export function generateRandomKey() {
@ -161,3 +164,34 @@ 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?: PinEncrypted) {
switch (l.type) {
case LoginSessionType.PrivateKey: {
if (!pin) throw new PinRequiredError();
l.privateKeyData = pin;
return EventPublisher.privateKey(pin.value);
}
case LoginSessionType.Nip46: {
if (!pin) throw new PinRequiredError();
l.privateKeyData = pin;
const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`);
const inner = new PrivateKeySigner(pin.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,32 +65,41 @@ 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 => ({
pubkey: unwrap(v.publicKey),
id: v.id,
}));
}
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,
relays?: Record<string, RelaySettings>,
remoteSignerRelays?: Array<string>,
privateKey?: string,
privateKey?: PinEncrypted,
) {
if (this.#accounts.has(key)) {
throw new Error("Already logged in with this pubkey");
@ -104,6 +107,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
const initRelays = this.decideInitRelays(relays);
const newSession = {
...LoggedOut,
id: uuid(),
type,
publicKey: key,
relays: {
@ -112,12 +116,15 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
},
preferences: deepClone(DefaultPreferences),
remoteSignerRelays,
privateKey,
privateKeyData: 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 +136,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 +157,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();
@ -186,62 +194,8 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
return { ...s };
}
#createPublisher(l: LoginSession) {
switch (l.type) {
case LoginSessionType.PrivateKey: {
return EventPublisher.privateKey(unwrap(l.privateKey));
}
case LoginSessionType.Nip46: {
const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`);
const inner = new PrivateKeySigner(unwrap(l.privateKey));
const nip46 = new Nip46Signer(`bunker://${unwrap(l.publicKey)}?${[...relayArgs].join("&")}`, inner);
return new EventPublisher(nip46, unwrap(l.publicKey));
}
case LoginSessionType.Nip7os: {
return new EventPublisher(new Nip7OsSigner(), unwrap(l.publicKey));
}
default: {
if (l.publicKey) {
return new EventPublisher(new Nip7Signer(), l.publicKey);
}
}
}
}
#migrate() {
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 +213,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 +229,21 @@ 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 = [];
for (const v of this.#accounts.values()) {
if (v.privateKeyData instanceof PinEncrypted) {
toSave.push({
...v,
privateKeyData: v.privateKeyData.toPayload(),
});
} else {
toSave.push({ ...v });
}
}
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,34 +20,17 @@ 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 [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));
}, [location, isReplyNoteCreatorShowing]);
const shouldHideHeader = useMemo(() => {
const hideOn = ["/login", "/new"];
return hideOn.some(a => location.pathname.startsWith(a));
@ -67,43 +47,51 @@ export default function Layout() {
}, [location]);
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 ? (
<>
<div className={pageClass}>
{!shouldHideHeader && (
<header className="main-content">
<LogoHeader />
<AccountHeader />
) : (
<button type="button" onClick={() => navigate("/login")}>
<FormattedMessage {...messages.Login} />
</button>
)}
</header>
)}
<Outlet />
{!shouldHideNoteCreator && (
<>
<button className="primary note-create-button" onClick={handleNoteCreatorButtonClick}>
<Icon name="plus" size={16} />
</button>
<NoteCreator />
</>
)}
<Toaster />
</div>
</header>
)}
<Outlet />
<NoteCreatorButton />
<Toaster />
</div>
<LoginUnlock />
</>
);
}
const NoteCreatorButton = () => {
const location = useLocation();
const { show, replyTo, update } = useNoteCreator(v => ({ show: v.show, replyTo: v.replyTo, update: v.update }));
const shouldHideNoteCreator = useMemo(() => {
const isReplyNoteCreatorShowing = replyTo && show;
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/e", "/subscribe"];
return isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a));
}, [location]);
if (shouldHideNoteCreator) return;
return (
<>
<button
className="primary note-create-button"
onClick={() =>
update(v => {
v.replyTo = undefined;
v.show = true;
})
}>
<Icon name="plus" size={16} />
</button>
<NoteCreator key="global-note-creator" />
</>
);
};
const AccountHeader = () => {
const navigate = useNavigate();
const { formatMessage } = useIntl();
@ -156,6 +144,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 +194,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

@ -3,22 +3,22 @@ import "./LoginPage.css";
import { CSSProperties, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useIntl, FormattedMessage } from "react-intl";
import { HexKey, Nip46Signer, PrivateKeySigner } from "@snort/system";
import { HexKey, Nip46Signer, PinEncrypted, PrivateKeySigner } from "@snort/system";
import { bech32ToHex, getPublicKey, unwrap } from "SnortUtils";
import ZapButton from "Element/ZapButton";
import useImgProxy from "Hooks/useImgProxy";
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 {
@ -75,9 +75,10 @@ export async function getNip05PubKey(addr: string): Promise<string> {
export default function LoginPage() {
const navigate = useNavigate();
const login = useLogin();
const [key, setKey] = useState("");
const [nip46Key, setNip46Key] = useState("");
const [error, setError] = useState("");
const [pin, setPin] = useState(false);
const [art, setArt] = useState<ArtworkEntry>();
const [isMasking, setMasking] = useState(true);
const { formatMessage } = useIntl();
@ -87,22 +88,19 @@ export default function LoginPage() {
const hasSubtleCrypto = window.crypto.subtle !== undefined;
const [nostrConnect, setNostrConnect] = useState("");
useEffect(() => {
if (login.publicKey) {
navigate("/");
}
}, [login, navigate]);
useEffect(() => {
const ret = unwrap(Artwork.at(Artwork.length * Math.random()));
const url = proxy(ret.link);
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 +114,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() {
@ -127,9 +131,10 @@ export default function LoginPage() {
"getRelays" in unwrap(window.nostr) ? await unwrap(window.nostr?.getRelays).call(window.nostr) : undefined;
const pubKey = await unwrap(window.nostr).getPublicKey();
LoginStore.loginWithPubkey(pubKey, LoginSessionType.Nip7, relays);
navigate("/");
}
async function startNip46() {
function generateNip46() {
const meta = {
name: "Snort",
url: window.location.href,
@ -142,26 +147,51 @@ export default function LoginPage() {
`metadata=${encodeURIComponent(JSON.stringify(meta))}`,
].join("&")}`;
setNostrConnect(connectUrl);
setNip46Key(newKey);
}
const signer = new Nip46Signer(connectUrl, new PrivateKeySigner(newKey));
async function startNip46(pin: string) {
if (!nostrConnect || !nip46Key) return;
const signer = new Nip46Signer(nostrConnect, new PrivateKeySigner(nip46Key));
await signer.init();
await delay(500);
await signer.describe();
LoginStore.loginWithPubkey(
await signer.getPubKey(),
LoginSessionType.Nip46,
undefined,
["wss://relay.damus.io"],
await PinEncrypted.create(nip46Key, pin),
);
navigate("/");
}
function nip46Buttons() {
return null;
return (
<>
<AsyncButton type="button" onClick={startNip46}>
<FormattedMessage defaultMessage="Nostr Connect (NIP-46)" description="Login button for NIP-46 signer app" />
<AsyncButton
type="button"
onClick={() => {
generateNip46();
setPin(true);
}}>
<FormattedMessage defaultMessage="Nostr Connect" description="Login button for NIP-46 signer app" />
</AsyncButton>
{nostrConnect && (
<Modal onClose={() => setNostrConnect("")}>
<div className="flex f-col">
<QrCode data={nostrConnect} />
<Copy text={nostrConnect} />
</div>
{nostrConnect && !pin && (
<Modal id="nostr-connect" onClose={() => setNostrConnect("")}>
<>
<h2>
<FormattedMessage defaultMessage="Nostr Connect" />
</h2>
<p>
<FormattedMessage defaultMessage="Scan this QR code with your signer app to get started" />
</p>
<div className="flex-column f-center g12">
<QrCode data={nostrConnect} />
<Copy text={nostrConnect} />
</div>
</>
</Modal>
)}
</>
@ -177,7 +207,7 @@ export default function LoginPage() {
<>
<AsyncButton type="button" onClick={doNip07Login}>
<FormattedMessage
defaultMessage="Login with Extension (NIP-07)"
defaultMessage="Nostr Extension"
description="Login button for NIP7 key manager extension"
/>
</AsyncButton>
@ -258,7 +288,7 @@ export default function LoginPage() {
<p dir="auto">
<FormattedMessage defaultMessage="Your key" description="Label for key input" />
</p>
<div className="flex">
<div className="flex f-center g8">
<input
dir="auto"
type={isMasking ? "password" : "text"}
@ -271,7 +301,7 @@ export default function LoginPage() {
<Icon
name={isMasking ? "openeye" : "closedeye"}
size={30}
className="highlight btn-sm pointer"
className="highlight pointer"
onClick={() => setMasking(!isMasking)}
/>
</div>
@ -283,12 +313,32 @@ 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
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 => {
setPin(false);
if (key) {
await doLogin(pin);
} else if (nostrConnect) {
await startNip46(pin);
} else {
await 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

@ -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" />
@ -473,7 +473,7 @@ export default function ProfilePage() {
navigate(
`/messages/${encodeTLVEntries("chat4" as NostrPrefix, {
type: TLVEntryType.Author,
length: 64,
length: 32,
value: id,
})}`,
)

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

@ -1,57 +1,32 @@
import { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import ProfilePreview from "Element/ProfilePreview";
import { LoginStore } from "Login";
import useLoginHandler from "Hooks/useLoginHandler";
import AsyncButton from "Element/AsyncButton";
import { getActiveSubscriptions } from "Subscription";
export default function AccountsPage() {
const { formatMessage } = useIntl();
const [key, setKey] = useState("");
const [error, setError] = useState("");
const loginHandler = useLoginHandler();
const logins = LoginStore.getSessions();
const sub = getActiveSubscriptions(LoginStore.allSubscriptions());
async function doLogin() {
try {
setError("");
await loginHandler.doLogin(key);
setKey("");
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError(
formatMessage({
defaultMessage: "Unknown login error",
}),
);
}
console.error(e);
}
}
return (
<>
<div className="flex-column g12">
<h3>
<FormattedMessage defaultMessage="Logins" />
</h3>
{logins.map(a => (
<div className="card flex" key={a}>
<div className="card flex" key={a.id}>
<ProfilePreview
pubkey={a}
pubkey={a.pubkey}
options={{
about: false,
}}
actions={
<div className="f-1">
<button className="mb10" onClick={() => LoginStore.switchAccount(a)}>
<button className="mb10" onClick={() => LoginStore.switchAccount(a.id)}>
<FormattedMessage defaultMessage="Switch" />
</button>
<button onClick={() => LoginStore.removeSession(a)}>
<button onClick={() => LoginStore.removeSession(a.id)}>
<FormattedMessage defaultMessage="Logout" />
</button>
</div>
@ -61,27 +36,12 @@ export default function AccountsPage() {
))}
{sub && (
<>
<h3>
<Link to={"/login"}>
<button type="button">
<FormattedMessage defaultMessage="Add Account" />
</h3>
<div className="flex">
<input
dir="auto"
type="text"
placeholder={formatMessage({
defaultMessage: "nsec, npub, nip-05, hex, mnemonic",
})}
className="f-grow mr10"
onChange={e => setKey(e.target.value)}
/>
<AsyncButton onClick={() => doLogin()}>
<FormattedMessage defaultMessage="Login" />
</AsyncButton>
</div>
</>
</button>
</Link>
)}
{error && <b className="error">{error}</b>}
</>
</div>
);
}

View File

@ -1,6 +1,6 @@
import "./Keys.css";
import { FormattedMessage } from "react-intl";
import { encodeTLV, NostrPrefix } from "@snort/system";
import { encodeTLV, NostrPrefix, PinEncrypted } from "@snort/system";
import Copy from "Element/Copy";
import useLogin from "Hooks/useLogin";
@ -8,7 +8,7 @@ import { hexToMnemonic } from "nip6";
import { hexToBech32 } from "SnortUtils";
export default function ExportKeys() {
const { publicKey, privateKey, generatedEntropy } = useLogin();
const { publicKey, privateKeyData, generatedEntropy } = useLogin();
return (
<div className="flex-column g12">
<h3>
@ -16,12 +16,12 @@ export default function ExportKeys() {
</h3>
<Copy text={hexToBech32("npub", publicKey ?? "")} className="dashed" />
<Copy text={encodeTLV(NostrPrefix.Profile, publicKey ?? "")} className="dashed" />
{privateKey && (
{privateKeyData instanceof PinEncrypted && (
<>
<h3>
<FormattedMessage defaultMessage="Private Key" />
</h3>
<Copy text={hexToBech32("nsec", privateKey)} className="dashed" />
<Copy text={hexToBech32("nsec", privateKeyData.value)} className="dashed" />
</>
)}
{generatedEntropy && (

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,94 @@
import { ExternalStore } from "@snort/shared";
import { NostrEvent, TaggedNostrEvent } from "@snort/system";
import { ZapTarget } from "Zapper";
import { useSyncExternalStore } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector";
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(this.#data);
},
update: (fn: (v: NoteCreatorDataSnapshot) => void) => {
fn(this.#data);
this.notifyChange(this.#data);
},
};
}
#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);
this.notifyChange(this.#data);
},
} as NoteCreatorDataSnapshot;
return sn;
}
}
const NoteCreatorState = new NoteCreatorStore();
export function useNoteCreator<T extends object = NoteCreatorDataSnapshot>(
selector?: (v: NoteCreatorDataSnapshot) => T,
) {
if (selector) {
return useSyncExternalStoreWithSelector<NoteCreatorDataSnapshot, T>(
c => NoteCreatorState.hook(c),
() => NoteCreatorState.snapshot(),
undefined,
selector,
);
} else {
return useSyncExternalStore<T>(
c => NoteCreatorState.hook(c),
() => NoteCreatorState.snapshot() as T,
);
}
}

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

@ -8,6 +8,7 @@ import {
TLVEntryType,
encodeTLVEntries,
TaggedNostrEvent,
decodeTLV,
} from "@snort/system";
import { Chat, ChatSystem, ChatType, inChatWith, lastReadInChat } from "chat";
import { debug } from "debug";
@ -59,26 +60,32 @@ export class Nip4ChatSystem extends ExternalStore<Array<Chat>> implements ChatSy
{} as Record<string, Array<NostrEvent>>,
);
return [...Object.entries(chats)].map(([k, v]) => Nip4ChatSystem.createChatObj(k, v));
return [...Object.entries(chats)].map(([k, v]) =>
Nip4ChatSystem.createChatObj(
encodeTLVEntries("chat4" as NostrPrefix, {
type: TLVEntryType.Author,
value: k,
length: 32,
}),
v,
),
);
}
static createChatObj(id: string, messages: Array<NostrEvent>) {
const last = lastReadInChat(id);
const participants = decodeTLV(id)
.filter(v => v.type === TLVEntryType.Author)
.map(v => ({
type: "pubkey",
id: v.value as string,
}));
return {
type: ChatType.DirectMessage,
id: encodeTLVEntries("chat4" as NostrPrefix, {
type: TLVEntryType.Author,
value: id,
length: 0,
}),
id,
unread: messages.reduce((acc, v) => (v.created_at > last ? acc++ : acc), 0),
lastMessage: messages.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0),
participants: [
{
type: "pubkey",
id: id,
},
],
participants,
messages: messages.map(m => ({
id: m.id,
created_at: m.created_at,
@ -91,7 +98,7 @@ export class Nip4ChatSystem extends ExternalStore<Array<Chat>> implements ChatSy
},
})),
createMessage: async (msg, pub) => {
return [await pub.sendDm(msg, id)];
return await Promise.all(participants.map(v => pub.sendDm(msg, v.id)));
},
sendMessage: (ev, system: SystemInterface) => {
ev.forEach(a => system.BroadcastEvent(a));

View File

@ -46,6 +46,7 @@
--header-padding-tb: 10px;
--btn-color: #fff;
--primary-gradient: linear-gradient(90deg, rgba(239, 150, 68, 1) 0%, rgba(123, 65, 246, 1) 100%);
--cashu-gradient: linear-gradient(90deg, #40b039, #adff2a);
}
::-webkit-scrollbar {
@ -132,10 +133,26 @@ code {
}
}
.bg-primary {
background: var(--primary-gradient);
}
.br {
border-radius: 16px;
}
.p {
padding: 12px 16px;
}
.p24 {
padding: 24px;
}
.uppercase {
text-transform: uppercase;
}
.card {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
@ -522,7 +539,7 @@ div.form-col {
height: 100vh;
}
small.xs {
.xs {
font-size: small;
}

View File

@ -7,24 +7,13 @@ 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,
NostrSystem,
ProfileLoaderService,
Nip7Signer,
PowWorker,
QueryOptimizer,
FlatReqFilter,
ReqFilter,
} from "@snort/system";
import { NostrSystem, ProfileLoaderService, PowWorker, QueryOptimizer, FlatReqFilter, ReqFilter } from "@snort/system";
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";
@ -73,13 +62,9 @@ export const System = new NostrSystem({
relayMetrics: RelayMetrics,
queryOptimizer: WasmQueryOptimizer,
authHandler: async (c, r) => {
const { publicKey, privateKey } = LoginStore.snapshot();
if (privateKey) {
const pub = EventPublisher.privateKey(privateKey);
return await pub.nip42Auth(c, r);
}
if (publicKey) {
const pub = new EventPublisher(new Nip7Signer(), publicKey);
const { id } = LoginStore.snapshot();
const pub = LoginStore.getPublisher(id);
if (pub) {
return await pub.nip42Auth(c, r);
}
},
@ -218,12 +203,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

@ -142,6 +142,9 @@
"3yk8fB": {
"defaultMessage": "Wallet"
},
"40VR6s": {
"defaultMessage": "Nostr Connect"
},
"450Fty": {
"defaultMessage": "None"
},
@ -190,6 +193,12 @@
"5ykRmX": {
"defaultMessage": "Send zap"
},
"6/SF6e": {
"defaultMessage": "<h1>{n}</h1> Cashu sats"
},
"6/hB3S": {
"defaultMessage": "Watch Replay"
},
"65BmHb": {
"defaultMessage": "Failed to proxy image from {host}, click here to load directly"
},
@ -223,6 +232,9 @@
"89q5wc": {
"defaultMessage": "Confirm Reposts"
},
"8Kboo2": {
"defaultMessage": "Scan this QR code with your signer app to get started"
},
"8QDesP": {
"defaultMessage": "Zap {n} sats"
},
@ -238,6 +250,10 @@
"8v1NN+": {
"defaultMessage": "Pairing phrase"
},
"8xNnhi": {
"defaultMessage": "Nostr Extension",
"description": "Login button for NIP7 key manager extension"
},
"9+Ddtu": {
"defaultMessage": "Next"
},
@ -352,6 +368,9 @@
"Dh3hbq": {
"defaultMessage": "Auto Zap"
},
"Dn82AL": {
"defaultMessage": "Live"
},
"DtYelJ": {
"defaultMessage": "Transfer"
},
@ -424,6 +443,9 @@
"GL8aXW": {
"defaultMessage": "Bookmarks ({n})"
},
"GQPtfk": {
"defaultMessage": "Join Stream"
},
"GSye7T": {
"defaultMessage": "Lightning Address"
},
@ -449,9 +471,6 @@
"HAlOn1": {
"defaultMessage": "Name"
},
"HF4YnO": {
"defaultMessage": "Watch Live!"
},
"HFls6j": {
"defaultMessage": "name will be available later"
},
@ -539,6 +558,9 @@
"KoFlZg": {
"defaultMessage": "Enter mint URL"
},
"KtsyO0": {
"defaultMessage": "Enter Pin"
},
"LF5kYT": {
"defaultMessage": "Other Connections"
},
@ -557,6 +579,10 @@
"LwYmVi": {
"defaultMessage": "Zaps on this note will be split to the following users."
},
"M10zFV": {
"defaultMessage": "Nostr Connect",
"description": "Login button for NIP-46 signer app"
},
"M3Oirc": {
"defaultMessage": "Debug Menus"
},
@ -708,10 +734,6 @@
"SP0+yi": {
"defaultMessage": "Buy Subscription"
},
"SX58hM": {
"defaultMessage": "Copy",
"description": "Button: Copy Cashu token"
},
"SYQtZ7": {
"defaultMessage": "LN Address Proxy"
},
@ -730,8 +752,8 @@
"TDR5ge": {
"defaultMessage": "Media in notes will automatically be shown for selected people, otherwise only the link will show"
},
"TMfYfY": {
"defaultMessage": "Cashu token"
"TP/cMX": {
"defaultMessage": "Ended"
},
"TpgeGw": {
"defaultMessage": "Hex Salt..",
@ -743,9 +765,6 @@
"UDYlxu": {
"defaultMessage": "Pending Subscriptions"
},
"ULotH9": {
"defaultMessage": "Amount: {amount} sats"
},
"UT7Nkj": {
"defaultMessage": "New Chat"
},
@ -773,10 +792,6 @@
"VnXp8Z": {
"defaultMessage": "Avatar"
},
"VtPV/B": {
"defaultMessage": "Login with Extension (NIP-07)",
"description": "Login button for NIP7 key manager extension"
},
"VvaJst": {
"defaultMessage": "View Wallets"
},
@ -887,6 +902,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}"
},
@ -931,6 +949,9 @@
"e61Jf3": {
"defaultMessage": "Coming soon"
},
"e7VmYP": {
"defaultMessage": "Enter pin to unlock your private key"
},
"e7qqly": {
"defaultMessage": "Mark All Read"
},
@ -1000,10 +1021,6 @@
"hMzcSq": {
"defaultMessage": "Messages"
},
"hWSp+B": {
"defaultMessage": "Nostr Connect (NIP-46)",
"description": "Login button for NIP-46 signer app"
},
"hY4lzx": {
"defaultMessage": "Supports"
},
@ -1034,9 +1051,6 @@
"iNWbVV": {
"defaultMessage": "Handle"
},
"iUsU2x": {
"defaultMessage": "Mint: {url}"
},
"iXPL0Z": {
"defaultMessage": "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead"
},
@ -1230,6 +1244,9 @@
"qtWLmt": {
"defaultMessage": "Like"
},
"qz9fty": {
"defaultMessage": "Incorrect pin"
},
"r3C4x/": {
"defaultMessage": "Software"
},
@ -1410,5 +1427,8 @@
},
"zvCDao": {
"defaultMessage": "Automatically show latest notes"
},
"zwb6LR": {
"defaultMessage": "<b>Mint:</b> {url}"
}
}

View File

@ -46,6 +46,7 @@
"3tVy+Z": "{n} Followers",
"3xCwbZ": "OR",
"3yk8fB": "Wallet",
"40VR6s": "Nostr Connect",
"450Fty": "None",
"47FYwb": "Cancel",
"4IPzdn": "Primary Developers",
@ -62,6 +63,8 @@
"5u6iEc": "Transfer to Pubkey",
"5vMmmR": "Usernames are not unique on Nostr. The nostr address is your unique human-readable address that is unique to you upon registration.",
"5ykRmX": "Send zap",
"6/SF6e": "<h1>{n}</h1> Cashu sats",
"6/hB3S": "Watch Replay",
"65BmHb": "Failed to proxy image from {host}, click here to load directly",
"6OSOXl": "Reason: <i>{reason}</i>",
"6Yfvvp": "Get an identifier",
@ -73,11 +76,13 @@
"7hp70g": "NIP-05",
"8/vBbP": "Reposts ({n})",
"89q5wc": "Confirm Reposts",
"8Kboo2": "Scan this QR code with your signer app to get started",
"8QDesP": "Zap {n} sats",
"8Rkoyb": "Recipient",
"8Y6bZQ": "Invalid zap split: {input}",
"8g2vyB": "name too long",
"8v1NN+": "Pairing phrase",
"8xNnhi": "Nostr Extension",
"9+Ddtu": "Next",
"9HU8vw": "Reply",
"9SvQep": "Follows {n}",
@ -115,6 +120,7 @@
"DZzCem": "Show latest {n} notes",
"DcL8P+": "Supporter",
"Dh3hbq": "Auto Zap",
"Dn82AL": "Live",
"DtYelJ": "Transfer",
"E8a4yq": "Follow some popular accounts",
"ELbg9p": "Data Providers",
@ -139,6 +145,7 @@
"G1BGCg": "Select Wallet",
"GFOoEE": "Salt",
"GL8aXW": "Bookmarks ({n})",
"GQPtfk": "Join Stream",
"GSye7T": "Lightning Address",
"GUlSVG": "Claim your included Snort nostr address",
"Gcn9NQ": "Magnet Link",
@ -147,7 +154,6 @@
"H0JBH6": "Log Out",
"H6/kLh": "Order Paid!",
"HAlOn1": "Name",
"HF4YnO": "Watch Live!",
"HFls6j": "name will be available later",
"HOzFdo": "Muted",
"HWbkEK": "Clear cache and reload",
@ -177,12 +183,14 @@
"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",
"Lu5/Bj": "Open on Zapstr",
"Lw+I+J": "{n,plural,=0{{name} zapped} other{{name} & {n} others zapped}}",
"LwYmVi": "Zaps on this note will be split to the following users.",
"M10zFV": "Nostr Connect",
"M3Oirc": "Debug Menus",
"MBAYRO": "Shows \"Copy ID\" and \"Copy Event JSON\" in the context menu on each message",
"MI2jkA": "Not available:",
@ -232,18 +240,16 @@
"SMO+on": "Send zap to {name}",
"SOqbe9": "Update Lightning Address",
"SP0+yi": "Buy Subscription",
"SX58hM": "Copy",
"SYQtZ7": "LN Address Proxy",
"ShdEie": "Mark all read",
"Sjo1P4": "Custom",
"Ss0sWu": "Pay Now",
"StKzTE": "The author has marked this note as a <i>sensitive topic</i>",
"TDR5ge": "Media in notes will automatically be shown for selected people, otherwise only the link will show",
"TMfYfY": "Cashu token",
"TP/cMX": "Ended",
"TpgeGw": "Hex Salt..",
"Tpy00S": "People",
"UDYlxu": "Pending Subscriptions",
"ULotH9": "Amount: {amount} sats",
"UT7Nkj": "New Chat",
"UUPFlt": "Users must accept the content warning to show the content of your note.",
"Up5U7K": "Block",
@ -253,7 +259,6 @@
"VR5eHw": "Public key (npub/nprofile)",
"VlJkSk": "{n} muted",
"VnXp8Z": "Avatar",
"VtPV/B": "Login with Extension (NIP-07)",
"VvaJst": "View Wallets",
"Vx7Zm2": "How do keys work?",
"W1yoZY": "It looks like you dont have any subscriptions, you can get one {link}",
@ -290,6 +295,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!",
@ -304,6 +310,7 @@
"d7d0/x": "LN Address",
"dOQCL8": "Display name",
"e61Jf3": "Coming soon",
"e7VmYP": "Enter pin to unlock your private key",
"e7qqly": "Mark All Read",
"eHAneD": "Reaction emoji",
"eJj8HD": "Get Verified",
@ -327,7 +334,6 @@
"h8XMJL": "Badges",
"hK5ZDk": "the world",
"hMzcSq": "Messages",
"hWSp+B": "Nostr Connect (NIP-46)",
"hY4lzx": "Supports",
"hicxcO": "Show replies",
"hmZ3Bz": "Media",
@ -338,7 +344,6 @@
"iEoXYx": "DeepL translations",
"iGT1eE": "Prevent fake accounts from imitating you",
"iNWbVV": "Handle",
"iUsU2x": "Mint: {url}",
"iXPL0Z": "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead",
"ieGrWo": "Follow",
"itPgxd": "Profile",
@ -402,6 +407,7 @@
"qkvYUb": "Add to Profile",
"qmJ8kD": "Translation failed",
"qtWLmt": "Like",
"qz9fty": "Incorrect pin",
"r3C4x/": "Software",
"r5srDR": "Enter wallet password",
"rT14Ow": "Add Relays",
@ -460,5 +466,6 @@
"zcaOTs": "Zap amount in sats",
"zjJZBd": "You're ready!",
"zonsdq": "Failed to load LNURL service",
"zvCDao": "Automatically show latest notes"
"zvCDao": "Automatically show latest notes",
"zwb6LR": "<b>Mint:</b> {url}"
}

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>) {

View File

@ -24,6 +24,8 @@ fn criterion_benchmark(c: &mut Criterion) {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -39,6 +41,8 @@ fn criterion_benchmark(c: &mut Criterion) {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,

View File

@ -28,6 +28,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -42,6 +44,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -63,6 +67,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -78,6 +84,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -92,6 +100,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -111,6 +121,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -130,6 +142,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -144,6 +158,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -162,6 +178,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,

View File

@ -29,6 +29,10 @@ pub struct ReqFilter {
pub d_tag: Option<HashSet<String>>,
#[serde(rename = "#r", skip_serializing_if = "Option::is_none")]
pub r_tag: Option<HashSet<String>>,
#[serde(rename = "#a", skip_serializing_if = "Option::is_none")]
pub a_tag: Option<HashSet<String>>,
#[serde(rename = "#g", skip_serializing_if = "Option::is_none")]
pub g_tag: Option<HashSet<String>>,
#[serde(rename = "search", skip_serializing_if = "Option::is_none")]
pub search: Option<HashSet<String>>,
#[serde(rename = "since", skip_serializing_if = "Option::is_none")]
@ -64,6 +68,10 @@ pub struct FlatReqFilter {
pub d_tag: Option<String>,
#[serde(rename = "#r", skip_serializing_if = "Option::is_none")]
pub r_tag: Option<String>,
#[serde(rename = "#a", skip_serializing_if = "Option::is_none")]
pub a_tag: Option<String>,
#[serde(rename = "#g", skip_serializing_if = "Option::is_none")]
pub g_tag: Option<String>,
#[serde(rename = "search", skip_serializing_if = "Option::is_none")]
pub search: Option<String>,
#[serde(rename = "since", skip_serializing_if = "Option::is_none")]
@ -145,6 +153,8 @@ impl From<Vec<&FlatReqFilter>> for ReqFilter {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -159,6 +169,8 @@ impl From<Vec<&FlatReqFilter>> for ReqFilter {
array_prop_append(&x.t_tag, &mut acc.t_tag);
array_prop_append(&x.d_tag, &mut acc.d_tag);
array_prop_append(&x.r_tag, &mut acc.r_tag);
array_prop_append(&x.a_tag, &mut acc.a_tag);
array_prop_append(&x.g_tag, &mut acc.g_tag);
array_prop_append(&x.search, &mut acc.search);
acc.since = x.since;
acc.until = x.until;
@ -180,6 +192,8 @@ impl From<Vec<&ReqFilter>> for ReqFilter {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -194,6 +208,8 @@ impl From<Vec<&ReqFilter>> for ReqFilter {
array_prop_append_vec(&x.t_tag, &mut acc.t_tag);
array_prop_append_vec(&x.d_tag, &mut acc.d_tag);
array_prop_append_vec(&x.r_tag, &mut acc.r_tag);
array_prop_append_vec(&x.a_tag, &mut acc.a_tag);
array_prop_append_vec(&x.g_tag, &mut acc.g_tag);
array_prop_append_vec(&x.search, &mut acc.search);
acc.since = x.since;
acc.until = x.until;
@ -265,6 +281,20 @@ impl Into<Vec<FlatReqFilter>> for &ReqFilter {
.collect();
inputs.push(t_ids);
}
if let Some(a_tags) = &self.a_tag {
let t_ids = a_tags
.iter()
.map(|z| StringOrNumberEntry::String(("a_tag", z)))
.collect();
inputs.push(t_ids);
}
if let Some(g_tags) = &self.g_tag {
let t_ids = g_tags
.iter()
.map(|z| StringOrNumberEntry::String(("g_tag", z)))
.collect();
inputs.push(t_ids);
}
if let Some(search) = &self.search {
let t_ids = search
.iter()
@ -339,6 +369,22 @@ impl Into<Vec<FlatReqFilter>> for &ReqFilter {
}
None
}),
a_tag: p.iter().find_map(|q| {
if let StringOrNumberEntry::String((k, v)) = q {
if (*k).eq("a_tag") {
return Some((*v).to_string());
}
}
None
}),
g_tag: p.iter().find_map(|q| {
if let StringOrNumberEntry::String((k, v)) = q {
if (*k).eq("g_tag") {
return Some((*v).to_string());
}
}
None
}),
search: p.iter().find_map(|q| {
if let StringOrNumberEntry::String((k, v)) = q {
if (*k).eq("search") {
@ -355,6 +401,7 @@ impl Into<Vec<FlatReqFilter>> for &ReqFilter {
ret
}
}
impl Distance for ReqFilter {
fn distance(&self, b: &Self) -> u32 {
let mut ret = 0u32;
@ -367,6 +414,7 @@ impl Distance for ReqFilter {
ret += prop_dist_vec(&self.d_tag, &b.d_tag);
ret += prop_dist_vec(&self.r_tag, &b.r_tag);
ret += prop_dist_vec(&self.t_tag, &b.t_tag);
ret += prop_dist_vec(&self.a_tag, &b.a_tag);
ret += prop_dist_vec(&self.search, &b.search);
ret
@ -464,6 +512,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -471,7 +521,7 @@ mod tests {
e_tag: None,
};
let output : Vec<FlatReqFilter> = (&input).into();
let output: Vec<FlatReqFilter> = (&input).into();
let expected = vec![
FlatReqFilter {
author: Some("a".to_owned()),
@ -481,6 +531,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -495,6 +547,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -509,6 +563,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -523,6 +579,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -537,6 +595,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -551,6 +611,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -565,6 +627,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -579,6 +643,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -593,6 +659,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -607,6 +675,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -621,6 +691,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -635,6 +707,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -649,6 +723,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -663,6 +739,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -677,6 +755,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -691,6 +771,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -705,6 +787,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,
@ -719,6 +803,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: Some(99),
until: None,

View File

@ -74,6 +74,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
authors: Some(HashSet::from([
"kieran".to_string(),
"snort".to_string(),
@ -94,6 +96,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -109,6 +113,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -122,6 +128,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,

View File

@ -1,9 +1,9 @@
use crate::filter::CanMerge;
pub fn merge<'a, T, Z>(all: Vec<&'a T>) -> Vec<Z>
where
T: CanMerge,
for<'b> Z: CanMerge + From<Vec<&'a T>> + From<Vec<&'b Z>>,
where
T: CanMerge,
for<'b> Z: CanMerge + From<Vec<&'a T>> + From<Vec<&'b Z>>,
{
let mut ret: Vec<Z> = merge_once(all);
loop {
@ -17,9 +17,9 @@ where
}
fn merge_once<'a, T, Z>(all: Vec<&'a T>) -> Vec<Z>
where
T: CanMerge,
Z: From<Vec<&'a T>>,
where
T: CanMerge,
Z: From<Vec<&'a T>>,
{
let mut ret: Vec<Z> = vec![];
if all.is_empty() {
@ -66,6 +66,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -80,6 +82,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -94,6 +98,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -108,6 +114,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -122,6 +130,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -144,6 +154,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -158,6 +170,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -173,6 +187,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -192,6 +208,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -206,6 +224,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -220,6 +240,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -241,6 +263,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -255,6 +279,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -269,6 +295,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -283,6 +311,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -297,6 +327,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -311,6 +343,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -325,6 +359,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -339,6 +375,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -353,6 +391,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -373,6 +413,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -387,6 +429,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -401,6 +445,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -415,6 +461,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,
@ -429,6 +477,8 @@ mod tests {
t_tag: None,
d_tag: None,
r_tag: None,
a_tag: None,
g_tag: None,
search: None,
since: None,
until: None,

View File

@ -0,0 +1,69 @@
import { scryptAsync } from "@noble/hashes/scrypt";
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 = { 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;
}
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

@ -9,6 +9,7 @@ import {
HexKey,
Lists,
NostrEvent,
NostrLink,
NotSignedNostrEvent,
PowMiner,
PrivateKeySigner,
@ -185,10 +186,11 @@ 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([rootOrReplyAsRoot.key, rootOrReplyAsRoot.value ?? "", rootOrReplyAsRoot.relay ?? "", "root"]);
}
eb.tag(["e", replyTo.id, replyTo.relays?.[0] ?? "", "reply"]);
eb.tag([...(NostrLink.fromEvent(replyTo).toEventTag() ?? []), "reply"]);
eb.tag(["p", replyTo.pubkey]);
for (const pk of thread.pubKeys) {
@ -198,7 +200,7 @@ export class EventPublisher {
eb.tag(["p", pk]);
}
} else {
eb.tag(["e", replyTo.id, "", "reply"]);
eb.tag([...(NostrLink.fromEvent(replyTo).toEventTag() ?? []), "reply"]);
// dont tag self in replies
if (replyTo.pubkey !== this.#pubKey) {
eb.tag(["p", replyTo.pubkey]);

View File

@ -84,7 +84,7 @@ export function splitByWriteRelays(cache: RelayCache, filter: ReqFilter): Array<
},
});
}
debug("GOSSIP")("Picked %o", picked);
debug("GOSSIP")("Picked %O => %O", filter, picked);
return picked;
}
@ -119,7 +119,7 @@ export function splitFlatByWriteRelays(cache: RelayCache, input: Array<FlatReqFi
} as RelayTaggedFlatFilters);
}
debug("GOSSIP")("Picked %o", picked);
debug("GOSSIP")("Picked %d relays from %d filters", picked.length, input.length);
return picked;
}

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

@ -1,6 +1,6 @@
import debug from "debug";
import { v4 as uuid } from "uuid";
import { appendDedupe, sanitizeRelayUrl, unixNowMs, unwrap } from "@snort/shared";
import { appendDedupe, dedupe, sanitizeRelayUrl, unixNowMs, unwrap } from "@snort/shared";
import EventKind from "./event-kind";
import { NostrLink, NostrPrefix, SystemInterface } from "index";
@ -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 {
@ -219,9 +219,9 @@ export class RequestFilterBuilder {
return this;
}
tag(key: "e" | "p" | "d" | "t" | "r" | "a" | "g", value?: Array<string>) {
tag(key: "e" | "p" | "d" | "t" | "r" | "a" | "g" | string, value?: Array<string>) {
if (!value) return this;
this.#filter[`#${key}`] = appendDedupe(this.#filter[`#${key}`], value);
this.#filter[`#${key}`] = appendDedupe(this.#filter[`#${key}`] as Array<string>, value);
return this;
}
@ -239,28 +239,25 @@ export class RequestFilterBuilder {
this.tag("d", [link.id])
.kinds([unwrap(link.kind)])
.authors([unwrap(link.author)]);
link.relays?.forEach(v => this.relay(v));
} else {
this.ids([link.id]);
link.relays?.forEach(v => this.relay(v));
}
link.relays?.forEach(v => this.relay(v));
return this;
}
/**
* Get replies to link with e/a tags
*/
replyToLink(link: NostrLink) {
if (link.type === NostrPrefix.Address) {
this.tag("a", [`${link.kind}:${link.author}:${link.id}`]);
link.relays?.forEach(v => this.relay(v));
} else if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) {
this.tag("p", [link.id]);
link.relays?.forEach(v => this.relay(v));
} else {
this.tag("e", [link.id]);
link.relays?.forEach(v => this.relay(v));
}
replyToLink(links: Array<NostrLink>) {
const types = dedupe(links.map(a => a.type));
if (types.length > 1) throw new Error("Cannot add multiple links of different kinds");
const tags = links.map(a => unwrap(a.toEventTag()));
this.tag(
tags[0][0],
tags.map(v => v[1]),
);
return this;
}
@ -293,7 +290,7 @@ export class RequestFilterBuilder {
return [
{
filters: [this.filter],
filters: [this.#filter],
relay: "",
strategy: RequestStrategy.DefaultRelays,
},

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);
}
}

100
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
@ -2719,6 +2698,7 @@ __metadata:
"@types/node": ^20.4.1
"@types/react": ^18.0.26
"@types/react-dom": ^18.0.10
"@types/use-sync-external-store": ^0.0.4
"@types/uuid": ^9.0.2
"@types/webscopeio__react-textarea-autocomplete": ^4.7.2
"@types/webtorrent": ^0.109.3
@ -2751,7 +2731,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
@ -2762,6 +2741,7 @@ __metadata:
ts-loader: ^9.4.4
typescript: ^5.2.2
use-long-press: ^2.0.3
use-sync-external-store: ^1.2.0
uuid: ^9.0.0
webpack: ^5.88.2
webpack-bundle-analyzer: ^4.8.0
@ -3590,10 +3570,10 @@ __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
"@types/use-sync-external-store@npm:^0.0.4":
version: 0.0.4
resolution: "@types/use-sync-external-store@npm:0.0.4"
checksum: f8bf56b14f28fda6d0215281d50623d5affd17c549ba46bcfcfbb97c6301a583066c0477856260ae6feadcaa714c46cd45678e76b74da0f6f8b364aec07bd854
languageName: node
linkType: hard
@ -7614,13 +7594,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 +11246,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 +11463,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 +11593,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,7 +13340,7 @@ __metadata:
languageName: node
linkType: hard
"use-sync-external-store@npm:^1.0.0":
"use-sync-external-store@npm:^1.2.0":
version: 1.2.0
resolution: "use-sync-external-store@npm:1.2.0"
peerDependencies: