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": { "scripts": {
"build": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/system-react build && yarn workspace @snort/app build", "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", "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": { "devDependencies": {
"@cloudflare/workers-types": "^4.20230307.0", "@cloudflare/workers-types": "^4.20230307.0",

View File

@ -6,7 +6,6 @@
"@lightninglabs/lnc-web": "^0.2.3-alpha", "@lightninglabs/lnc-web": "^0.2.3-alpha",
"@noble/curves": "^1.0.0", "@noble/curves": "^1.0.0",
"@noble/hashes": "^1.2.0", "@noble/hashes": "^1.2.0",
"@reduxjs/toolkit": "^1.9.1",
"@scure/base": "^1.1.1", "@scure/base": "^1.1.1",
"@scure/bip32": "^1.3.0", "@scure/bip32": "^1.3.0",
"@scure/bip39": "^1.1.1", "@scure/bip39": "^1.1.1",
@ -15,6 +14,7 @@
"@snort/system-query": "workspace:*", "@snort/system-query": "workspace:*",
"@snort/system-react": "workspace:*", "@snort/system-react": "workspace:*",
"@szhsin/react-menu": "^3.3.1", "@szhsin/react-menu": "^3.3.1",
"@types/use-sync-external-store": "^0.0.4",
"@void-cat/api": "^1.0.4", "@void-cat/api": "^1.0.4",
"debug": "^4.3.4", "debug": "^4.3.4",
"dexie": "^3.2.4", "dexie": "^3.2.4",
@ -27,11 +27,11 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-intersection-observer": "^9.4.1", "react-intersection-observer": "^9.4.1",
"react-intl": "^6.4.4", "react-intl": "^6.4.4",
"react-redux": "^8.0.5",
"react-router-dom": "^6.5.0", "react-router-dom": "^6.5.0",
"react-textarea-autosize": "^8.4.0", "react-textarea-autosize": "^8.4.0",
"react-twitter-embed": "^4.0.4", "react-twitter-embed": "^4.0.4",
"use-long-press": "^2.0.3", "use-long-press": "^2.0.3",
"use-sync-external-store": "^1.2.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"workbox-core": "^6.4.2", "workbox-core": "^6.4.2",
"workbox-precaching": "^7.0.0", "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"/> <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> </g>
</symbol> </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> </defs>
</svg> </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 { UnwrappedGift, db } from "Db";
import { findTag, unwrap } from "SnortUtils"; import { findTag, unwrap } from "SnortUtils";
import { RefreshFeedCache } from "./RefreshFeedCache"; import { RefreshFeedCache } from "./RefreshFeedCache";
import { LoginSession } from "Login"; import { LoginSession, LoginSessionType } from "Login";
export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> { export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
constructor() { constructor() {
@ -15,7 +15,7 @@ export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
buildSub(session: LoginSession, rb: RequestBuilder): void { buildSub(session: LoginSession, rb: RequestBuilder): void {
const pubkey = session.publicKey; const pubkey = session.publicKey;
if (pubkey) { if (pubkey && session.type === LoginSessionType.PrivateKey) {
rb.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubkey]).since(this.newest()); rb.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubkey]).since(this.newest());
} }
} }

View File

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

View File

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

View File

@ -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 { 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 useLogin from "Hooks/useLogin";
import { useUserProfile } from "@snort/system-react"; import Icon from "Icons/Icon";
interface Token { interface Token {
token: Array<{ token: Array<{
@ -48,33 +50,87 @@ export default function CashuNuts({ token }: { token: string }) {
if (!cashu) return <>{token}</>; if (!cashu) return <>{token}</>;
const amount = cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
return ( return (
<div className="note-invoice"> <div className="cashu flex f-space p24 br">
<div className="flex f-between"> <div className="flex-column g8 f-ellipsis">
<div> <div className="flex f-center g16">
<h4> <svg width="30" height="39" viewBox="0 0 30 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<FormattedMessage defaultMessage="Cashu token" /> <g id="Group 47711">
</h4> <path
<p> id="Rectangle 585"
<FormattedMessage 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"
defaultMessage="Amount: {amount} sats" fill="url(#paint0_linear_1976_19241)"
values={{ />
amount: cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0), <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"
</p> fill="url(#paint1_linear_1976_19241)"
<small className="xs"> />
<FormattedMessage defaultMessage="Mint: {url}" values={{ url: cashu.token[0].mint }} /> <path
</small> id="Rectangle 586"
</div> 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"
<div> fill="url(#paint2_linear_1976_19241)"
<button onClick={e => copyToken(e, token)} className="mr5"> />
<FormattedMessage defaultMessage="Copy" description="Button: Copy Cashu token" /> </g>
</button> <defs>
<button onClick={e => redeemToken(e, token)}> <linearGradient
<FormattedMessage defaultMessage="Redeem" description="Button: Redeem Cashu token" /> id="paint0_linear_1976_19241"
</button> 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> </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>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,84 @@
import { NostrEvent, NostrLink } from "@snort/system"; import { NostrEvent, NostrLink } from "@snort/system";
import { findTag } from "SnortUtils";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom"; 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 }) { export function LiveEvent({ ev }: { ev: NostrEvent }) {
const title = findTag(ev, "title"); const title = findTag(ev, "title");
return ( const status = findTag(ev, "status");
<div className="text"> const starts = Number(findTag(ev, "starts"));
<div className="flex card"> const host = ev.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;
<div className="f-grow">
<h3>{title}</h3> function statusLine() {
</div> switch (status) {
<div> case "live": {
<Link to={`https://zap.stream/${NostrLink.fromEvent(ev).encode()}`}> return (
<button className="primary" type="button"> <div className="flex g4">
<FormattedMessage defaultMessage="Watch Live!" /> <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> </button>
</Link> </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> </div>
<div>{cta()}</div>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,31 +1,23 @@
import "./NoteCreator.css"; import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useDispatch, useSelector } from "react-redux"; import {
import { EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder, tryParseNostrLink, NostrLink } from "@snort/system"; EventKind,
NostrPrefix,
TaggedNostrEvent,
EventBuilder,
tryParseNostrLink,
NostrLink,
NostrEvent,
} from "@snort/system";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { openFile } from "SnortUtils"; import { openFile } from "SnortUtils";
import Textarea from "Element/Textarea"; import Textarea from "Element/Textarea";
import Modal from "Element/Modal"; import Modal from "Element/Modal";
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";
import useFileUpload from "Upload"; import useFileUpload from "Upload";
import Note from "Element/Note"; import Note from "Element/Note";
import {
setShow,
setNote,
setError,
setActive,
setPreview,
setShowAdvanced,
setSelectedCustomRelays,
setZapSplits,
setSensitive,
reset,
setPollOptions,
setOtherEvents,
} from "State/NoteCreator";
import type { RootState } from "State/Store";
import { ClipboardEventHandler } from "react"; import { ClipboardEventHandler } from "react";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
@ -34,37 +26,24 @@ import AsyncButton from "Element/AsyncButton";
import { AsyncIcon } from "Element/AsyncIcon"; import { AsyncIcon } from "Element/AsyncIcon";
import { fetchNip05Pubkey } from "@snort/shared"; import { fetchNip05Pubkey } from "@snort/shared";
import { ZapTarget } from "Zapper"; import { ZapTarget } from "Zapper";
import { useNoteCreator } from "State/NoteCreator";
export function NoteCreator() { export function NoteCreator() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const uploader = useFileUpload(); const uploader = useFileUpload();
const {
note,
zapSplits,
sensitive,
pollOptions,
replyTo,
otherEvents,
preview,
active,
show,
showAdvanced,
selectedCustomRelays,
error,
} = useSelector((s: RootState) => s.noteCreator);
const dispatch = useDispatch();
const login = useLogin(); const login = useLogin();
const note = useNoteCreator();
const relays = login.relays; const relays = login.relays;
async function buildNote() { async function buildNote() {
try { try {
dispatch(setError("")); note.update(v => (v.error = ""));
if (note && publisher) { if (note && publisher) {
let extraTags: Array<Array<string>> | undefined; let extraTags: Array<Array<string>> | undefined;
if (zapSplits) { if (note.zapSplits) {
const parsedSplits = [] as Array<ZapTarget>; const parsedSplits = [] as Array<ZapTarget>;
for (const s of zapSplits) { for (const s of note.zapSplits) {
if (s.value.startsWith(NostrPrefix.PublicKey) || s.value.startsWith(NostrPrefix.Profile)) { if (s.value.startsWith(NostrPrefix.PublicKey) || s.value.startsWith(NostrPrefix.Profile)) {
const link = tryParseNostrLink(s.value); const link = tryParseNostrLink(s.value);
if (link) { if (link) {
@ -114,43 +93,55 @@ export function NoteCreator() {
extraTags = parsedSplits.map(v => ["zap", v.value, "", String(v.weight)]); extraTags = parsedSplits.map(v => ["zap", v.value, "", String(v.weight)]);
} }
if (sensitive) { if (note.sensitive) {
extraTags ??= []; extraTags ??= [];
extraTags.push(["content-warning", sensitive]); extraTags.push(["content-warning", note.sensitive]);
} }
const kind = pollOptions ? EventKind.Polls : EventKind.TextNote; const kind = note.pollOptions ? EventKind.Polls : EventKind.TextNote;
if (pollOptions) { if (note.pollOptions) {
extraTags ??= []; extraTags ??= [];
extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a])); extraTags.push(...note.pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
} }
const hk = (eb: EventBuilder) => { const hk = (eb: EventBuilder) => {
extraTags?.forEach(t => eb.tag(t)); extraTags?.forEach(t => eb.tag(t));
eb.kind(kind); eb.kind(kind);
return eb; return eb;
}; };
const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk); const ev = note.replyTo
? await publisher.reply(note.replyTo, note.note, hk)
: await publisher.note(note.note, hk);
return ev; return ev;
} }
} catch (e) { } catch (e) {
if (e instanceof Error) { note.update(v => {
dispatch(setError(e.message)); if (e instanceof Error) {
} else { v.error = e.message;
dispatch(setError(e as string)); } else {
} v.error = e as string;
}
});
}
}
async function sendEventToRelays(ev: NostrEvent) {
if (note.selectedCustomRelays) {
await Promise.all(note.selectedCustomRelays.map(r => System.WriteOnceToRelay(r, ev)));
} else {
System.BroadcastEvent(ev);
} }
} }
async function sendNote() { async function sendNote() {
const ev = await buildNote(); const ev = await buildNote();
if (ev) { if (ev) {
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, ev)); await sendEventToRelays(ev);
else System.BroadcastEvent(ev); for (const oe of note.otherEvents ?? []) {
dispatch(reset()); await sendEventToRelays(oe);
for (const oe of otherEvents) {
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, oe));
else System.BroadcastEvent(oe);
} }
dispatch(reset()); note.update(v => {
v.reset();
v.show = false;
});
} }
} }
@ -160,10 +151,14 @@ export function NoteCreator() {
if (file) { if (file) {
uploadFile(file); uploadFile(file);
} }
} catch (error: unknown) { } catch (e) {
if (error instanceof Error) { note.update(v => {
dispatch(setError(error?.message)); if (e instanceof Error) {
} v.error = e.message;
} else {
v.error = e as string;
}
});
} }
} }
@ -171,35 +166,39 @@ export function NoteCreator() {
try { try {
if (file) { if (file) {
const rx = await uploader.upload(file, file.name); const rx = await uploader.upload(file, file.name);
if (rx.header) { note.update(v => {
const link = `nostr:${new NostrLink(NostrPrefix.Event, rx.header.id, rx.header.kind).encode()}`; if (rx.header) {
dispatch(setNote(`${note ? `${note}\n` : ""}${link}`)); const link = `nostr:${new NostrLink(NostrPrefix.Event, rx.header.id, rx.header.kind).encode()}`;
dispatch(setOtherEvents([...otherEvents, rx.header])); v.note = `${v.note ? `${v.note}\n` : ""}${link}`;
} else if (rx.url) { v.otherEvents = [...(v.otherEvents ?? []), rx.header];
dispatch(setNote(`${note ? `${note}\n` : ""}${rx.url}`)); } else if (rx.url) {
} else if (rx?.error) { v.note = `${v.note ? `${v.note}\n` : ""}${rx.url}`;
dispatch(setError(rx.error)); } else if (rx?.error) {
v.error = rx.error;
}
});
}
} catch (e) {
note.update(v => {
if (e instanceof Error) {
v.error = e.message;
} else {
v.error = e as string;
} }
} });
} catch (error: unknown) {
if (error instanceof Error) {
dispatch(setError(error?.message));
}
} }
} }
function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) { function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) {
const { value } = ev.target; const { value } = ev.target;
dispatch(setNote(value)); note.update(n => (n.note = value));
if (value) {
dispatch(setActive(true));
} else {
dispatch(setActive(false));
}
} }
function cancel() { function cancel() {
dispatch(reset()); note.update(v => {
v.show = false;
v.reset();
});
} }
async function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) { async function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
@ -208,21 +207,19 @@ export function NoteCreator() {
} }
async function loadPreview() { async function loadPreview() {
if (preview) { if (note.preview) {
dispatch(setPreview(undefined)); note.update(v => (v.preview = undefined));
} else if (publisher) { } else if (publisher) {
const tmpNote = await buildNote(); const tmpNote = await buildNote();
if (tmpNote) { note.update(v => (v.preview = tmpNote));
dispatch(setPreview(tmpNote));
}
} }
} }
function getPreviewNote() { function getPreviewNote() {
if (preview) { if (note.preview) {
return ( return (
<Note <Note
data={preview as TaggedNostrEvent} data={note.preview as TaggedNostrEvent}
related={[]} related={[]}
options={{ options={{
showContextMenu: false, showContextMenu: false,
@ -236,13 +233,13 @@ export function NoteCreator() {
} }
function renderPollOptions() { function renderPollOptions() {
if (pollOptions) { if (note.pollOptions) {
return ( return (
<> <>
<h4> <h4>
<FormattedMessage defaultMessage="Poll Options" /> <FormattedMessage defaultMessage="Poll Options" />
</h4> </h4>
{pollOptions?.map((a, i) => ( {note.pollOptions?.map((a, i) => (
<div className="form-group w-max" key={`po-${i}`}> <div className="form-group w-max" key={`po-${i}`}>
<div> <div>
<FormattedMessage defaultMessage="Option: {n}" values={{ n: i + 1 }} /> <FormattedMessage defaultMessage="Option: {n}" values={{ n: i + 1 }} />
@ -257,7 +254,7 @@ export function NoteCreator() {
</div> </div>
</div> </div>
))} ))}
<button onClick={() => dispatch(setPollOptions([...pollOptions, ""]))}> <button onClick={() => note.update(v => (v.pollOptions = [...(note.pollOptions ?? []), ""]))}>
<Icon name="plus" size={14} /> <Icon name="plus" size={14} />
</button> </button>
</> </>
@ -266,18 +263,18 @@ export function NoteCreator() {
} }
function changePollOption(i: number, v: string) { function changePollOption(i: number, v: string) {
if (pollOptions) { if (note.pollOptions) {
const copy = [...pollOptions]; const copy = [...note.pollOptions];
copy[i] = v; copy[i] = v;
dispatch(setPollOptions(copy)); note.update(v => (v.pollOptions = copy));
} }
} }
function removePollOption(i: number) { function removePollOption(i: number) {
if (pollOptions) { if (note.pollOptions) {
const copy = [...pollOptions]; const copy = [...note.pollOptions];
copy.splice(i, 1); copy.splice(i, 1);
dispatch(setPollOptions(copy)); note.update(v => (v.pollOptions = copy));
} }
} }
@ -292,20 +289,24 @@ export function NoteCreator() {
<div> <div>
<input <input
type="checkbox" type="checkbox"
checked={!selectedCustomRelays || selectedCustomRelays.includes(r)} checked={!note.selectedCustomRelays || note.selectedCustomRelays.includes(r)}
onChange={e => onChange={e => {
dispatch( note.update(
setSelectedCustomRelays( v =>
// set false if all relays selected (v.selectedCustomRelays =
e.target.checked && selectedCustomRelays && selectedCustomRelays.length == a.length - 1 // set false if all relays selected
? false e.target.checked &&
: // otherwise return selectedCustomRelays with target relay added / removed note.selectedCustomRelays &&
a.filter(el => note.selectedCustomRelays.length == a.length - 1
el === r ? e.target.checked : !selectedCustomRelays || selectedCustomRelays.includes(el), ? undefined
), : // otherwise return selectedCustomRelays with target relay added / removed
), a.filter(el =>
) el === r
} ? e.target.checked
: !note.selectedCustomRelays || note.selectedCustomRelays.includes(el),
)),
);
}}
/> />
</div> </div>
</div> </div>
@ -345,163 +346,167 @@ export function NoteCreator() {
} }
}; };
if (!note.show) return null;
return ( return (
<> <Modal id="note-creator" className="note-creator-modal" onClose={() => note.update(v => (v.show = false))}>
{show && ( {note.replyTo && (
<Modal className="note-creator-modal" onClose={() => dispatch(setShow(false))}> <Note
{replyTo && ( data={note.replyTo}
<Note related={[]}
data={replyTo} options={{
related={[]} showFooter: false,
options={{ showContextMenu: false,
showFooter: false, showTime: false,
showContextMenu: false, canClick: false,
showTime: false, showMedia: false,
canClick: false, }}
showMedia: false, />
}}
/>
)}
{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>
)} )}
</> {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 React, { HTMLProps, useContext, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { useLongPress } from "use-long-press"; import { useLongPress } from "use-long-press";
import { TaggedNostrEvent, ParsedZap, countLeadingZeros, NostrLink } from "@snort/system"; import { TaggedNostrEvent, ParsedZap, countLeadingZeros, NostrLink } from "@snort/system";
import { SnortContext, useUserProfile } from "@snort/system-react"; import { SnortContext, useUserProfile } from "@snort/system-react";
import { formatShort } from "Number"; import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { delay, findTag, normalizeReaction } from "SnortUtils"; import { delay, findTag, normalizeReaction } from "SnortUtils";
import { NoteCreator } from "Element/NoteCreator"; import { NoteCreator } from "Element/NoteCreator";
import SendSats from "Element/SendSats"; import SendSats from "Element/SendSats";
import { ZapsSummary } from "Element/Zap"; import { ZapsSummary } from "Element/Zap";
import { RootState } from "State/Store";
import { setReplyTo, setShow, reset } from "State/NoteCreator";
import { AsyncIcon } from "Element/AsyncIcon"; import { AsyncIcon } from "Element/AsyncIcon";
import { useWallet } from "Wallet"; import { useWallet } from "Wallet";
@ -22,6 +19,7 @@ import { ZapPoolController } from "ZapPoolController";
import { System } from "index"; import { System } from "index";
import { Zapper, ZapTarget } from "Zapper"; import { Zapper, ZapTarget } from "Zapper";
import { getDisplayName } from "./ProfileImage"; import { getDisplayName } from "./ProfileImage";
import { useNoteCreator } from "State/NoteCreator";
import messages from "./messages"; import messages from "./messages";
@ -47,7 +45,6 @@ export interface NoteFooterProps {
export default function NoteFooter(props: NoteFooterProps) { export default function NoteFooter(props: NoteFooterProps) {
const { ev, positive, reposts, zaps } = props; const { ev, positive, reposts, zaps } = props;
const dispatch = useDispatch();
const system = useContext(SnortContext); const system = useContext(SnortContext);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const login = useLogin(); const login = useLogin();
@ -55,9 +52,8 @@ export default function NoteFooter(props: NoteFooterProps) {
const author = useUserProfile(ev.pubkey); const author = useUserProfile(ev.pubkey);
const interactionCache = useInteractionCache(publicKey, ev.id); const interactionCache = useInteractionCache(publicKey, ev.id);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show); const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update }));
const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo); const willRenderNoteCreator = note.show && note.replyTo?.id === ev.id;
const willRenderNoteCreator = showNoteCreatorModal && replyTo?.id === ev.id;
const [tip, setTip] = useState(false); const [tip, setTip] = useState(false);
const [zapping, setZapping] = useState(false); const [zapping, setZapping] = useState(false);
const walletState = useWallet(); const walletState = useWallet();
@ -238,7 +234,7 @@ export default function NoteFooter(props: NoteFooterProps) {
function replyIcon() { function replyIcon() {
return ( return (
<AsyncFooterIcon <AsyncFooterIcon
className={showNoteCreatorModal ? "reacted" : ""} className={note.show ? "reacted" : ""}
iconName="reply" iconName="reply"
title={formatMessage({ defaultMessage: "Reply" })} title={formatMessage({ defaultMessage: "Reply" })}
value={0} value={0}
@ -248,12 +244,13 @@ export default function NoteFooter(props: NoteFooterProps) {
} }
const handleReplyButtonClick = () => { const handleReplyButtonClick = () => {
if (replyTo?.id !== ev.id) { note.update(v => {
dispatch(reset()); if (v.replyTo?.id !== ev.id) {
} v.reset();
}
dispatch(setReplyTo(ev)); v.show = true;
dispatch(setShow(!showNoteCreatorModal)); v.replyTo = ev;
});
}; };
return ( return (
@ -266,7 +263,7 @@ export default function NoteFooter(props: NoteFooterProps) {
{tipButton()} {tipButton()}
{powIcon()} {powIcon()}
</div> </div>
{willRenderNoteCreator && <NoteCreator />} {willRenderNoteCreator && <NoteCreator key={`note-creator-${ev.id}`} />}
<SendSats targets={getZapTarget()} onClose={() => setTip(false)} show={tip} note={ev.id} allocatePool={true} /> <SendSats targets={getZapTarget()} onClose={() => setTip(false)} show={tip} note={ev.id} allocatePool={true} />
</div> </div>
<ZapsSummary zaps={zaps} /> <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 { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Hooks/useEventPublisher";
import { useWallet } from "Wallet"; import { useWallet } from "Wallet";
import { unwrap } from "SnortUtils"; import { unwrap } from "SnortUtils";
import { formatShort } from "Number"; import { formatShort } from "Number";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import BackButton from "Element/BackButton";
import Note from "Element/Note"; import Note from "Element/Note";
import NoteGhost from "Element/NoteGhost"; import NoteGhost from "Element/NoteGhost";
import Collapsed from "Element/Collapsed"; import Collapsed from "Element/Collapsed";
import { ThreadContext, ThreadContextWrapper } from "Hooks/useThreadContext"; import { ThreadContext, ThreadContextWrapper, chainKey } from "Hooks/useThreadContext";
import messages from "./messages"; import messages from "./messages";
@ -297,14 +297,35 @@ export function Thread(props: { onBack?: () => void }) {
description: "Navigate back button on threads view", description: "Navigate back button on threads view",
}); });
const debug = window.location.search.includes("debug=true");
return ( 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"> <div className="main-content p">
<BackButton onClick={goBack} text={parent ? parentText : backText} /> <BackButton onClick={goBack} text={parent ? parentText : backText} />
</div> </div>
<div className="main-content"> <div className="main-content">
{thread.root && renderRoot(thread.root)} {thread.root && renderRoot(thread.root)}
{thread.root && renderChain(thread.root.id)} {thread.root && renderChain(chainKey(thread.root))}
</div> </div>
</> </>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ export default function useZapsFeed(link?: NostrLink) {
const sub = useMemo(() => { const sub = useMemo(() => {
if (!link) return null; if (!link) return null;
const b = new RequestBuilder(`zaps:${link.encode()}`); const b = new RequestBuilder(`zaps:${link.encode()}`);
b.withFilter().kinds([EventKind.ZapReceipt]).replyToLink(link); b.withFilter().kinds([EventKind.ZapReceipt]).replyToLink([link]);
return b; return b;
}, [link]); }, [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 { useIntl } from "react-intl";
import { Nip46Signer, PinEncrypted } from "@snort/system";
import { EmailRegex, MnemonicRegex } from "Const"; import { EmailRegex, MnemonicRegex } from "Const";
import { LoginSessionType, LoginStore } from "Login"; import { LoginSessionType, LoginStore } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6"; import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { getNip05PubKey } from "Pages/LoginPage"; import { getNip05PubKey } from "Pages/LoginPage";
import { bech32ToHex } from "SnortUtils"; import { bech32ToHex } from "SnortUtils";
import { Nip46Signer } from "@snort/system"; import { unwrap } from "@snort/shared";
export class PinRequiredError extends Error {}
export default function useLoginHandler() { export default function useLoginHandler() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const hasSubtleCrypto = window.crypto.subtle !== undefined; const hasSubtleCrypto = window.crypto.subtle !== undefined;
async function doLogin(key: string) { async function doLogin(key: string, pin?: string) {
const insecureMsg = formatMessage({ const insecureMsg = formatMessage({
defaultMessage: defaultMessage:
"Can't login with private key on an insecure connection, please use a Nostr key manager extension instead", "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead",
@ -23,7 +26,8 @@ export default function useLoginHandler() {
} }
const hexKey = bech32ToHex(key); const hexKey = bech32ToHex(key);
if (hexKey.length === 64) { if (hexKey.length === 64) {
LoginStore.loginWithPrivateKey(hexKey); if (!pin) throw new PinRequiredError();
LoginStore.loginWithPrivateKey(await PinEncrypted.create(hexKey, pin));
} else { } else {
throw new Error("INVALID PRIVATE KEY"); throw new Error("INVALID PRIVATE KEY");
} }
@ -31,14 +35,16 @@ export default function useLoginHandler() {
if (!hasSubtleCrypto) { if (!hasSubtleCrypto) {
throw new Error(insecureMsg); throw new Error(insecureMsg);
} }
if (!pin) throw new PinRequiredError();
const ent = generateBip39Entropy(key); const ent = generateBip39Entropy(key);
const keyHex = entropyToPrivateKey(ent); const keyHex = entropyToPrivateKey(ent);
LoginStore.loginWithPrivateKey(keyHex); LoginStore.loginWithPrivateKey(await PinEncrypted.create(keyHex, pin));
} else if (key.length === 64) { } else if (key.length === 64) {
if (!hasSubtleCrypto) { if (!hasSubtleCrypto) {
throw new Error(insecureMsg); throw new Error(insecureMsg);
} }
LoginStore.loginWithPrivateKey(key); if (!pin) throw new PinRequiredError();
LoginStore.loginWithPrivateKey(await PinEncrypted.create(key, pin));
} }
// public key logins // public key logins
@ -49,11 +55,18 @@ export default function useLoginHandler() {
const hexKey = await getNip05PubKey(key); const hexKey = await getNip05PubKey(key);
LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey); LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey);
} else if (key.startsWith("bunker://")) { } else if (key.startsWith("bunker://")) {
if (!pin) throw new PinRequiredError();
const nip46 = new Nip46Signer(key); const nip46 = new Nip46Signer(key);
await nip46.init(); await nip46.init();
const loginPubkey = await nip46.getPubKey(); 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(); nip46.close();
} else { } else {
throw new Error("INVALID PRIVATE KEY"); throw new Error("INVALID PRIVATE KEY");

View File

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

View File

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

View File

@ -1,7 +1,7 @@
/* eslint-disable no-debugger */
import { unwrap } from "@snort/shared"; 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 useThreadFeed from "Feed/ThreadFeed";
import { findTag } from "SnortUtils";
import { ReactNode, createContext, useMemo, useState } from "react"; import { ReactNode, createContext, useMemo, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
@ -10,14 +10,31 @@ export interface ThreadContext {
root?: TaggedNostrEvent; root?: TaggedNostrEvent;
chains: Map<string, Array<TaggedNostrEvent>>; chains: Map<string, Array<TaggedNostrEvent>>;
data: Array<TaggedNostrEvent>; data: Array<TaggedNostrEvent>;
reactions: Array<TaggedNostrEvent>;
setCurrent: (i: string) => void; setCurrent: (i: string) => void;
} }
export const ThreadContext = createContext({} as ThreadContext); 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 }) { export function ThreadContextWrapper({ link, children }: { link: NostrLink; children?: ReactNode }) {
const location = useLocation(); const location = useLocation();
const [currentId, setCurrentId] = useState(link.id); const [currentId, setCurrentId] = useState(unwrap(link.toEventTag())[1]);
const feed = useThreadFeed(link); const feed = useThreadFeed(link);
const chains = useMemo(() => { const chains = useMemo(() => {
@ -26,21 +43,12 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
feed.thread feed.thread
?.sort((a, b) => b.created_at - a.created_at) ?.sort((a, b) => b.created_at - a.created_at)
.forEach(v => { .forEach(v => {
const t = EventExt.extractThread(v); const replyTo = replyChainKey(v);
if (t) { if (replyTo) {
let replyTo = t.replyTo?.value ?? t.root?.value; if (!chains.has(replyTo)) {
if (t.root?.key === "a" && t.root?.value) { chains.set(replyTo, [v]);
const parsed = t.root.value.split(":"); } else {
replyTo = feed.thread?.find( unwrap(chains.get(replyTo)).push(v);
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);
}
} }
} }
}); });
@ -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 // 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 root = useMemo(() => {
const currentNote = const currentNote =
feed.thread?.find( feed.thread?.find(a => chainKey(a) === currentId) ??
ne => (location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined);
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);
if (currentNote) { if (currentNote) {
const currentThread = EventExt.extractThread(currentNote); const key = replyChainKey(currentNote);
const isRoot = (ne?: ThreadInfo) => ne === undefined; if (key) {
return feed.thread?.find(a => chainKey(a) === key);
if (isRoot(currentThread)) { } else {
return currentNote; 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]); }, [feed.thread.length, currentId, location]);
const ctxValue = useMemo(() => { const ctxValue = useMemo<ThreadContext>(() => {
return { return {
current: currentId, current: currentId,
root, root,
chains, chains,
data: feed.reactions, reactions: feed.reactions,
data: feed.thread,
setCurrent: v => setCurrentId(v), setCurrent: v => setCurrentId(v),
} as ThreadContext; };
}, [root, chains]); }, [root, chains]);
return <ThreadContext.Provider value={ctxValue}>{children}</ThreadContext.Provider>; 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 { unixNowMs } from "@snort/shared";
import * as secp from "@noble/curves/secp256k1"; import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils"; import * as utils from "@noble/curves/abstract/utils";
import { DefaultRelays, SnortPubKey } from "Const"; import { DefaultRelays, SnortPubKey } from "Const";
import { LoginStore, UserPreferences, LoginSession } from "Login"; import { LoginStore, UserPreferences, LoginSession, LoginSessionType } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6"; import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unwrap } from "SnortUtils"; import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unwrap } from "SnortUtils";
import { SubscriptionEvent } from "Subscription"; import { SubscriptionEvent } from "Subscription";
import { System } from "index"; import { System } from "index";
import { Chats, FollowsFeed, GiftsCache, Notifications } from "Cache"; import { Chats, FollowsFeed, GiftsCache, Notifications } from "Cache";
import { PinRequiredError } from "Hooks/useLoginHandler";
import { Nip7OsSigner } from "./Nip7OsSigner";
export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) { export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) {
if (state.relays.timestamp >= createdAt) { if (state.relays.timestamp >= createdAt) {
@ -41,8 +43,8 @@ export function updatePreferences(state: LoginSession, p: UserPreferences) {
LoginStore.updateSession(state); LoginStore.updateSession(state);
} }
export function logout(k: HexKey) { export function logout(id: string) {
LoginStore.removeSession(k); LoginStore.removeSession(id);
GiftsCache.clear(); GiftsCache.clear();
Notifications.clear(); Notifications.clear();
FollowsFeed.clear(); FollowsFeed.clear();
@ -62,7 +64,7 @@ export function clearEntropy(state: LoginSession) {
/** /**
* Generate a new key and login with this generated key * Generate a new key and login with this generated key
*/ */
export async function generateNewLogin() { export async function generateNewLogin(pin: string) {
const ent = generateBip39Entropy(); const ent = generateBip39Entropy();
const entropy = utils.bytesToHex(ent); const entropy = utils.bytesToHex(ent);
const privateKey = entropyToPrivateKey(ent); const privateKey = entropyToPrivateKey(ent);
@ -88,7 +90,8 @@ export async function generateNewLogin() {
const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey], newRelays); const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey], newRelays);
System.BroadcastEvent(ev); System.BroadcastEvent(ev);
LoginStore.loginWithPrivateKey(privateKey, entropy, newRelays); const key = await PinEncrypted.create(privateKey, pin);
LoginStore.loginWithPrivateKey(key, entropy, newRelays);
} }
export function generateRandomKey() { export function generateRandomKey() {
@ -161,3 +164,34 @@ export function addSubscription(state: LoginSession, ...subs: SubscriptionEvent[
LoginStore.updateSession(state); LoginStore.updateSession(state);
} }
} }
export function sessionNeedsPin(l: LoginSession) {
return l.type === LoginSessionType.PrivateKey || l.type === LoginSessionType.Nip46;
}
export function createPublisher(l: LoginSession, pin?: 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 { UserPreferences } from "Login";
import { SubscriptionEvent } from "Subscription"; import { SubscriptionEvent } from "Subscription";
@ -19,6 +19,11 @@ export enum LoginSessionType {
} }
export interface LoginSession { export interface LoginSession {
/**
* Unique ID to identify this session
*/
id: string;
/** /**
* Type of login session * Type of login session
*/ */
@ -26,9 +31,15 @@ export interface LoginSession {
/** /**
* Current user private key * Current user private key
* @deprecated Moving to pin encrypted storage
*/ */
privateKey?: HexKey; privateKey?: HexKey;
/**
* Encrypted private key
*/
privateKeyData?: PinEncrypted | PinEncryptedPayload;
/** /**
* BIP39-generated, hex-encoded entropy * BIP39-generated, hex-encoded entropy
*/ */
@ -98,9 +109,4 @@ export interface LoginSession {
* Remote signer relays (NIP-46) * Remote signer relays (NIP-46)
*/ */
remoteSignerRelays?: Array<string>; remoteSignerRelays?: Array<string>;
/**
* Instance event publisher
*/
publisher?: EventPublisher;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -447,7 +447,7 @@ export default function ProfilePage() {
<Icon name="qr" size={16} /> <Icon name="qr" size={16} />
</IconButton> </IconButton>
{showProfileQr && ( {showProfileQr && (
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}> <Modal id="profile-qr" className="qr-modal" onClose={() => setShowProfileQr(false)}>
<ProfileImage pubkey={id} /> <ProfileImage pubkey={id} />
<QrCode data={link} className="m10 align-center" /> <QrCode data={link} className="m10 align-center" />
<Copy text={link} className="align-center" /> <Copy text={link} className="align-center" />
@ -473,7 +473,7 @@ export default function ProfilePage() {
navigate( navigate(
`/messages/${encodeTLVEntries("chat4" as NostrPrefix, { `/messages/${encodeTLVEntries("chat4" as NostrPrefix, {
type: TLVEntryType.Author, type: TLVEntryType.Author,
length: 64, length: 32,
value: id, value: id,
})}`, })}`,
) )

View File

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

View File

@ -1,57 +1,32 @@
import { useState } from "react"; import { FormattedMessage } from "react-intl";
import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom";
import ProfilePreview from "Element/ProfilePreview"; import ProfilePreview from "Element/ProfilePreview";
import { LoginStore } from "Login"; import { LoginStore } from "Login";
import useLoginHandler from "Hooks/useLoginHandler";
import AsyncButton from "Element/AsyncButton";
import { getActiveSubscriptions } from "Subscription"; import { getActiveSubscriptions } from "Subscription";
export default function AccountsPage() { export default function AccountsPage() {
const { formatMessage } = useIntl();
const [key, setKey] = useState("");
const [error, setError] = useState("");
const loginHandler = useLoginHandler();
const logins = LoginStore.getSessions(); const logins = LoginStore.getSessions();
const sub = getActiveSubscriptions(LoginStore.allSubscriptions()); 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 ( return (
<> <div className="flex-column g12">
<h3> <h3>
<FormattedMessage defaultMessage="Logins" /> <FormattedMessage defaultMessage="Logins" />
</h3> </h3>
{logins.map(a => ( {logins.map(a => (
<div className="card flex" key={a}> <div className="card flex" key={a.id}>
<ProfilePreview <ProfilePreview
pubkey={a} pubkey={a.pubkey}
options={{ options={{
about: false, about: false,
}} }}
actions={ actions={
<div className="f-1"> <div className="f-1">
<button className="mb10" onClick={() => LoginStore.switchAccount(a)}> <button className="mb10" onClick={() => LoginStore.switchAccount(a.id)}>
<FormattedMessage defaultMessage="Switch" /> <FormattedMessage defaultMessage="Switch" />
</button> </button>
<button onClick={() => LoginStore.removeSession(a)}> <button onClick={() => LoginStore.removeSession(a.id)}>
<FormattedMessage defaultMessage="Logout" /> <FormattedMessage defaultMessage="Logout" />
</button> </button>
</div> </div>
@ -61,27 +36,12 @@ export default function AccountsPage() {
))} ))}
{sub && ( {sub && (
<> <Link to={"/login"}>
<h3> <button type="button">
<FormattedMessage defaultMessage="Add Account" /> <FormattedMessage defaultMessage="Add Account" />
</h3> </button>
<div className="flex"> </Link>
<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>
</>
)} )}
{error && <b className="error">{error}</b>} </div>
</>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,6 +46,7 @@
--header-padding-tb: 10px; --header-padding-tb: 10px;
--btn-color: #fff; --btn-color: #fff;
--primary-gradient: linear-gradient(90deg, rgba(239, 150, 68, 1) 0%, rgba(123, 65, 246, 1) 100%); --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 { ::-webkit-scrollbar {
@ -132,10 +133,26 @@ code {
} }
} }
.bg-primary {
background: var(--primary-gradient);
}
.br {
border-radius: 16px;
}
.p { .p {
padding: 12px 16px; padding: 12px 16px;
} }
.p24 {
padding: 24px;
}
.uppercase {
text-transform: uppercase;
}
.card { .card {
padding: 12px 16px; padding: 12px 16px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
@ -522,7 +539,7 @@ div.form-col {
height: 100vh; height: 100vh;
} }
small.xs { .xs {
font-size: small; 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 { StrictMode } from "react";
import * as ReactDOM from "react-dom/client"; import * as ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { import { NostrSystem, ProfileLoaderService, PowWorker, QueryOptimizer, FlatReqFilter, ReqFilter } from "@snort/system";
EventPublisher,
NostrSystem,
ProfileLoaderService,
Nip7Signer,
PowWorker,
QueryOptimizer,
FlatReqFilter,
ReqFilter,
} from "@snort/system";
import { SnortContext } from "@snort/system-react"; import { SnortContext } from "@snort/system-react";
import * as serviceWorkerRegistration from "serviceWorkerRegistration"; import * as serviceWorkerRegistration from "serviceWorkerRegistration";
import { IntlProvider } from "IntlProvider"; import { IntlProvider } from "IntlProvider";
import { unwrap } from "SnortUtils"; import { unwrap } from "SnortUtils";
import Store from "State/Store";
import Layout from "Pages/Layout"; import Layout from "Pages/Layout";
import LoginPage from "Pages/LoginPage"; import LoginPage from "Pages/LoginPage";
import ProfilePage from "Pages/ProfilePage"; import ProfilePage from "Pages/ProfilePage";
@ -73,13 +62,9 @@ export const System = new NostrSystem({
relayMetrics: RelayMetrics, relayMetrics: RelayMetrics,
queryOptimizer: WasmQueryOptimizer, queryOptimizer: WasmQueryOptimizer,
authHandler: async (c, r) => { authHandler: async (c, r) => {
const { publicKey, privateKey } = LoginStore.snapshot(); const { id } = LoginStore.snapshot();
if (privateKey) { const pub = LoginStore.getPublisher(id);
const pub = EventPublisher.privateKey(privateKey); if (pub) {
return await pub.nip42Auth(c, r);
}
if (publicKey) {
const pub = new EventPublisher(new Nip7Signer(), publicKey);
return await pub.nip42Auth(c, r); return await pub.nip42Auth(c, r);
} }
}, },
@ -218,12 +203,10 @@ export const router = createBrowserRouter([
const root = ReactDOM.createRoot(unwrap(document.getElementById("root"))); const root = ReactDOM.createRoot(unwrap(document.getElementById("root")));
root.render( root.render(
<StrictMode> <StrictMode>
<Provider store={Store}> <IntlProvider>
<IntlProvider> <SnortContext.Provider value={System}>
<SnortContext.Provider value={System}> <RouterProvider router={router} />
<RouterProvider router={router} /> </SnortContext.Provider>
</SnortContext.Provider> </IntlProvider>
</IntlProvider>
</Provider>
</StrictMode>, </StrictMode>,
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
use crate::filter::CanMerge; use crate::filter::CanMerge;
pub fn merge<'a, T, Z>(all: Vec<&'a T>) -> Vec<Z> pub fn merge<'a, T, Z>(all: Vec<&'a T>) -> Vec<Z>
where where
T: CanMerge, T: CanMerge,
for<'b> Z: CanMerge + From<Vec<&'a T>> + From<Vec<&'b Z>>, for<'b> Z: CanMerge + From<Vec<&'a T>> + From<Vec<&'b Z>>,
{ {
let mut ret: Vec<Z> = merge_once(all); let mut ret: Vec<Z> = merge_once(all);
loop { loop {
@ -17,9 +17,9 @@ where
} }
fn merge_once<'a, T, Z>(all: Vec<&'a T>) -> Vec<Z> fn merge_once<'a, T, Z>(all: Vec<&'a T>) -> Vec<Z>
where where
T: CanMerge, T: CanMerge,
Z: From<Vec<&'a T>>, Z: From<Vec<&'a T>>,
{ {
let mut ret: Vec<Z> = vec![]; let mut ret: Vec<Z> = vec![];
if all.is_empty() { if all.is_empty() {
@ -66,6 +66,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -80,6 +82,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -94,6 +98,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -108,6 +114,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -122,6 +130,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -144,6 +154,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -158,6 +170,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -173,6 +187,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -192,6 +208,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -206,6 +224,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -220,6 +240,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -241,6 +263,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -255,6 +279,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -269,6 +295,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -283,6 +311,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -297,6 +327,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -311,6 +343,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -325,6 +359,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -339,6 +375,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -353,6 +391,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -373,6 +413,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -387,6 +429,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -401,6 +445,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -415,6 +461,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: None, until: None,
@ -429,6 +477,8 @@ mod tests {
t_tag: None, t_tag: None,
d_tag: None, d_tag: None,
r_tag: None, r_tag: None,
a_tag: None,
g_tag: None,
search: None, search: None,
since: None, since: None,
until: 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, HexKey,
Lists, Lists,
NostrEvent, NostrEvent,
NostrLink,
NotSignedNostrEvent, NotSignedNostrEvent,
PowMiner, PowMiner,
PrivateKeySigner, PrivateKeySigner,
@ -185,10 +186,11 @@ export class EventPublisher {
const thread = EventExt.extractThread(replyTo); const thread = EventExt.extractThread(replyTo);
if (thread) { if (thread) {
if (thread.root || thread.replyTo) { const rootOrReplyAsRoot = thread.root || thread.replyTo;
eb.tag(["e", thread.root?.value ?? thread.replyTo?.value ?? "", "", "root"]); if (rootOrReplyAsRoot) {
eb.tag([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]); eb.tag(["p", replyTo.pubkey]);
for (const pk of thread.pubKeys) { for (const pk of thread.pubKeys) {
@ -198,7 +200,7 @@ export class EventPublisher {
eb.tag(["p", pk]); eb.tag(["p", pk]);
} }
} else { } else {
eb.tag(["e", replyTo.id, "", "reply"]); eb.tag([...(NostrLink.fromEvent(replyTo).toEventTag() ?? []), "reply"]);
// dont tag self in replies // dont tag self in replies
if (replyTo.pubkey !== this.#pubKey) { if (replyTo.pubkey !== this.#pubKey) {
eb.tag(["p", replyTo.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; return picked;
} }
@ -119,7 +119,7 @@ export function splitFlatByWriteRelays(cache: RelayCache, input: Array<FlatReqFi
} as RelayTaggedFlatFilters); } as RelayTaggedFlatFilters);
} }
debug("GOSSIP")("Picked %o", picked); debug("GOSSIP")("Picked %d relays from %d filters", picked.length, input.length);
return picked; return picked;
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import debug from "debug"; import debug from "debug";
import { v4 as uuid } from "uuid"; 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 EventKind from "./event-kind";
import { NostrLink, NostrPrefix, SystemInterface } from "index"; import { NostrLink, NostrPrefix, SystemInterface } from "index";
@ -113,7 +113,7 @@ export class RequestBuilder {
const diff = system.QueryOptimizer.getDiff(prev, this.buildRaw()); const diff = system.QueryOptimizer.getDiff(prev, this.buildRaw());
const ts = unixNowMs() - start; const ts = unixNowMs() - start;
this.#log("buildDiff %s %d ms +%d %O=>%O", this.id, ts, diff.length, prev, this.buildRaw()); this.#log("buildDiff %s %d ms +%d", this.id, ts, diff.length);
if (diff.length > 0) { if (diff.length > 0) {
return splitFlatByWriteRelays(system.RelayCache, diff).map(a => { return splitFlatByWriteRelays(system.RelayCache, diff).map(a => {
return { return {
@ -219,9 +219,9 @@ export class RequestFilterBuilder {
return this; 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; 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; return this;
} }
@ -239,28 +239,25 @@ export class RequestFilterBuilder {
this.tag("d", [link.id]) this.tag("d", [link.id])
.kinds([unwrap(link.kind)]) .kinds([unwrap(link.kind)])
.authors([unwrap(link.author)]); .authors([unwrap(link.author)]);
link.relays?.forEach(v => this.relay(v));
} else { } else {
this.ids([link.id]); this.ids([link.id]);
link.relays?.forEach(v => this.relay(v));
} }
link.relays?.forEach(v => this.relay(v));
return this; return this;
} }
/** /**
* Get replies to link with e/a tags * Get replies to link with e/a tags
*/ */
replyToLink(link: NostrLink) { replyToLink(links: Array<NostrLink>) {
if (link.type === NostrPrefix.Address) { const types = dedupe(links.map(a => a.type));
this.tag("a", [`${link.kind}:${link.author}:${link.id}`]); if (types.length > 1) throw new Error("Cannot add multiple links of different kinds");
link.relays?.forEach(v => this.relay(v));
} else if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) { const tags = links.map(a => unwrap(a.toEventTag()));
this.tag("p", [link.id]); this.tag(
link.relays?.forEach(v => this.relay(v)); tags[0][0],
} else { tags.map(v => v[1]),
this.tag("e", [link.id]); );
link.relays?.forEach(v => this.relay(v));
}
return this; return this;
} }
@ -293,7 +290,7 @@ export class RequestFilterBuilder {
return [ return [
{ {
filters: [this.filter], filters: [this.#filter],
relay: "", relay: "",
strategy: RequestStrategy.DefaultRelays, strategy: RequestStrategy.DefaultRelays,
}, },

View File

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

100
yarn.lock
View File

@ -1376,7 +1376,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": "@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.8.4":
version: 7.22.11 version: 7.22.11
resolution: "@babel/runtime@npm:7.22.11" resolution: "@babel/runtime@npm:7.22.11"
dependencies: dependencies:
@ -2507,26 +2507,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@reduxjs/toolkit@npm:^1.9.1":
version: 1.9.5
resolution: "@reduxjs/toolkit@npm:1.9.5"
dependencies:
immer: ^9.0.21
redux: ^4.2.1
redux-thunk: ^2.4.2
reselect: ^4.1.8
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18
react-redux: ^7.2.1 || ^8.0.2
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
checksum: 54672c5593d05208af577e948a338f23128d3aa01ef056ab0d40bcfa14400cf6566be99e11715388f12c1d7655cdf7c5c6b63cb92eb0fecf996c454a46a3914c
languageName: node
linkType: hard
"@remix-run/router@npm:1.8.0": "@remix-run/router@npm:1.8.0":
version: 1.8.0 version: 1.8.0
resolution: "@remix-run/router@npm:1.8.0" resolution: "@remix-run/router@npm:1.8.0"
@ -2705,7 +2685,6 @@ __metadata:
"@lightninglabs/lnc-web": ^0.2.3-alpha "@lightninglabs/lnc-web": ^0.2.3-alpha
"@noble/curves": ^1.0.0 "@noble/curves": ^1.0.0
"@noble/hashes": ^1.2.0 "@noble/hashes": ^1.2.0
"@reduxjs/toolkit": ^1.9.1
"@scure/base": ^1.1.1 "@scure/base": ^1.1.1
"@scure/bip32": ^1.3.0 "@scure/bip32": ^1.3.0
"@scure/bip39": ^1.1.1 "@scure/bip39": ^1.1.1
@ -2719,6 +2698,7 @@ __metadata:
"@types/node": ^20.4.1 "@types/node": ^20.4.1
"@types/react": ^18.0.26 "@types/react": ^18.0.26
"@types/react-dom": ^18.0.10 "@types/react-dom": ^18.0.10
"@types/use-sync-external-store": ^0.0.4
"@types/uuid": ^9.0.2 "@types/uuid": ^9.0.2
"@types/webscopeio__react-textarea-autocomplete": ^4.7.2 "@types/webscopeio__react-textarea-autocomplete": ^4.7.2
"@types/webtorrent": ^0.109.3 "@types/webtorrent": ^0.109.3
@ -2751,7 +2731,6 @@ __metadata:
react-dom: ^18.2.0 react-dom: ^18.2.0
react-intersection-observer: ^9.4.1 react-intersection-observer: ^9.4.1
react-intl: ^6.4.4 react-intl: ^6.4.4
react-redux: ^8.0.5
react-router-dom: ^6.5.0 react-router-dom: ^6.5.0
react-textarea-autosize: ^8.4.0 react-textarea-autosize: ^8.4.0
react-twitter-embed: ^4.0.4 react-twitter-embed: ^4.0.4
@ -2762,6 +2741,7 @@ __metadata:
ts-loader: ^9.4.4 ts-loader: ^9.4.4
typescript: ^5.2.2 typescript: ^5.2.2
use-long-press: ^2.0.3 use-long-press: ^2.0.3
use-sync-external-store: ^1.2.0
uuid: ^9.0.0 uuid: ^9.0.0
webpack: ^5.88.2 webpack: ^5.88.2
webpack-bundle-analyzer: ^4.8.0 webpack-bundle-analyzer: ^4.8.0
@ -3590,10 +3570,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/use-sync-external-store@npm:^0.0.3": "@types/use-sync-external-store@npm:^0.0.4":
version: 0.0.3 version: 0.0.4
resolution: "@types/use-sync-external-store@npm:0.0.3" resolution: "@types/use-sync-external-store@npm:0.0.4"
checksum: 161ddb8eec5dbe7279ac971531217e9af6b99f7783213566d2b502e2e2378ea19cf5e5ea4595039d730aa79d3d35c6567d48599f69773a02ffcff1776ec2a44e checksum: f8bf56b14f28fda6d0215281d50623d5affd17c549ba46bcfcfbb97c6301a583066c0477856260ae6feadcaa714c46cd45678e76b74da0f6f8b364aec07bd854
languageName: node languageName: node
linkType: hard linkType: hard
@ -7614,13 +7594,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"immer@npm:^9.0.21":
version: 9.0.21
resolution: "immer@npm:9.0.21"
checksum: 70e3c274165995352f6936695f0ef4723c52c92c92dd0e9afdfe008175af39fa28e76aafb3a2ca9d57d1fb8f796efc4dd1e1cc36f18d33fa5b74f3dfb0375432
languageName: node
linkType: hard
"import-fresh@npm:^3.2.1": "import-fresh@npm:^3.2.1":
version: 3.3.0 version: 3.3.0
resolution: "import-fresh@npm:3.3.0" resolution: "import-fresh@npm:3.3.0"
@ -11273,38 +11246,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-redux@npm:^8.0.5":
version: 8.1.2
resolution: "react-redux@npm:8.1.2"
dependencies:
"@babel/runtime": ^7.12.1
"@types/hoist-non-react-statics": ^3.3.1
"@types/use-sync-external-store": ^0.0.3
hoist-non-react-statics: ^3.3.2
react-is: ^18.0.0
use-sync-external-store: ^1.0.0
peerDependencies:
"@types/react": ^16.8 || ^17.0 || ^18.0
"@types/react-dom": ^16.8 || ^17.0 || ^18.0
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
react-native: ">=0.59"
redux: ^4 || ^5.0.0-beta.0
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
react-dom:
optional: true
react-native:
optional: true
redux:
optional: true
checksum: 4d5976b0f721e4148475871fcabce2fee875cc7f70f9a292f3370d63b38aa1dd474eb303c073c5555f3e69fc732f3bac05303def60304775deb28361e3f4b7cc
languageName: node
linkType: hard
"react-router-dom@npm:^6.5.0": "react-router-dom@npm:^6.5.0":
version: 6.15.0 version: 6.15.0
resolution: "react-router-dom@npm:6.15.0" resolution: "react-router-dom@npm:6.15.0"
@ -11522,24 +11463,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"redux-thunk@npm:^2.4.2":
version: 2.4.2
resolution: "redux-thunk@npm:2.4.2"
peerDependencies:
redux: ^4
checksum: c7f757f6c383b8ec26152c113e20087818d18ed3edf438aaad43539e9a6b77b427ade755c9595c4a163b6ad3063adf3497e5fe6a36c68884eb1f1cfb6f049a5c
languageName: node
linkType: hard
"redux@npm:^4.2.1":
version: 4.2.1
resolution: "redux@npm:4.2.1"
dependencies:
"@babel/runtime": ^7.9.2
checksum: f63b9060c3a1d930ae775252bb6e579b42415aee7a23c4114e21a0b4ba7ec12f0ec76936c00f546893f06e139819f0e2855e0d55ebfce34ca9c026241a6950dd
languageName: node
linkType: hard
"regenerate-unicode-properties@npm:^10.1.0": "regenerate-unicode-properties@npm:^10.1.0":
version: 10.1.0 version: 10.1.0
resolution: "regenerate-unicode-properties@npm:10.1.0" resolution: "regenerate-unicode-properties@npm:10.1.0"
@ -11670,13 +11593,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"reselect@npm:^4.1.8":
version: 4.1.8
resolution: "reselect@npm:4.1.8"
checksum: a4ac87cedab198769a29be92bc221c32da76cfdad6911eda67b4d3e7136dca86208c3b210e31632eae31ebd2cded18596f0dd230d3ccc9e978df22f233b5583e
languageName: node
linkType: hard
"resolve-cwd@npm:^3.0.0": "resolve-cwd@npm:^3.0.0":
version: 3.0.0 version: 3.0.0
resolution: "resolve-cwd@npm:3.0.0" resolution: "resolve-cwd@npm:3.0.0"
@ -13424,7 +13340,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"use-sync-external-store@npm:^1.0.0": "use-sync-external-store@npm:^1.2.0":
version: 1.2.0 version: 1.2.0
resolution: "use-sync-external-store@npm:1.2.0" resolution: "use-sync-external-store@npm:1.2.0"
peerDependencies: peerDependencies: