commit
8ab3c16eed
@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"build": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/system-react build && yarn workspace @snort/app build",
|
||||
"start": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/system-react build && yarn workspace @snort/app start",
|
||||
"test": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/app test && yarn workspace @snort/system test"
|
||||
"test": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/app test && yarn workspace @snort/system test",
|
||||
"pre:commit": "yarn workspace @snort/app intl-extract && yarn workspace @snort/app intl-compile && yarn prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20230307.0",
|
||||
|
@ -6,7 +6,6 @@
|
||||
"@lightninglabs/lnc-web": "^0.2.3-alpha",
|
||||
"@noble/curves": "^1.0.0",
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"@reduxjs/toolkit": "^1.9.1",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@scure/bip32": "^1.3.0",
|
||||
"@scure/bip39": "^1.1.1",
|
||||
@ -15,6 +14,7 @@
|
||||
"@snort/system-query": "workspace:*",
|
||||
"@snort/system-react": "workspace:*",
|
||||
"@szhsin/react-menu": "^3.3.1",
|
||||
"@types/use-sync-external-store": "^0.0.4",
|
||||
"@void-cat/api": "^1.0.4",
|
||||
"debug": "^4.3.4",
|
||||
"dexie": "^3.2.4",
|
||||
@ -27,11 +27,11 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-intersection-observer": "^9.4.1",
|
||||
"react-intl": "^6.4.4",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.5.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
"react-twitter-embed": "^4.0.4",
|
||||
"use-long-press": "^2.0.3",
|
||||
"use-sync-external-store": "^1.2.0",
|
||||
"uuid": "^9.0.0",
|
||||
"workbox-core": "^6.4.2",
|
||||
"workbox-precaching": "^7.0.0",
|
||||
|
@ -330,6 +330,15 @@
|
||||
<path d="M9 6.5V9.5M9 12.5H9.0075M16.5 9.5C16.5 13.6421 13.1421 17 9 17C4.85786 17 1.5 13.6421 1.5 9.5C1.5 5.35786 4.85786 2 9 2C13.1421 2 16.5 5.35786 16.5 9.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="signal-01" viewBox="0 0 24 25" fill="none">
|
||||
<g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.5355 7.55101C15.9261 7.16048 16.5592 7.16048 16.9497 7.55101C19.6834 10.2847 19.6834 14.7168 16.9497 17.4505C16.5592 17.841 15.9261 17.841 15.5355 17.4505C15.145 17.06 15.145 16.4268 15.5355 16.0363C17.4882 14.0837 17.4882 10.9178 15.5355 8.96522C15.145 8.5747 15.145 7.94153 15.5355 7.55101Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.46447 7.55097C8.85499 7.9415 8.85499 8.57466 8.46447 8.96519C6.51184 10.9178 6.51184 14.0836 8.46447 16.0363C8.85499 16.4268 8.85499 17.0599 8.46447 17.4505C8.07394 17.841 7.44078 17.841 7.05025 17.4505C4.31658 14.7168 4.31658 10.2846 7.05025 7.55097C7.44078 7.16045 8.07394 7.16045 8.46447 7.55097Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.63604 4.72258C6.02656 5.11311 6.02656 5.74627 5.63604 6.13679C2.12132 9.65151 2.12132 15.35 5.63604 18.8647C6.02656 19.2552 6.02656 19.8884 5.63604 20.2789C5.24551 20.6695 4.61235 20.6695 4.22183 20.2789C-0.0739419 15.9832 -0.0739419 9.01835 4.22183 4.72258C4.61235 4.33206 5.24551 4.33206 5.63604 4.72258Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.364 4.72263C18.7545 4.3321 19.3877 4.3321 19.7782 4.72263C24.0739 9.01839 24.0739 15.9832 19.7782 20.279C19.3877 20.6695 18.7545 20.6695 18.364 20.279C17.9734 19.8885 17.9734 19.2553 18.364 18.8648C21.8787 15.35 21.8787 9.65156 18.364 6.13684C17.9734 5.74632 17.9734 5.11315 18.364 4.72263Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 12.5008C9 10.8439 10.3431 9.50076 12 9.50076C13.6569 9.50076 15 10.8439 15 12.5008C15 14.1576 13.6569 15.5008 12 15.5008C10.3431 15.5008 9 14.1576 9 12.5008Z" fill="currentColor"/>
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
</defs>
|
||||
</svg>
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 90 KiB |
@ -2,7 +2,7 @@ import { EventKind, EventPublisher, RequestBuilder, TaggedNostrEvent } from "@sn
|
||||
import { UnwrappedGift, db } from "Db";
|
||||
import { findTag, unwrap } from "SnortUtils";
|
||||
import { RefreshFeedCache } from "./RefreshFeedCache";
|
||||
import { LoginSession } from "Login";
|
||||
import { LoginSession, LoginSessionType } from "Login";
|
||||
|
||||
export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
|
||||
constructor() {
|
||||
@ -15,7 +15,7 @@ export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
|
||||
|
||||
buildSub(session: LoginSession, rb: RequestBuilder): void {
|
||||
const pubkey = session.publicKey;
|
||||
if (pubkey) {
|
||||
if (pubkey && session.type === LoginSessionType.PrivateKey) {
|
||||
rb.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubkey]).since(this.newest());
|
||||
}
|
||||
}
|
||||
|
@ -21,10 +21,11 @@ const Avatar = ({ pubkey, user, size, onClick, image, imageOverlay, icons }: Ava
|
||||
const [url, setUrl] = useState("");
|
||||
const { proxy } = useImgProxy();
|
||||
|
||||
const s = size ?? 120;
|
||||
useEffect(() => {
|
||||
const url = image ?? user?.picture;
|
||||
if (url) {
|
||||
const proxyUrl = proxy(url, size ?? 120);
|
||||
const proxyUrl = proxy(url, s);
|
||||
setUrl(proxyUrl);
|
||||
} else {
|
||||
setUrl(defaultAvatar(pubkey));
|
||||
@ -33,6 +34,10 @@ const Avatar = ({ pubkey, user, size, onClick, image, imageOverlay, icons }: Ava
|
||||
|
||||
const backgroundImage = `url(${url})`;
|
||||
const style = { "--img-url": backgroundImage } as CSSProperties;
|
||||
if (size) {
|
||||
style.width = `${s}px`;
|
||||
style.height = `${s}px`;
|
||||
}
|
||||
const domain = user?.nip05 && user.nip05.split("@")[1];
|
||||
return (
|
||||
<div
|
||||
|
@ -35,7 +35,7 @@ export default function BadgeList({ badges }: { badges: TaggedNostrEvent[] }) {
|
||||
))}
|
||||
</div>
|
||||
{showModal && (
|
||||
<Modal className="reactions-modal" onClose={() => setShowModal(false)}>
|
||||
<Modal id="badges" className="reactions-modal" onClose={() => setShowModal(false)}>
|
||||
<div className="reactions-view">
|
||||
<div className="close" onClick={() => setShowModal(false)}>
|
||||
<Icon name="close" />
|
||||
|
8
packages/app/src/Element/CashuNuts.css
Normal file
8
packages/app/src/Element/CashuNuts.css
Normal file
@ -0,0 +1,8 @@
|
||||
.cashu {
|
||||
background: var(--cashu-gradient);
|
||||
}
|
||||
|
||||
.cashu h1 {
|
||||
font-size: 44px;
|
||||
line-height: 1em;
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
import "./CashuNuts.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import Icon from "Icons/Icon";
|
||||
|
||||
interface Token {
|
||||
token: Array<{
|
||||
@ -48,34 +50,88 @@ export default function CashuNuts({ token }: { token: string }) {
|
||||
|
||||
if (!cashu) return <>{token}</>;
|
||||
|
||||
const amount = cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
|
||||
return (
|
||||
<div className="note-invoice">
|
||||
<div className="flex f-between">
|
||||
<div>
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Cashu token" />
|
||||
</h4>
|
||||
<p>
|
||||
<div className="cashu flex f-space p24 br">
|
||||
<div className="flex-column g8 f-ellipsis">
|
||||
<div className="flex f-center g16">
|
||||
<svg width="30" height="39" viewBox="0 0 30 39" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group 47711">
|
||||
<path
|
||||
id="Rectangle 585"
|
||||
d="M29.3809 2.47055L29.3809 11.7277L26.7913 11.021C23.8493 10.2181 20.727 10.3835 17.8863 11.4929C15.5024 12.4238 12.9113 12.6933 10.3869 12.2728L7.11501 11.7277L7.11501 2.47054L10.3869 3.01557C12.9113 3.43607 15.5024 3.1666 17.8863 2.23566C20.727 1.12632 23.8493 0.960876 26.7913 1.7638L29.3809 2.47055Z"
|
||||
fill="url(#paint0_linear_1976_19241)"
|
||||
/>
|
||||
<path
|
||||
id="Rectangle 587"
|
||||
d="M29.3809 27.9803L29.3809 37.2375L26.7913 36.5308C23.8493 35.7278 20.727 35.8933 17.8863 37.0026C15.5024 37.9336 12.9113 38.203 10.3869 37.7825L7.11501 37.2375L7.11501 27.9803L10.3869 28.5253C12.9113 28.9458 15.5024 28.6764 17.8863 27.7454C20.727 26.6361 23.8493 26.4706 26.7913 27.2736L29.3809 27.9803Z"
|
||||
fill="url(#paint1_linear_1976_19241)"
|
||||
/>
|
||||
<path
|
||||
id="Rectangle 586"
|
||||
d="M8.494e-08 15.2069L4.89585e-07 24.4641L2.5896 23.7573C5.53159 22.9544 8.6539 23.1198 11.4946 24.2292C13.8784 25.1601 16.4695 25.4296 18.9939 25.0091L22.2658 24.4641L22.2658 15.2069L18.9939 15.7519C16.4695 16.1724 13.8784 15.9029 11.4946 14.972C8.6539 13.8627 5.53159 13.6972 2.5896 14.5001L8.494e-08 15.2069Z"
|
||||
fill="url(#paint2_linear_1976_19241)"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_1976_19241"
|
||||
x1="29.3809"
|
||||
y1="6.7213"
|
||||
x2="7.11501"
|
||||
y2="6.7213"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="white" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_1976_19241"
|
||||
x1="29.3809"
|
||||
y1="32.2311"
|
||||
x2="7.11501"
|
||||
y2="32.2311"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="white" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_1976_19241"
|
||||
x1="2.70746e-07"
|
||||
y1="19.4576"
|
||||
x2="22.2658"
|
||||
y2="19.4576"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="white" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0.5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<FormattedMessage
|
||||
defaultMessage="Amount: {amount} sats"
|
||||
defaultMessage="<h1>{n}</h1> Cashu sats"
|
||||
values={{
|
||||
amount: cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0),
|
||||
h1: c => <h1>{c}</h1>,
|
||||
n: <FormattedNumber value={amount} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<small className="xs w-max">
|
||||
<FormattedMessage
|
||||
defaultMessage="<b>Mint:</b> {url}"
|
||||
values={{
|
||||
b: c => <b>{c}</b>,
|
||||
url: new URL(cashu.token[0].mint).hostname,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<small className="xs">
|
||||
<FormattedMessage defaultMessage="Mint: {url}" values={{ url: cashu.token[0].mint }} />
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={e => copyToken(e, token)} className="mr5">
|
||||
<FormattedMessage defaultMessage="Copy" description="Button: Copy Cashu token" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import Text from "Element/Text";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
|
@ -2,7 +2,7 @@ import { NostrLink } from "@snort/system";
|
||||
import { useArticles } from "Feed/ArticlesFeed";
|
||||
import { orderDescending } from "SnortUtils";
|
||||
import Note from "../Note";
|
||||
import { useReactions } from "Feed/FeedReactions";
|
||||
import { useReactions } from "Feed/Reactions";
|
||||
|
||||
export default function Articles() {
|
||||
const data = useArticles();
|
||||
|
@ -2,7 +2,7 @@ import "./FollowButton.css";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/system";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import { parseId } from "SnortUtils";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
|
@ -2,13 +2,16 @@ import { ReactNode } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/system";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { System } from "index";
|
||||
|
||||
import messages from "./messages";
|
||||
import { FollowsFeed } from "Cache";
|
||||
import AsyncButton from "./AsyncButton";
|
||||
import { setFollows } from "Login";
|
||||
import { dedupe } from "@snort/shared";
|
||||
|
||||
export interface FollowListBaseProps {
|
||||
pubkeys: HexKey[];
|
||||
@ -30,13 +33,15 @@ export default function FollowListBase({
|
||||
profileActions,
|
||||
}: FollowListBaseProps) {
|
||||
const publisher = useEventPublisher();
|
||||
const { follows, relays } = useLogin();
|
||||
const login = useLogin();
|
||||
|
||||
async function followAll() {
|
||||
if (publisher) {
|
||||
const ev = await publisher.contactList([...pubkeys, ...follows.item], relays.item);
|
||||
await FollowsFeed.backFill(System, pubkeys);
|
||||
const newFollows = dedupe([...pubkeys, ...login.follows.item]);
|
||||
const ev = await publisher.contactList(newFollows, login.relays.item);
|
||||
System.BroadcastEvent(ev);
|
||||
await FollowsFeed.backFill(System, pubkeys);
|
||||
setFollows(login, newFollows, ev.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,9 +51,9 @@ export default function FollowListBase({
|
||||
<div className="flex mt10 mb10">
|
||||
<div className="f-grow bold">{title}</div>
|
||||
{actions}
|
||||
<button className="transparent" type="button" onClick={() => followAll()}>
|
||||
<AsyncButton className="transparent" type="button" onClick={() => followAll()}>
|
||||
<FormattedMessage {...messages.FollowAll} />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
)}
|
||||
{pubkeys?.map(a => (
|
||||
|
@ -1,24 +1,84 @@
|
||||
import { NostrEvent, NostrLink } from "@snort/system";
|
||||
import { findTag } from "SnortUtils";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { findTag } from "SnortUtils";
|
||||
import ProfileImage from "./ProfileImage";
|
||||
import Icon from "Icons/Icon";
|
||||
|
||||
export function LiveEvent({ ev }: { ev: NostrEvent }) {
|
||||
const title = findTag(ev, "title");
|
||||
const status = findTag(ev, "status");
|
||||
const starts = Number(findTag(ev, "starts"));
|
||||
const host = ev.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;
|
||||
|
||||
function statusLine() {
|
||||
switch (status) {
|
||||
case "live": {
|
||||
return (
|
||||
<div className="text">
|
||||
<div className="flex card">
|
||||
<div className="f-grow">
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
<div>
|
||||
<Link to={`https://zap.stream/${NostrLink.fromEvent(ev).encode()}`}>
|
||||
<button className="primary" type="button">
|
||||
<FormattedMessage defaultMessage="Watch Live!" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex g4">
|
||||
<Icon name="signal-01" />
|
||||
<b className="uppercase">
|
||||
<FormattedMessage defaultMessage="Live" />
|
||||
</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "ended": {
|
||||
return (
|
||||
<b className="uppercase">
|
||||
<FormattedMessage defaultMessage="Ended" />
|
||||
</b>
|
||||
);
|
||||
}
|
||||
case "planned": {
|
||||
return (
|
||||
<b className="uppercase">
|
||||
{new Intl.DateTimeFormat(undefined, { dateStyle: "full", timeStyle: "short" }).format(
|
||||
new Date(starts * 1000),
|
||||
)}
|
||||
</b>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cta() {
|
||||
const link = `https://zap.stream/${NostrLink.fromEvent(ev).encode()}`;
|
||||
switch (status) {
|
||||
case "live": {
|
||||
return (
|
||||
<Link to={link} target="_blank">
|
||||
<button type="button">
|
||||
<FormattedMessage defaultMessage="Join Stream" />
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
case "ended": {
|
||||
if (findTag(ev, "recording")) {
|
||||
return (
|
||||
<Link to={link} target="_blank">
|
||||
<button type="button">
|
||||
<FormattedMessage defaultMessage="Watch Replay" />
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex f-space br p24 bg-primary">
|
||||
<div className="flex g12">
|
||||
<ProfileImage pubkey={host} showUsername={false} size={56} />
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{statusLine()}
|
||||
</div>
|
||||
</div>
|
||||
<div>{cta()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -7,15 +7,15 @@ import messages from "./messages";
|
||||
|
||||
export default function LogoutButton() {
|
||||
const navigate = useNavigate();
|
||||
const publicKey = useLogin().publicKey;
|
||||
const login = useLogin();
|
||||
|
||||
if (!publicKey) return;
|
||||
if (!login.publicKey) return;
|
||||
return (
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
logout(publicKey);
|
||||
logout(login.id);
|
||||
navigate("/");
|
||||
}}>
|
||||
<FormattedMessage {...messages.Logout} />
|
||||
|
@ -20,6 +20,7 @@
|
||||
width: 500px;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
--border-color: var(--gray);
|
||||
}
|
||||
|
||||
.modal-body button.secondary:hover {
|
||||
|
@ -1,23 +1,21 @@
|
||||
import "./Modal.css";
|
||||
import { useEffect, MouseEventHandler, ReactNode } from "react";
|
||||
import { ReactNode, useEffect } from "react";
|
||||
|
||||
export interface ModalProps {
|
||||
id: string;
|
||||
className?: string;
|
||||
onClose?: MouseEventHandler;
|
||||
onClose?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Modal(props: ModalProps) {
|
||||
const onClose = props.onClose || (() => undefined);
|
||||
const className = props.className || "";
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add("scroll-lock");
|
||||
return () => document.body.classList.remove("scroll-lock");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`modal ${className}`} onClick={onClose}>
|
||||
<div className={`modal${props.className ? ` ${props.className}` : ""}`} onClick={props.onClose}>
|
||||
<div className="modal-body" onClick={e => e.stopPropagation()}>
|
||||
{props.children}
|
||||
</div>
|
||||
|
@ -18,7 +18,7 @@ import AsyncButton from "Element/AsyncButton";
|
||||
import SendSats from "Element/SendSats";
|
||||
import Copy from "Element/Copy";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import { debounce } from "SnortUtils";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import SnortServiceProvider from "Nip05/SnortServiceProvider";
|
||||
|
@ -6,7 +6,7 @@ import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { TaggedNostrEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt, parseZap, NostrLink } from "@snort/system";
|
||||
|
||||
import { System } from "index";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import Icon from "Icons/Icon";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import Text from "Element/Text";
|
||||
|
@ -1,23 +1,17 @@
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { HexKey, Lists, NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import { TranslateHost } from "Const";
|
||||
import { System } from "index";
|
||||
import Icon from "Icons/Icon";
|
||||
import { setPinned, setBookmarked } from "Login";
|
||||
import {
|
||||
setNote as setReBroadcastNote,
|
||||
setShow as setReBroadcastShow,
|
||||
reset as resetReBroadcast,
|
||||
} from "State/ReBroadcast";
|
||||
import messages from "Element/messages";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { RootState } from "State/Store";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import { ReBroadcaster } from "./ReBroadcaster";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface NoteTranslation {
|
||||
text: string;
|
||||
@ -33,15 +27,12 @@ interface NosteContextMenuProps {
|
||||
}
|
||||
|
||||
export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { formatMessage } = useIntl();
|
||||
const login = useLogin();
|
||||
const { pinned, bookmarked, publicKey, preferences: prefs } = login;
|
||||
const { mute, block } = useModeration();
|
||||
const publisher = useEventPublisher();
|
||||
const showReBroadcastModal = useSelector((s: RootState) => s.reBroadcast.show);
|
||||
const reBroadcastNote = useSelector((s: RootState) => s.reBroadcast.note);
|
||||
const willRenderReBroadcast = showReBroadcastModal && reBroadcastNote && reBroadcastNote?.id === ev.id;
|
||||
const [showBroadcast, setShowBroadcast] = useState(false);
|
||||
const lang = window.navigator.language;
|
||||
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
|
||||
type: "language",
|
||||
@ -119,12 +110,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
|
||||
}
|
||||
|
||||
const handleReBroadcastButtonClick = () => {
|
||||
if (reBroadcastNote?.id !== ev.id) {
|
||||
dispatch(resetReBroadcast());
|
||||
}
|
||||
|
||||
dispatch(setReBroadcastNote(ev));
|
||||
dispatch(setReBroadcastShow(!showReBroadcastModal));
|
||||
setShowBroadcast(true);
|
||||
};
|
||||
|
||||
function menuItems() {
|
||||
@ -214,7 +200,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
|
||||
menuClassName="ctx-menu">
|
||||
{menuItems()}
|
||||
</Menu>
|
||||
{willRenderReBroadcast && <ReBroadcaster />}
|
||||
{showBroadcast && <ReBroadcaster ev={ev} onClose={() => setShowBroadcast(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,31 +1,23 @@
|
||||
import "./NoteCreator.css";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder, tryParseNostrLink, NostrLink } from "@snort/system";
|
||||
import {
|
||||
EventKind,
|
||||
NostrPrefix,
|
||||
TaggedNostrEvent,
|
||||
EventBuilder,
|
||||
tryParseNostrLink,
|
||||
NostrLink,
|
||||
NostrEvent,
|
||||
} from "@snort/system";
|
||||
|
||||
import Icon from "Icons/Icon";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import { openFile } from "SnortUtils";
|
||||
import Textarea from "Element/Textarea";
|
||||
import Modal from "Element/Modal";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import useFileUpload from "Upload";
|
||||
import Note from "Element/Note";
|
||||
import {
|
||||
setShow,
|
||||
setNote,
|
||||
setError,
|
||||
setActive,
|
||||
setPreview,
|
||||
setShowAdvanced,
|
||||
setSelectedCustomRelays,
|
||||
setZapSplits,
|
||||
setSensitive,
|
||||
reset,
|
||||
setPollOptions,
|
||||
setOtherEvents,
|
||||
} from "State/NoteCreator";
|
||||
import type { RootState } from "State/Store";
|
||||
|
||||
import { ClipboardEventHandler } from "react";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
@ -34,37 +26,24 @@ import AsyncButton from "Element/AsyncButton";
|
||||
import { AsyncIcon } from "Element/AsyncIcon";
|
||||
import { fetchNip05Pubkey } from "@snort/shared";
|
||||
import { ZapTarget } from "Zapper";
|
||||
import { useNoteCreator } from "State/NoteCreator";
|
||||
|
||||
export function NoteCreator() {
|
||||
const { formatMessage } = useIntl();
|
||||
const publisher = useEventPublisher();
|
||||
const uploader = useFileUpload();
|
||||
const {
|
||||
note,
|
||||
zapSplits,
|
||||
sensitive,
|
||||
pollOptions,
|
||||
replyTo,
|
||||
otherEvents,
|
||||
preview,
|
||||
active,
|
||||
show,
|
||||
showAdvanced,
|
||||
selectedCustomRelays,
|
||||
error,
|
||||
} = useSelector((s: RootState) => s.noteCreator);
|
||||
const dispatch = useDispatch();
|
||||
const login = useLogin();
|
||||
const note = useNoteCreator();
|
||||
const relays = login.relays;
|
||||
|
||||
async function buildNote() {
|
||||
try {
|
||||
dispatch(setError(""));
|
||||
note.update(v => (v.error = ""));
|
||||
if (note && publisher) {
|
||||
let extraTags: Array<Array<string>> | undefined;
|
||||
if (zapSplits) {
|
||||
if (note.zapSplits) {
|
||||
const parsedSplits = [] as Array<ZapTarget>;
|
||||
for (const s of zapSplits) {
|
||||
for (const s of note.zapSplits) {
|
||||
if (s.value.startsWith(NostrPrefix.PublicKey) || s.value.startsWith(NostrPrefix.Profile)) {
|
||||
const link = tryParseNostrLink(s.value);
|
||||
if (link) {
|
||||
@ -114,43 +93,55 @@ export function NoteCreator() {
|
||||
extraTags = parsedSplits.map(v => ["zap", v.value, "", String(v.weight)]);
|
||||
}
|
||||
|
||||
if (sensitive) {
|
||||
if (note.sensitive) {
|
||||
extraTags ??= [];
|
||||
extraTags.push(["content-warning", sensitive]);
|
||||
extraTags.push(["content-warning", note.sensitive]);
|
||||
}
|
||||
const kind = pollOptions ? EventKind.Polls : EventKind.TextNote;
|
||||
if (pollOptions) {
|
||||
const kind = note.pollOptions ? EventKind.Polls : EventKind.TextNote;
|
||||
if (note.pollOptions) {
|
||||
extraTags ??= [];
|
||||
extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
|
||||
extraTags.push(...note.pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
|
||||
}
|
||||
const hk = (eb: EventBuilder) => {
|
||||
extraTags?.forEach(t => eb.tag(t));
|
||||
eb.kind(kind);
|
||||
return eb;
|
||||
};
|
||||
const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk);
|
||||
const ev = note.replyTo
|
||||
? await publisher.reply(note.replyTo, note.note, hk)
|
||||
: await publisher.note(note.note, hk);
|
||||
return ev;
|
||||
}
|
||||
} catch (e) {
|
||||
note.update(v => {
|
||||
if (e instanceof Error) {
|
||||
dispatch(setError(e.message));
|
||||
v.error = e.message;
|
||||
} else {
|
||||
dispatch(setError(e as string));
|
||||
v.error = e as string;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEventToRelays(ev: NostrEvent) {
|
||||
if (note.selectedCustomRelays) {
|
||||
await Promise.all(note.selectedCustomRelays.map(r => System.WriteOnceToRelay(r, ev)));
|
||||
} else {
|
||||
System.BroadcastEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendNote() {
|
||||
const ev = await buildNote();
|
||||
if (ev) {
|
||||
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, ev));
|
||||
else System.BroadcastEvent(ev);
|
||||
dispatch(reset());
|
||||
for (const oe of otherEvents) {
|
||||
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, oe));
|
||||
else System.BroadcastEvent(oe);
|
||||
await sendEventToRelays(ev);
|
||||
for (const oe of note.otherEvents ?? []) {
|
||||
await sendEventToRelays(oe);
|
||||
}
|
||||
dispatch(reset());
|
||||
note.update(v => {
|
||||
v.reset();
|
||||
v.show = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -160,10 +151,14 @@ export function NoteCreator() {
|
||||
if (file) {
|
||||
uploadFile(file);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
dispatch(setError(error?.message));
|
||||
} catch (e) {
|
||||
note.update(v => {
|
||||
if (e instanceof Error) {
|
||||
v.error = e.message;
|
||||
} else {
|
||||
v.error = e as string;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,35 +166,39 @@ export function NoteCreator() {
|
||||
try {
|
||||
if (file) {
|
||||
const rx = await uploader.upload(file, file.name);
|
||||
note.update(v => {
|
||||
if (rx.header) {
|
||||
const link = `nostr:${new NostrLink(NostrPrefix.Event, rx.header.id, rx.header.kind).encode()}`;
|
||||
dispatch(setNote(`${note ? `${note}\n` : ""}${link}`));
|
||||
dispatch(setOtherEvents([...otherEvents, rx.header]));
|
||||
v.note = `${v.note ? `${v.note}\n` : ""}${link}`;
|
||||
v.otherEvents = [...(v.otherEvents ?? []), rx.header];
|
||||
} else if (rx.url) {
|
||||
dispatch(setNote(`${note ? `${note}\n` : ""}${rx.url}`));
|
||||
v.note = `${v.note ? `${v.note}\n` : ""}${rx.url}`;
|
||||
} else if (rx?.error) {
|
||||
dispatch(setError(rx.error));
|
||||
v.error = rx.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
dispatch(setError(error?.message));
|
||||
} catch (e) {
|
||||
note.update(v => {
|
||||
if (e instanceof Error) {
|
||||
v.error = e.message;
|
||||
} else {
|
||||
v.error = e as string;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
const { value } = ev.target;
|
||||
dispatch(setNote(value));
|
||||
if (value) {
|
||||
dispatch(setActive(true));
|
||||
} else {
|
||||
dispatch(setActive(false));
|
||||
}
|
||||
note.update(n => (n.note = value));
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
dispatch(reset());
|
||||
note.update(v => {
|
||||
v.show = false;
|
||||
v.reset();
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
|
||||
@ -208,21 +207,19 @@ export function NoteCreator() {
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
if (preview) {
|
||||
dispatch(setPreview(undefined));
|
||||
if (note.preview) {
|
||||
note.update(v => (v.preview = undefined));
|
||||
} else if (publisher) {
|
||||
const tmpNote = await buildNote();
|
||||
if (tmpNote) {
|
||||
dispatch(setPreview(tmpNote));
|
||||
}
|
||||
note.update(v => (v.preview = tmpNote));
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewNote() {
|
||||
if (preview) {
|
||||
if (note.preview) {
|
||||
return (
|
||||
<Note
|
||||
data={preview as TaggedNostrEvent}
|
||||
data={note.preview as TaggedNostrEvent}
|
||||
related={[]}
|
||||
options={{
|
||||
showContextMenu: false,
|
||||
@ -236,13 +233,13 @@ export function NoteCreator() {
|
||||
}
|
||||
|
||||
function renderPollOptions() {
|
||||
if (pollOptions) {
|
||||
if (note.pollOptions) {
|
||||
return (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Poll Options" />
|
||||
</h4>
|
||||
{pollOptions?.map((a, i) => (
|
||||
{note.pollOptions?.map((a, i) => (
|
||||
<div className="form-group w-max" key={`po-${i}`}>
|
||||
<div>
|
||||
<FormattedMessage defaultMessage="Option: {n}" values={{ n: i + 1 }} />
|
||||
@ -257,7 +254,7 @@ export function NoteCreator() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => dispatch(setPollOptions([...pollOptions, ""]))}>
|
||||
<button onClick={() => note.update(v => (v.pollOptions = [...(note.pollOptions ?? []), ""]))}>
|
||||
<Icon name="plus" size={14} />
|
||||
</button>
|
||||
</>
|
||||
@ -266,18 +263,18 @@ export function NoteCreator() {
|
||||
}
|
||||
|
||||
function changePollOption(i: number, v: string) {
|
||||
if (pollOptions) {
|
||||
const copy = [...pollOptions];
|
||||
if (note.pollOptions) {
|
||||
const copy = [...note.pollOptions];
|
||||
copy[i] = v;
|
||||
dispatch(setPollOptions(copy));
|
||||
note.update(v => (v.pollOptions = copy));
|
||||
}
|
||||
}
|
||||
|
||||
function removePollOption(i: number) {
|
||||
if (pollOptions) {
|
||||
const copy = [...pollOptions];
|
||||
if (note.pollOptions) {
|
||||
const copy = [...note.pollOptions];
|
||||
copy.splice(i, 1);
|
||||
dispatch(setPollOptions(copy));
|
||||
note.update(v => (v.pollOptions = copy));
|
||||
}
|
||||
}
|
||||
|
||||
@ -292,20 +289,24 @@ export function NoteCreator() {
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!selectedCustomRelays || selectedCustomRelays.includes(r)}
|
||||
onChange={e =>
|
||||
dispatch(
|
||||
setSelectedCustomRelays(
|
||||
checked={!note.selectedCustomRelays || note.selectedCustomRelays.includes(r)}
|
||||
onChange={e => {
|
||||
note.update(
|
||||
v =>
|
||||
(v.selectedCustomRelays =
|
||||
// set false if all relays selected
|
||||
e.target.checked && selectedCustomRelays && selectedCustomRelays.length == a.length - 1
|
||||
? false
|
||||
e.target.checked &&
|
||||
note.selectedCustomRelays &&
|
||||
note.selectedCustomRelays.length == a.length - 1
|
||||
? undefined
|
||||
: // otherwise return selectedCustomRelays with target relay added / removed
|
||||
a.filter(el =>
|
||||
el === r ? e.target.checked : !selectedCustomRelays || selectedCustomRelays.includes(el),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
el === r
|
||||
? e.target.checked
|
||||
: !note.selectedCustomRelays || note.selectedCustomRelays.includes(el),
|
||||
)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -345,13 +346,12 @@ export function NoteCreator() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!note.show) return null;
|
||||
return (
|
||||
<>
|
||||
{show && (
|
||||
<Modal className="note-creator-modal" onClose={() => dispatch(setShow(false))}>
|
||||
{replyTo && (
|
||||
<Modal id="note-creator" className="note-creator-modal" onClose={() => note.update(v => (v.show = false))}>
|
||||
{note.replyTo && (
|
||||
<Note
|
||||
data={replyTo}
|
||||
data={note.replyTo}
|
||||
related={[]}
|
||||
options={{
|
||||
showFooter: false,
|
||||
@ -362,15 +362,15 @@ export function NoteCreator() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{preview && getPreviewNote()}
|
||||
{!preview && (
|
||||
<div onPaste={handlePaste} className={`note-creator${pollOptions ? " poll" : ""}`}>
|
||||
{note.preview && getPreviewNote()}
|
||||
{!note.preview && (
|
||||
<div onPaste={handlePaste} className={`note-creator${note.pollOptions ? " poll" : ""}`}>
|
||||
<Textarea
|
||||
autoFocus
|
||||
className={`textarea ${active ? "textarea--focused" : ""}`}
|
||||
onChange={onChange}
|
||||
value={note}
|
||||
onFocus={() => dispatch(setActive(true))}
|
||||
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);
|
||||
@ -382,14 +382,20 @@ export function NoteCreator() {
|
||||
)}
|
||||
<div className="flex f-space">
|
||||
<div className="flex g8">
|
||||
<ProfileImage pubkey={login.publicKey ?? ""} className="note-creator-icon" link="" showUsername={false} />
|
||||
{pollOptions === undefined && !replyTo && (
|
||||
<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={() => dispatch(setPollOptions(["A", "B"]))} size={24} />
|
||||
<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={() => dispatch(setShowAdvanced(!showAdvanced))}>
|
||||
<button className="secondary" onClick={() => note.update(v => (v.advanced = !v.advanced))}>
|
||||
<FormattedMessage defaultMessage="Advanced" />
|
||||
</button>
|
||||
</div>
|
||||
@ -397,13 +403,13 @@ export function NoteCreator() {
|
||||
<button className="secondary" onClick={cancel}>
|
||||
<FormattedMessage defaultMessage="Cancel" />
|
||||
</button>
|
||||
<AsyncButton className="primary" onClick={onSubmit}>
|
||||
{replyTo ? <FormattedMessage defaultMessage="Reply" /> : <FormattedMessage defaultMessage="Send" />}
|
||||
<AsyncButton onClick={onSubmit}>
|
||||
{note.replyTo ? <FormattedMessage defaultMessage="Reply" /> : <FormattedMessage defaultMessage="Send" />}
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
{error && <span className="error">{error}</span>}
|
||||
{showAdvanced && (
|
||||
{note.error && <span className="error">{note.error}</span>}
|
||||
{note.advanced && (
|
||||
<>
|
||||
<button className="secondary" onClick={loadPreview}>
|
||||
<FormattedMessage defaultMessage="Toggle Preview" />
|
||||
@ -423,7 +429,7 @@ export function NoteCreator() {
|
||||
</h4>
|
||||
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." />
|
||||
<div className="flex-column g8">
|
||||
{[...(zapSplits ?? [])].map((v, i, arr) => (
|
||||
{[...(note.zapSplits ?? [])].map((v, i, arr) => (
|
||||
<div className="flex f-center g8">
|
||||
<div className="flex-column f-4 g4">
|
||||
<h4>
|
||||
@ -433,8 +439,8 @@ export function NoteCreator() {
|
||||
type="text"
|
||||
value={v.value}
|
||||
onChange={e =>
|
||||
dispatch(
|
||||
setZapSplits(arr.map((vv, ii) => (ii === i ? { ...vv, value: e.target.value } : vv))),
|
||||
note.update(
|
||||
v => (v.zapSplits = arr.map((vv, ii) => (ii === i ? { ...vv, value: e.target.value } : vv))),
|
||||
)
|
||||
}
|
||||
placeholder={formatMessage({ defaultMessage: "npub / nprofile / nostr address" })}
|
||||
@ -449,10 +455,11 @@ export function NoteCreator() {
|
||||
min={0}
|
||||
value={v.weight}
|
||||
onChange={e =>
|
||||
dispatch(
|
||||
setZapSplits(
|
||||
arr.map((vv, ii) => (ii === i ? { ...vv, weight: Number(e.target.value) } : vv)),
|
||||
),
|
||||
note.update(
|
||||
v =>
|
||||
(v.zapSplits = arr.map((vv, ii) =>
|
||||
ii === i ? { ...vv, weight: Number(e.target.value) } : vv,
|
||||
)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
@ -461,7 +468,7 @@ export function NoteCreator() {
|
||||
<div> </div>
|
||||
<Icon
|
||||
name="close"
|
||||
onClick={() => dispatch(setZapSplits((zapSplits ?? []).filter((_v, ii) => ii !== i)))}
|
||||
onClick={() => note.update(v => (v.zapSplits = (v.zapSplits ?? []).filter((_v, ii) => ii !== i)))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -469,7 +476,7 @@ export function NoteCreator() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch(setZapSplits([...(zapSplits ?? []), { type: "pubkey", value: "", weight: 1 }]))
|
||||
note.update(v => (v.zapSplits = [...(v.zapSplits ?? []), { type: "pubkey", value: "", weight: 1 }]))
|
||||
}>
|
||||
<FormattedMessage defaultMessage="Add" />
|
||||
</button>
|
||||
@ -486,8 +493,8 @@ export function NoteCreator() {
|
||||
<input
|
||||
className="w-max"
|
||||
type="text"
|
||||
value={sensitive}
|
||||
onChange={e => dispatch(setSensitive(e.target.value))}
|
||||
value={note.sensitive}
|
||||
onChange={e => note.update(v => (v.sensitive = e.target.value))}
|
||||
maxLength={50}
|
||||
minLength={1}
|
||||
placeholder={formatMessage({
|
||||
@ -501,7 +508,5 @@ export function NoteCreator() {
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,18 +1,15 @@
|
||||
import React, { HTMLProps, useContext, useEffect, useState } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useLongPress } from "use-long-press";
|
||||
import { TaggedNostrEvent, ParsedZap, countLeadingZeros, NostrLink } from "@snort/system";
|
||||
import { SnortContext, useUserProfile } from "@snort/system-react";
|
||||
|
||||
import { formatShort } from "Number";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import { delay, findTag, normalizeReaction } from "SnortUtils";
|
||||
import { NoteCreator } from "Element/NoteCreator";
|
||||
import SendSats from "Element/SendSats";
|
||||
import { ZapsSummary } from "Element/Zap";
|
||||
import { RootState } from "State/Store";
|
||||
import { setReplyTo, setShow, reset } from "State/NoteCreator";
|
||||
import { AsyncIcon } from "Element/AsyncIcon";
|
||||
|
||||
import { useWallet } from "Wallet";
|
||||
@ -22,6 +19,7 @@ import { ZapPoolController } from "ZapPoolController";
|
||||
import { System } from "index";
|
||||
import { Zapper, ZapTarget } from "Zapper";
|
||||
import { getDisplayName } from "./ProfileImage";
|
||||
import { useNoteCreator } from "State/NoteCreator";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -47,7 +45,6 @@ export interface NoteFooterProps {
|
||||
|
||||
export default function NoteFooter(props: NoteFooterProps) {
|
||||
const { ev, positive, reposts, zaps } = props;
|
||||
const dispatch = useDispatch();
|
||||
const system = useContext(SnortContext);
|
||||
const { formatMessage } = useIntl();
|
||||
const login = useLogin();
|
||||
@ -55,9 +52,8 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
const author = useUserProfile(ev.pubkey);
|
||||
const interactionCache = useInteractionCache(publicKey, ev.id);
|
||||
const publisher = useEventPublisher();
|
||||
const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show);
|
||||
const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo);
|
||||
const willRenderNoteCreator = showNoteCreatorModal && replyTo?.id === ev.id;
|
||||
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update }));
|
||||
const willRenderNoteCreator = note.show && note.replyTo?.id === ev.id;
|
||||
const [tip, setTip] = useState(false);
|
||||
const [zapping, setZapping] = useState(false);
|
||||
const walletState = useWallet();
|
||||
@ -238,7 +234,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
function replyIcon() {
|
||||
return (
|
||||
<AsyncFooterIcon
|
||||
className={showNoteCreatorModal ? "reacted" : ""}
|
||||
className={note.show ? "reacted" : ""}
|
||||
iconName="reply"
|
||||
title={formatMessage({ defaultMessage: "Reply" })}
|
||||
value={0}
|
||||
@ -248,12 +244,13 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
}
|
||||
|
||||
const handleReplyButtonClick = () => {
|
||||
if (replyTo?.id !== ev.id) {
|
||||
dispatch(reset());
|
||||
note.update(v => {
|
||||
if (v.replyTo?.id !== ev.id) {
|
||||
v.reset();
|
||||
}
|
||||
|
||||
dispatch(setReplyTo(ev));
|
||||
dispatch(setShow(!showNoteCreatorModal));
|
||||
v.show = true;
|
||||
v.replyTo = ev;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@ -266,7 +263,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
{tipButton()}
|
||||
{powIcon()}
|
||||
</div>
|
||||
{willRenderNoteCreator && <NoteCreator />}
|
||||
{willRenderNoteCreator && <NoteCreator key={`note-creator-${ev.id}`} />}
|
||||
<SendSats targets={getZapTarget()} onClose={() => setTip(false)} show={tip} note={ev.id} allocatePool={true} />
|
||||
</div>
|
||||
<ZapsSummary zaps={zaps} />
|
||||
|
7
packages/app/src/Element/PinPrompt.css
Normal file
7
packages/app/src/Element/PinPrompt.css
Normal file
@ -0,0 +1,7 @@
|
||||
.pin-box {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px 16px;
|
||||
font-size: 80px;
|
||||
height: 1em;
|
||||
border-radius: 12px;
|
||||
}
|
159
packages/app/src/Element/PinPrompt.tsx
Normal file
159
packages/app/src/Element/PinPrompt.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import { useWallet } from "Wallet";
|
||||
import { unwrap } from "SnortUtils";
|
||||
import { formatShort } from "Number";
|
||||
|
@ -10,7 +10,7 @@ import { Toastore } from "Toaster";
|
||||
import { getDisplayName } from "Element/ProfileImage";
|
||||
import { UserCache } from "Cache";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import { WalletInvoiceState } from "Wallet";
|
||||
|
||||
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
|
||||
|
@ -1,28 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import Modal from "Element/Modal";
|
||||
import type { RootState } from "State/Store";
|
||||
import { setShow, reset, setSelectedCustomRelays } from "State/ReBroadcast";
|
||||
import messages from "./messages";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { System } from "index";
|
||||
|
||||
export function ReBroadcaster() {
|
||||
export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: TaggedNostrEvent }) {
|
||||
const [selected, setSelected] = useState<Array<string>>();
|
||||
const publisher = useEventPublisher();
|
||||
const { note, show, selectedCustomRelays } = useSelector((s: RootState) => s.reBroadcast);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
async function sendReBroadcast() {
|
||||
if (note && publisher) {
|
||||
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, note));
|
||||
else System.BroadcastEvent(note);
|
||||
dispatch(reset());
|
||||
if (publisher) {
|
||||
if (selected) {
|
||||
await Promise.all(selected.map(r => System.WriteOnceToRelay(r, ev)));
|
||||
} else {
|
||||
System.BroadcastEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
dispatch(reset());
|
||||
}
|
||||
|
||||
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
|
||||
@ -46,18 +43,12 @@ export function ReBroadcaster() {
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!selectedCustomRelays || selectedCustomRelays.includes(r)}
|
||||
checked={!selected || selected.includes(r)}
|
||||
onChange={e =>
|
||||
dispatch(
|
||||
setSelectedCustomRelays(
|
||||
// set false if all relays selected
|
||||
e.target.checked && selectedCustomRelays && selectedCustomRelays.length == a.length - 1
|
||||
? false
|
||||
: // otherwise return selectedCustomRelays with target relay added / removed
|
||||
a.filter(el =>
|
||||
el === r ? e.target.checked : !selectedCustomRelays || selectedCustomRelays.includes(el),
|
||||
),
|
||||
),
|
||||
setSelected(
|
||||
e.target.checked && selected && selected.length == a.length - 1
|
||||
? undefined
|
||||
: a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))),
|
||||
)
|
||||
}
|
||||
/>
|
||||
@ -70,11 +61,10 @@ export function ReBroadcaster() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{show && (
|
||||
<Modal className="note-creator-modal" onClose={() => dispatch(setShow(false))}>
|
||||
<Modal id="broadcaster" className="note-creator-modal" onClose={onClose}>
|
||||
{renderRelayCustomisation()}
|
||||
<div className="note-creator-actions">
|
||||
<button className="secondary" onClick={cancel}>
|
||||
<button className="secondary" onClick={onClose}>
|
||||
<FormattedMessage {...messages.Cancel} />
|
||||
</button>
|
||||
<button onClick={onSubmit}>
|
||||
@ -82,7 +72,6 @@ export function ReBroadcaster() {
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -72,7 +72,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
|
||||
}, [show]);
|
||||
|
||||
return show ? (
|
||||
<Modal className="reactions-modal" onClose={onClose}>
|
||||
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
|
||||
<div className="close" onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</div>
|
||||
|
@ -8,7 +8,7 @@ import { LNURLSuccessAction } from "@snort/shared";
|
||||
|
||||
import { formatShort } from "Number";
|
||||
import Icon from "Icons/Icon";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import Modal from "Element/Modal";
|
||||
import QrCode from "Element/QrCode";
|
||||
@ -180,7 +180,7 @@ export default function SendSats(props: SendSatsProps) {
|
||||
|
||||
if (!(props.show ?? false)) return null;
|
||||
return (
|
||||
<Modal className="lnurl-modal" onClose={onClose}>
|
||||
<Modal id="send-sats" className="lnurl-modal" onClose={onClose}>
|
||||
<div className="p flex-column g12">
|
||||
<div className="flex g12">
|
||||
<div className="flex f-grow">{props.title || title()}</div>
|
||||
|
@ -55,7 +55,7 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
|
||||
|
||||
export function SpotlightMediaModal(props: SpotlightMediaProps) {
|
||||
return (
|
||||
<Modal onClose={props.onClose} className="spotlight">
|
||||
<Modal id="spotlight" onClose={props.onClose} className="spotlight">
|
||||
<SpotlightMedia {...props} />
|
||||
</Modal>
|
||||
);
|
||||
|
@ -9,7 +9,7 @@ import BackButton from "Element/BackButton";
|
||||
import Note from "Element/Note";
|
||||
import NoteGhost from "Element/NoteGhost";
|
||||
import Collapsed from "Element/Collapsed";
|
||||
import { ThreadContext, ThreadContextWrapper } from "Hooks/useThreadContext";
|
||||
import { ThreadContext, ThreadContextWrapper, chainKey } from "Hooks/useThreadContext";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -297,14 +297,35 @@ export function Thread(props: { onBack?: () => void }) {
|
||||
description: "Navigate back button on threads view",
|
||||
});
|
||||
|
||||
const debug = window.location.search.includes("debug=true");
|
||||
return (
|
||||
<>
|
||||
{debug && (
|
||||
<div className="main-content p xs">
|
||||
<h1>Chains</h1>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
Object.fromEntries([...thread.chains.entries()].map(([k, v]) => [k, v.map(c => c.id)])),
|
||||
undefined,
|
||||
" ",
|
||||
)}
|
||||
</pre>
|
||||
<h1>Current</h1>
|
||||
<pre>{JSON.stringify(thread.current)}</pre>
|
||||
<h1>Root</h1>
|
||||
<pre>{JSON.stringify(thread.root, undefined, " ")}</pre>
|
||||
<h1>Data</h1>
|
||||
<pre>{JSON.stringify(thread.data, undefined, " ")}</pre>
|
||||
<h1>Reactions</h1>
|
||||
<pre>{JSON.stringify(thread.reactions, undefined, " ")}</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="main-content p">
|
||||
<BackButton onClick={goBack} text={parent ? parentText : backText} />
|
||||
</div>
|
||||
<div className="main-content">
|
||||
{thread.root && renderRoot(thread.root)}
|
||||
{thread.root && renderChain(thread.root.id)}
|
||||
{thread.root && renderChain(chainKey(thread.root))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -11,7 +11,7 @@ import Note from "Element/Note";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { FollowsFeed } from "Cache";
|
||||
import { LiveStreams } from "Element/LiveStreams";
|
||||
import { useReactions } from "Feed/FeedReactions";
|
||||
import { useReactions } from "Feed/Reactions";
|
||||
import AsyncButton from "./AsyncButton";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
|
@ -4,7 +4,7 @@ import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import PageSpinner from "Element/PageSpinner";
|
||||
import Note from "Element/Note";
|
||||
import NostrBandApi from "External/NostrBand";
|
||||
import { useReactions } from "Feed/FeedReactions";
|
||||
import { useReactions } from "Feed/Reactions";
|
||||
|
||||
export default function TrendingNotes() {
|
||||
const [posts, setPosts] = useState<Array<NostrEvent>>();
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { NostrPrefix, NostrEvent, NostrLink } from "@snort/system";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import Icon from "Icons/Icon";
|
||||
import Spinner from "Icons/Spinner";
|
||||
import { useState } from "react";
|
||||
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
@ -4,7 +4,7 @@ import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "SnortUtils";
|
||||
import { makeNotification, sendNotification } from "Notifications";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import { getMutedKeys } from "Feed/MuteList";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
@ -28,9 +28,7 @@ export default function useLoginFeed() {
|
||||
|
||||
useRefreshFeedCache(Notifications, true);
|
||||
useRefreshFeedCache(FollowsFeed, true);
|
||||
if (publisher?.supports("nip44")) {
|
||||
useRefreshFeedCache(GiftsCache, true);
|
||||
}
|
||||
|
||||
const subLogin = useMemo(() => {
|
||||
if (!pubKey) return null;
|
||||
|
37
packages/app/src/Feed/Reactions.ts
Normal file
37
packages/app/src/Feed/Reactions.ts
Normal 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);
|
||||
}
|
@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EventKind, NostrLink, RequestBuilder, NoteCollection } from "@snort/system";
|
||||
import { EventKind, NostrLink, RequestBuilder, NoteCollection, EventExt } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
import { useReactions } from "./FeedReactions";
|
||||
import { useReactions } from "./Reactions";
|
||||
|
||||
export default function useThreadFeed(link: NostrLink) {
|
||||
const [root, setRoot] = useState<NostrLink>();
|
||||
const [allEvents, setAllEvents] = useState<Array<NostrLink>>([]);
|
||||
|
||||
const sub = useMemo(() => {
|
||||
@ -12,10 +13,22 @@ export default function useThreadFeed(link: NostrLink) {
|
||||
sub.withOptions({
|
||||
leaveOpen: true,
|
||||
});
|
||||
sub.withFilter().kinds([EventKind.TextNote]).link(link).replyToLink(link);
|
||||
allEvents.forEach(x => {
|
||||
sub.withFilter().kinds([EventKind.TextNote]).link(x).replyToLink(x);
|
||||
});
|
||||
sub.withFilter().link(link);
|
||||
if (root) {
|
||||
sub.withFilter().link(root);
|
||||
}
|
||||
const grouped = [link, ...allEvents].reduce(
|
||||
(acc, v) => {
|
||||
acc[v.type] ??= [];
|
||||
acc[v.type].push(v);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Array<NostrLink>>,
|
||||
);
|
||||
|
||||
for (const [, v] of Object.entries(grouped)) {
|
||||
sub.withFilter().kinds([EventKind.TextNote]).replyToLink(v);
|
||||
}
|
||||
return sub;
|
||||
}, [allEvents.length]);
|
||||
|
||||
@ -23,14 +36,31 @@ export default function useThreadFeed(link: NostrLink) {
|
||||
|
||||
useEffect(() => {
|
||||
if (store.data) {
|
||||
const mainNotes = store.data?.filter(a => a.kind === EventKind.TextNote || a.kind === EventKind.Polls) ?? [];
|
||||
const links = mainNotes
|
||||
const links = store.data
|
||||
.map(a => [
|
||||
NostrLink.fromEvent(a),
|
||||
...a.tags.filter(a => a[0] === "e" || a[0] === "a").map(v => NostrLink.fromTag(v)),
|
||||
])
|
||||
.flat();
|
||||
setAllEvents(links);
|
||||
|
||||
const current = store.data.find(a => link.matchesEvent(a));
|
||||
if (current) {
|
||||
const t = EventExt.extractThread(current);
|
||||
if (t) {
|
||||
const rootOrReplyAsRoot = t?.root ?? t?.replyTo;
|
||||
if (rootOrReplyAsRoot) {
|
||||
setRoot(
|
||||
NostrLink.fromTag([
|
||||
rootOrReplyAsRoot.key,
|
||||
rootOrReplyAsRoot.value ?? "",
|
||||
rootOrReplyAsRoot.relay ?? "",
|
||||
...(rootOrReplyAsRoot.marker ?? []),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [store.data?.length]);
|
||||
|
||||
|
@ -7,7 +7,7 @@ export default function useZapsFeed(link?: NostrLink) {
|
||||
const sub = useMemo(() => {
|
||||
if (!link) return null;
|
||||
const b = new RequestBuilder(`zaps:${link.encode()}`);
|
||||
b.withFilter().kinds([EventKind.ZapReceipt]).replyToLink(link);
|
||||
b.withFilter().kinds([EventKind.ZapReceipt]).replyToLink([link]);
|
||||
return b;
|
||||
}, [link]);
|
||||
|
||||
|
21
packages/app/src/Hooks/useEventPublisher.tsx
Normal file
21
packages/app/src/Hooks/useEventPublisher.tsx
Normal 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;
|
||||
}
|
@ -1,17 +1,20 @@
|
||||
import { useIntl } from "react-intl";
|
||||
import { Nip46Signer, PinEncrypted } from "@snort/system";
|
||||
|
||||
import { EmailRegex, MnemonicRegex } from "Const";
|
||||
import { LoginSessionType, LoginStore } from "Login";
|
||||
import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
|
||||
import { getNip05PubKey } from "Pages/LoginPage";
|
||||
import { bech32ToHex } from "SnortUtils";
|
||||
import { Nip46Signer } from "@snort/system";
|
||||
import { unwrap } from "@snort/shared";
|
||||
|
||||
export class PinRequiredError extends Error {}
|
||||
|
||||
export default function useLoginHandler() {
|
||||
const { formatMessage } = useIntl();
|
||||
const hasSubtleCrypto = window.crypto.subtle !== undefined;
|
||||
|
||||
async function doLogin(key: string) {
|
||||
async function doLogin(key: string, pin?: string) {
|
||||
const insecureMsg = formatMessage({
|
||||
defaultMessage:
|
||||
"Can't login with private key on an insecure connection, please use a Nostr key manager extension instead",
|
||||
@ -23,7 +26,8 @@ export default function useLoginHandler() {
|
||||
}
|
||||
const hexKey = bech32ToHex(key);
|
||||
if (hexKey.length === 64) {
|
||||
LoginStore.loginWithPrivateKey(hexKey);
|
||||
if (!pin) throw new PinRequiredError();
|
||||
LoginStore.loginWithPrivateKey(await PinEncrypted.create(hexKey, pin));
|
||||
} else {
|
||||
throw new Error("INVALID PRIVATE KEY");
|
||||
}
|
||||
@ -31,14 +35,16 @@ export default function useLoginHandler() {
|
||||
if (!hasSubtleCrypto) {
|
||||
throw new Error(insecureMsg);
|
||||
}
|
||||
if (!pin) throw new PinRequiredError();
|
||||
const ent = generateBip39Entropy(key);
|
||||
const keyHex = entropyToPrivateKey(ent);
|
||||
LoginStore.loginWithPrivateKey(keyHex);
|
||||
LoginStore.loginWithPrivateKey(await PinEncrypted.create(keyHex, pin));
|
||||
} else if (key.length === 64) {
|
||||
if (!hasSubtleCrypto) {
|
||||
throw new Error(insecureMsg);
|
||||
}
|
||||
LoginStore.loginWithPrivateKey(key);
|
||||
if (!pin) throw new PinRequiredError();
|
||||
LoginStore.loginWithPrivateKey(await PinEncrypted.create(key, pin));
|
||||
}
|
||||
|
||||
// public key logins
|
||||
@ -49,11 +55,18 @@ export default function useLoginHandler() {
|
||||
const hexKey = await getNip05PubKey(key);
|
||||
LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey);
|
||||
} else if (key.startsWith("bunker://")) {
|
||||
if (!pin) throw new PinRequiredError();
|
||||
const nip46 = new Nip46Signer(key);
|
||||
await nip46.init();
|
||||
|
||||
const loginPubkey = await nip46.getPubKey();
|
||||
LoginStore.loginWithPubkey(loginPubkey, LoginSessionType.Nip46, undefined, nip46.relays, nip46.privateKey);
|
||||
LoginStore.loginWithPubkey(
|
||||
loginPubkey,
|
||||
LoginSessionType.Nip46,
|
||||
undefined,
|
||||
nip46.relays,
|
||||
await PinEncrypted.create(unwrap(nip46.privateKey), pin),
|
||||
);
|
||||
nip46.close();
|
||||
} else {
|
||||
throw new Error("INVALID PRIVATE KEY");
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { HexKey } from "@snort/system";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { setBlocked, setMuted } from "Login";
|
||||
import { appendDedupe } from "SnortUtils";
|
||||
|
@ -1,17 +1,18 @@
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { NoopStore, RequestBuilder, TaggedNostrEvent } from "@snort/system";
|
||||
import { unwrap } from "@snort/shared";
|
||||
|
||||
import { RefreshFeedCache } from "Cache/RefreshFeedCache";
|
||||
import useLogin from "./useLogin";
|
||||
import useEventPublisher from "./useEventPublisher";
|
||||
|
||||
export function useRefreshFeedCache<T>(c: RefreshFeedCache<T>, leaveOpen = false) {
|
||||
const system = useContext(SnortContext);
|
||||
const login = useLogin();
|
||||
const publisher = useEventPublisher();
|
||||
|
||||
const sub = useMemo(() => {
|
||||
if (login) {
|
||||
if (login.publicKey) {
|
||||
const rb = new RequestBuilder(`using-${c.name}`);
|
||||
rb.withOptions({
|
||||
leaveOpen,
|
||||
@ -28,11 +29,11 @@ export function useRefreshFeedCache<T>(c: RefreshFeedCache<T>, leaveOpen = false
|
||||
let t: ReturnType<typeof setTimeout> | undefined;
|
||||
let tBuf: Array<TaggedNostrEvent> = [];
|
||||
const releaseOnEvent = q.feed.onEvent(evs => {
|
||||
if (!t) {
|
||||
if (!t && publisher) {
|
||||
tBuf = [...evs];
|
||||
t = setTimeout(() => {
|
||||
t = undefined;
|
||||
c.onEvent(tBuf, unwrap(login.publisher));
|
||||
c.onEvent(tBuf, publisher);
|
||||
}, 100);
|
||||
} else {
|
||||
tBuf.push(...evs);
|
||||
|
@ -1,7 +1,7 @@
|
||||
/* eslint-disable no-debugger */
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { EventExt, NostrLink, NostrPrefix, TaggedNostrEvent, u256, Thread as ThreadInfo } from "@snort/system";
|
||||
import { EventExt, NostrLink, TaggedNostrEvent, u256 } from "@snort/system";
|
||||
import useThreadFeed from "Feed/ThreadFeed";
|
||||
import { findTag } from "SnortUtils";
|
||||
import { ReactNode, createContext, useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
@ -10,14 +10,31 @@ export interface ThreadContext {
|
||||
root?: TaggedNostrEvent;
|
||||
chains: Map<string, Array<TaggedNostrEvent>>;
|
||||
data: Array<TaggedNostrEvent>;
|
||||
reactions: Array<TaggedNostrEvent>;
|
||||
setCurrent: (i: string) => void;
|
||||
}
|
||||
|
||||
export const ThreadContext = createContext({} as ThreadContext);
|
||||
|
||||
/**
|
||||
* Get the chain key as a reply event
|
||||
*/
|
||||
export function replyChainKey(ev: TaggedNostrEvent) {
|
||||
const t = EventExt.extractThread(ev);
|
||||
return t?.replyTo?.value ?? t?.root?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the chain key of this event
|
||||
*/
|
||||
export function chainKey(ev: TaggedNostrEvent) {
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
return unwrap(link.toEventTag())[1];
|
||||
}
|
||||
|
||||
export function ThreadContextWrapper({ link, children }: { link: NostrLink; children?: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const [currentId, setCurrentId] = useState(link.id);
|
||||
const [currentId, setCurrentId] = useState(unwrap(link.toEventTag())[1]);
|
||||
const feed = useThreadFeed(link);
|
||||
|
||||
const chains = useMemo(() => {
|
||||
@ -26,15 +43,7 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
|
||||
feed.thread
|
||||
?.sort((a, b) => b.created_at - a.created_at)
|
||||
.forEach(v => {
|
||||
const t = EventExt.extractThread(v);
|
||||
if (t) {
|
||||
let replyTo = t.replyTo?.value ?? t.root?.value;
|
||||
if (t.root?.key === "a" && t.root?.value) {
|
||||
const parsed = t.root.value.split(":");
|
||||
replyTo = feed.thread?.find(
|
||||
a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2],
|
||||
)?.id;
|
||||
}
|
||||
const replyTo = replyChainKey(v);
|
||||
if (replyTo) {
|
||||
if (!chains.has(replyTo)) {
|
||||
chains.set(replyTo, [v]);
|
||||
@ -42,7 +51,6 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
|
||||
unwrap(chains.get(replyTo)).push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return chains;
|
||||
@ -51,58 +59,27 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
|
||||
// Root is the parent of the current note or the current note if its a root note or the root of the thread
|
||||
const root = useMemo(() => {
|
||||
const currentNote =
|
||||
feed.thread?.find(
|
||||
ne =>
|
||||
ne.id === currentId ||
|
||||
(link.type === NostrPrefix.Address && findTag(ne, "d") === currentId && ne.pubkey === link.author),
|
||||
) ?? (location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined);
|
||||
feed.thread?.find(a => chainKey(a) === currentId) ??
|
||||
(location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined);
|
||||
if (currentNote) {
|
||||
const currentThread = EventExt.extractThread(currentNote);
|
||||
const isRoot = (ne?: ThreadInfo) => ne === undefined;
|
||||
|
||||
if (isRoot(currentThread)) {
|
||||
const key = replyChainKey(currentNote);
|
||||
if (key) {
|
||||
return feed.thread?.find(a => chainKey(a) === key);
|
||||
} else {
|
||||
return currentNote;
|
||||
}
|
||||
const replyTo = currentThread?.replyTo ?? currentThread?.root;
|
||||
|
||||
// sometimes the root event ID is missing, and we can only take the happy path if the root event ID exists
|
||||
if (replyTo) {
|
||||
if (replyTo.key === "a" && replyTo.value) {
|
||||
const parsed = replyTo.value.split(":");
|
||||
return feed.thread?.find(
|
||||
a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2],
|
||||
);
|
||||
}
|
||||
if (replyTo.value) {
|
||||
return feed.thread?.find(a => a.id === replyTo.value);
|
||||
}
|
||||
}
|
||||
|
||||
const possibleRoots = feed.thread?.filter(a => {
|
||||
const thread = EventExt.extractThread(a);
|
||||
return isRoot(thread);
|
||||
});
|
||||
if (possibleRoots) {
|
||||
// worst case we need to check every possible root to see which one contains the current note as a child
|
||||
for (const ne of possibleRoots) {
|
||||
const children = chains.get(ne.id) ?? [];
|
||||
|
||||
if (children.find(ne => ne.id === currentId)) {
|
||||
return ne;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [feed.thread.length, currentId, location]);
|
||||
|
||||
const ctxValue = useMemo(() => {
|
||||
const ctxValue = useMemo<ThreadContext>(() => {
|
||||
return {
|
||||
current: currentId,
|
||||
root,
|
||||
chains,
|
||||
data: feed.reactions,
|
||||
reactions: feed.reactions,
|
||||
data: feed.thread,
|
||||
setCurrent: v => setCurrentId(v),
|
||||
} as ThreadContext;
|
||||
};
|
||||
}, [root, chains]);
|
||||
|
||||
return <ThreadContext.Provider value={ctxValue}>{children}</ThreadContext.Provider>;
|
||||
|
@ -1,15 +1,17 @@
|
||||
import { HexKey, RelaySettings, EventPublisher } from "@snort/system";
|
||||
import { RelaySettings, EventPublisher, PinEncrypted, Nip46Signer, Nip7Signer, PrivateKeySigner } from "@snort/system";
|
||||
import { unixNowMs } from "@snort/shared";
|
||||
import * as secp from "@noble/curves/secp256k1";
|
||||
import * as utils from "@noble/curves/abstract/utils";
|
||||
|
||||
import { DefaultRelays, SnortPubKey } from "Const";
|
||||
import { LoginStore, UserPreferences, LoginSession } from "Login";
|
||||
import { LoginStore, UserPreferences, LoginSession, LoginSessionType } from "Login";
|
||||
import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
|
||||
import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unwrap } from "SnortUtils";
|
||||
import { SubscriptionEvent } from "Subscription";
|
||||
import { System } from "index";
|
||||
import { Chats, FollowsFeed, GiftsCache, Notifications } from "Cache";
|
||||
import { PinRequiredError } from "Hooks/useLoginHandler";
|
||||
import { Nip7OsSigner } from "./Nip7OsSigner";
|
||||
|
||||
export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) {
|
||||
if (state.relays.timestamp >= createdAt) {
|
||||
@ -41,8 +43,8 @@ export function updatePreferences(state: LoginSession, p: UserPreferences) {
|
||||
LoginStore.updateSession(state);
|
||||
}
|
||||
|
||||
export function logout(k: HexKey) {
|
||||
LoginStore.removeSession(k);
|
||||
export function logout(id: string) {
|
||||
LoginStore.removeSession(id);
|
||||
GiftsCache.clear();
|
||||
Notifications.clear();
|
||||
FollowsFeed.clear();
|
||||
@ -62,7 +64,7 @@ export function clearEntropy(state: LoginSession) {
|
||||
/**
|
||||
* Generate a new key and login with this generated key
|
||||
*/
|
||||
export async function generateNewLogin() {
|
||||
export async function generateNewLogin(pin: string) {
|
||||
const ent = generateBip39Entropy();
|
||||
const entropy = utils.bytesToHex(ent);
|
||||
const privateKey = entropyToPrivateKey(ent);
|
||||
@ -88,7 +90,8 @@ export async function generateNewLogin() {
|
||||
const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey], newRelays);
|
||||
System.BroadcastEvent(ev);
|
||||
|
||||
LoginStore.loginWithPrivateKey(privateKey, entropy, newRelays);
|
||||
const key = await PinEncrypted.create(privateKey, pin);
|
||||
LoginStore.loginWithPrivateKey(key, entropy, newRelays);
|
||||
}
|
||||
|
||||
export function generateRandomKey() {
|
||||
@ -161,3 +164,34 @@ export function addSubscription(state: LoginSession, ...subs: SubscriptionEvent[
|
||||
LoginStore.updateSession(state);
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionNeedsPin(l: LoginSession) {
|
||||
return l.type === LoginSessionType.PrivateKey || l.type === LoginSessionType.Nip46;
|
||||
}
|
||||
|
||||
export function createPublisher(l: LoginSession, pin?: PinEncrypted) {
|
||||
switch (l.type) {
|
||||
case LoginSessionType.PrivateKey: {
|
||||
if (!pin) throw new PinRequiredError();
|
||||
l.privateKeyData = pin;
|
||||
return EventPublisher.privateKey(pin.value);
|
||||
}
|
||||
case LoginSessionType.Nip46: {
|
||||
if (!pin) throw new PinRequiredError();
|
||||
l.privateKeyData = pin;
|
||||
|
||||
const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`);
|
||||
const inner = new PrivateKeySigner(pin.value);
|
||||
const nip46 = new Nip46Signer(`bunker://${unwrap(l.publicKey)}?${[...relayArgs].join("&")}`, inner);
|
||||
return new EventPublisher(nip46, unwrap(l.publicKey));
|
||||
}
|
||||
case LoginSessionType.Nip7os: {
|
||||
return new EventPublisher(new Nip7OsSigner(), unwrap(l.publicKey));
|
||||
}
|
||||
default: {
|
||||
if (l.publicKey) {
|
||||
return new EventPublisher(new Nip7Signer(), l.publicKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { HexKey, RelaySettings, u256, EventPublisher } from "@snort/system";
|
||||
import { HexKey, RelaySettings, u256, PinEncrypted, PinEncryptedPayload } from "@snort/system";
|
||||
import { UserPreferences } from "Login";
|
||||
import { SubscriptionEvent } from "Subscription";
|
||||
|
||||
@ -19,6 +19,11 @@ export enum LoginSessionType {
|
||||
}
|
||||
|
||||
export interface LoginSession {
|
||||
/**
|
||||
* Unique ID to identify this session
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Type of login session
|
||||
*/
|
||||
@ -26,9 +31,15 @@ export interface LoginSession {
|
||||
|
||||
/**
|
||||
* Current user private key
|
||||
* @deprecated Moving to pin encrypted storage
|
||||
*/
|
||||
privateKey?: HexKey;
|
||||
|
||||
/**
|
||||
* Encrypted private key
|
||||
*/
|
||||
privateKeyData?: PinEncrypted | PinEncryptedPayload;
|
||||
|
||||
/**
|
||||
* BIP39-generated, hex-encoded entropy
|
||||
*/
|
||||
@ -98,9 +109,4 @@ export interface LoginSession {
|
||||
* Remote signer relays (NIP-46)
|
||||
*/
|
||||
remoteSignerRelays?: Array<string>;
|
||||
|
||||
/**
|
||||
* Instance event publisher
|
||||
*/
|
||||
publisher?: EventPublisher;
|
||||
}
|
||||
|
@ -1,16 +1,17 @@
|
||||
import * as secp from "@noble/curves/secp256k1";
|
||||
import * as utils from "@noble/curves/abstract/utils";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { HexKey, RelaySettings, EventPublisher, Nip46Signer, Nip7Signer, PrivateKeySigner } from "@snort/system";
|
||||
import { HexKey, RelaySettings, PinEncrypted, EventPublisher } from "@snort/system";
|
||||
import { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared";
|
||||
|
||||
import { DefaultRelays } from "Const";
|
||||
import { LoginSession, LoginSessionType } from "Login";
|
||||
import { DefaultPreferences, UserPreferences } from "./Preferences";
|
||||
import { Nip7OsSigner } from "./Nip7OsSigner";
|
||||
import { LoginSession, LoginSessionType, createPublisher } from "Login";
|
||||
import { DefaultPreferences } from "./Preferences";
|
||||
|
||||
const AccountStoreKey = "sessions";
|
||||
const LoggedOut = {
|
||||
id: "default",
|
||||
type: "public_key",
|
||||
preferences: DefaultPreferences,
|
||||
tags: {
|
||||
@ -45,25 +46,18 @@ const LoggedOut = {
|
||||
readNotifications: 0,
|
||||
subscriptions: [],
|
||||
} as LoginSession;
|
||||
const LegacyKeys = {
|
||||
PrivateKeyItem: "secret",
|
||||
PublicKeyItem: "pubkey",
|
||||
NotificationsReadItem: "notifications-read",
|
||||
UserPreferencesKey: "preferences",
|
||||
RelayListKey: "last-relays",
|
||||
FollowList: "last-follows",
|
||||
};
|
||||
|
||||
export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
#activeAccount?: HexKey;
|
||||
#accounts: Map<string, LoginSession>;
|
||||
#publishers = new Map<string, EventPublisher>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const existing = window.localStorage.getItem(AccountStoreKey);
|
||||
if (existing) {
|
||||
const logins = JSON.parse(existing);
|
||||
this.#accounts = new Map((logins as Array<LoginSession>).map(a => [unwrap(a.publicKey), a]));
|
||||
this.#accounts = new Map((logins as Array<LoginSession>).map(a => [a.id, a]));
|
||||
} else {
|
||||
this.#accounts = new Map();
|
||||
}
|
||||
@ -71,32 +65,41 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
if (!this.#activeAccount) {
|
||||
this.#activeAccount = this.#accounts.keys().next().value;
|
||||
}
|
||||
for (const [, v] of this.#accounts) {
|
||||
v.publisher = this.#createPublisher(v);
|
||||
}
|
||||
}
|
||||
|
||||
getSessions() {
|
||||
return [...this.#accounts.keys()];
|
||||
return [...this.#accounts.values()].map(v => ({
|
||||
pubkey: unwrap(v.publicKey),
|
||||
id: v.id,
|
||||
}));
|
||||
}
|
||||
|
||||
allSubscriptions() {
|
||||
return [...this.#accounts.values()].map(a => a.subscriptions).flat();
|
||||
}
|
||||
|
||||
switchAccount(pk: string) {
|
||||
if (this.#accounts.has(pk)) {
|
||||
this.#activeAccount = pk;
|
||||
switchAccount(id: string) {
|
||||
if (this.#accounts.has(id)) {
|
||||
this.#activeAccount = id;
|
||||
this.#save();
|
||||
}
|
||||
}
|
||||
|
||||
getPublisher(id: string) {
|
||||
return this.#publishers.get(id);
|
||||
}
|
||||
|
||||
setPublisher(id: string, pub: EventPublisher) {
|
||||
this.#publishers.set(id, pub);
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
loginWithPubkey(
|
||||
key: HexKey,
|
||||
type: LoginSessionType,
|
||||
relays?: Record<string, RelaySettings>,
|
||||
remoteSignerRelays?: Array<string>,
|
||||
privateKey?: string,
|
||||
privateKey?: PinEncrypted,
|
||||
) {
|
||||
if (this.#accounts.has(key)) {
|
||||
throw new Error("Already logged in with this pubkey");
|
||||
@ -104,6 +107,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
const initRelays = this.decideInitRelays(relays);
|
||||
const newSession = {
|
||||
...LoggedOut,
|
||||
id: uuid(),
|
||||
type,
|
||||
publicKey: key,
|
||||
relays: {
|
||||
@ -112,12 +116,15 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
},
|
||||
preferences: deepClone(DefaultPreferences),
|
||||
remoteSignerRelays,
|
||||
privateKey,
|
||||
privateKeyData: privateKey,
|
||||
} as LoginSession;
|
||||
newSession.publisher = this.#createPublisher(newSession);
|
||||
|
||||
this.#accounts.set(key, newSession);
|
||||
this.#activeAccount = key;
|
||||
const pub = createPublisher(newSession);
|
||||
if (pub) {
|
||||
this.setPublisher(newSession.id, pub);
|
||||
}
|
||||
this.#accounts.set(newSession.id, newSession);
|
||||
this.#activeAccount = newSession.id;
|
||||
this.#save();
|
||||
return newSession;
|
||||
}
|
||||
@ -129,16 +136,17 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
return Object.fromEntries(DefaultRelays.entries());
|
||||
}
|
||||
|
||||
loginWithPrivateKey(key: HexKey, entropy?: string, relays?: Record<string, RelaySettings>) {
|
||||
const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(key));
|
||||
loginWithPrivateKey(key: PinEncrypted, entropy?: string, relays?: Record<string, RelaySettings>) {
|
||||
const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(key.value));
|
||||
if (this.#accounts.has(pubKey)) {
|
||||
throw new Error("Already logged in with this pubkey");
|
||||
}
|
||||
const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries());
|
||||
const newSession = {
|
||||
...LoggedOut,
|
||||
id: uuid(),
|
||||
type: LoginSessionType.PrivateKey,
|
||||
privateKey: key,
|
||||
privateKeyData: key,
|
||||
publicKey: pubKey,
|
||||
generatedEntropy: entropy,
|
||||
relays: {
|
||||
@ -149,30 +157,30 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
} as LoginSession;
|
||||
|
||||
if ("nostr_os" in window && window.nostr_os) {
|
||||
window.nostr_os.saveKey(key);
|
||||
window.nostr_os.saveKey(key.value);
|
||||
newSession.type = LoginSessionType.Nip7os;
|
||||
newSession.privateKey = undefined;
|
||||
newSession.privateKeyData = undefined;
|
||||
}
|
||||
newSession.publisher = this.#createPublisher(newSession);
|
||||
const pub = EventPublisher.privateKey(key.value);
|
||||
this.setPublisher(newSession.id, pub);
|
||||
|
||||
this.#accounts.set(pubKey, newSession);
|
||||
this.#activeAccount = pubKey;
|
||||
this.#accounts.set(newSession.id, newSession);
|
||||
this.#activeAccount = newSession.id;
|
||||
this.#save();
|
||||
return newSession;
|
||||
}
|
||||
|
||||
updateSession(s: LoginSession) {
|
||||
const pk = unwrap(s.publicKey);
|
||||
if (this.#accounts.has(pk)) {
|
||||
this.#accounts.set(pk, s);
|
||||
if (this.#accounts.has(s.id)) {
|
||||
this.#accounts.set(s.id, s);
|
||||
console.debug("SET SESSION", s);
|
||||
this.#save();
|
||||
}
|
||||
}
|
||||
|
||||
removeSession(k: string) {
|
||||
if (this.#accounts.delete(k)) {
|
||||
if (this.#activeAccount === k) {
|
||||
removeSession(id: string) {
|
||||
if (this.#accounts.delete(id)) {
|
||||
if (this.#activeAccount === id) {
|
||||
this.#activeAccount = undefined;
|
||||
}
|
||||
this.#save();
|
||||
@ -186,62 +194,8 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
return { ...s };
|
||||
}
|
||||
|
||||
#createPublisher(l: LoginSession) {
|
||||
switch (l.type) {
|
||||
case LoginSessionType.PrivateKey: {
|
||||
return EventPublisher.privateKey(unwrap(l.privateKey));
|
||||
}
|
||||
case LoginSessionType.Nip46: {
|
||||
const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`);
|
||||
const inner = new PrivateKeySigner(unwrap(l.privateKey));
|
||||
const nip46 = new Nip46Signer(`bunker://${unwrap(l.publicKey)}?${[...relayArgs].join("&")}`, inner);
|
||||
return new EventPublisher(nip46, unwrap(l.publicKey));
|
||||
}
|
||||
case LoginSessionType.Nip7os: {
|
||||
return new EventPublisher(new Nip7OsSigner(), unwrap(l.publicKey));
|
||||
}
|
||||
default: {
|
||||
if (l.publicKey) {
|
||||
return new EventPublisher(new Nip7Signer(), l.publicKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#migrate() {
|
||||
let didMigrate = false;
|
||||
const oldPreferences = window.localStorage.getItem(LegacyKeys.UserPreferencesKey);
|
||||
const pref: UserPreferences = oldPreferences ? JSON.parse(oldPreferences) : deepClone(DefaultPreferences);
|
||||
window.localStorage.removeItem(LegacyKeys.UserPreferencesKey);
|
||||
|
||||
const privKey = window.localStorage.getItem(LegacyKeys.PrivateKeyItem);
|
||||
if (privKey) {
|
||||
const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(privKey));
|
||||
this.#accounts.set(pubKey, {
|
||||
...LoggedOut,
|
||||
privateKey: privKey,
|
||||
publicKey: pubKey,
|
||||
preferences: pref,
|
||||
} as LoginSession);
|
||||
window.localStorage.removeItem(LegacyKeys.PrivateKeyItem);
|
||||
window.localStorage.removeItem(LegacyKeys.PublicKeyItem);
|
||||
didMigrate = true;
|
||||
}
|
||||
|
||||
const pubKey = window.localStorage.getItem(LegacyKeys.PublicKeyItem);
|
||||
if (pubKey) {
|
||||
this.#accounts.set(pubKey, {
|
||||
...LoggedOut,
|
||||
publicKey: pubKey,
|
||||
preferences: pref,
|
||||
} as LoginSession);
|
||||
window.localStorage.removeItem(LegacyKeys.PublicKeyItem);
|
||||
didMigrate = true;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(LegacyKeys.RelayListKey);
|
||||
window.localStorage.removeItem(LegacyKeys.FollowList);
|
||||
window.localStorage.removeItem(LegacyKeys.NotificationsReadItem);
|
||||
|
||||
// replace default tab with notes
|
||||
for (const [, v] of this.#accounts) {
|
||||
@ -259,6 +213,14 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
}
|
||||
}
|
||||
|
||||
// add ids
|
||||
for (const [, v] of this.#accounts) {
|
||||
if ((v.id?.length ?? 0) === 0) {
|
||||
v.id = uuid();
|
||||
didMigrate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (didMigrate) {
|
||||
console.debug("Finished migration to MultiAccountStore");
|
||||
this.#save();
|
||||
@ -267,9 +229,21 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
|
||||
#save() {
|
||||
if (!this.#activeAccount && this.#accounts.size > 0) {
|
||||
this.#activeAccount = [...this.#accounts.keys()][0];
|
||||
this.#activeAccount = this.#accounts.keys().next().value;
|
||||
}
|
||||
window.localStorage.setItem(AccountStoreKey, JSON.stringify([...this.#accounts.values()]));
|
||||
const toSave = [];
|
||||
for (const v of this.#accounts.values()) {
|
||||
if (v.privateKeyData instanceof PinEncrypted) {
|
||||
toSave.push({
|
||||
...v,
|
||||
privateKeyData: v.privateKeyData.toPayload(),
|
||||
});
|
||||
} else {
|
||||
toSave.push({ ...v });
|
||||
}
|
||||
}
|
||||
|
||||
window.localStorage.setItem(AccountStoreKey, JSON.stringify(toSave));
|
||||
this.notifyChange();
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { MultiAccountStore } from "./MultiAccountStore";
|
||||
|
||||
export const LoginStore = new MultiAccountStore();
|
||||
|
||||
export interface Nip7os {
|
||||
|
@ -70,7 +70,7 @@ export function SnortDeckLayout() {
|
||||
</div>
|
||||
{deckScope.thread && (
|
||||
<>
|
||||
<Modal onClose={() => deckScope.setThread(undefined)} className="thread-overlay">
|
||||
<Modal id="thread-overlay" onClose={() => deckScope.setThread(undefined)} className="thread-overlay">
|
||||
<ThreadContextWrapper link={deckScope.thread}>
|
||||
<SpotlightFromThread onClose={() => deckScope.setThread(undefined)} />
|
||||
<div>
|
||||
|
@ -3,7 +3,7 @@ import { useParams } from "react-router-dom";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import Timeline from "Element/Timeline";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { setTags } from "Login";
|
||||
import { System } from "index";
|
||||
|
@ -1,6 +1,5 @@
|
||||
import "./Layout.css";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
@ -9,8 +8,6 @@ import { NostrLink, NostrPrefix, tryParseNostrLink } from "@snort/system";
|
||||
import messages from "./messages";
|
||||
|
||||
import Icon from "Icons/Icon";
|
||||
import { RootState } from "State/Store";
|
||||
import { setShow, reset } from "State/NoteCreator";
|
||||
import useLoginFeed from "Feed/LoginFeed";
|
||||
import { NoteCreator } from "Element/NoteCreator";
|
||||
import { mapPlanName } from "./subscribe";
|
||||
@ -23,34 +20,17 @@ import Spinner from "Icons/Spinner";
|
||||
import { fetchNip05Pubkey } from "Nip05/Verifier";
|
||||
import { useTheme } from "Hooks/useTheme";
|
||||
import { useLoginRelays } from "Hooks/useLoginRelays";
|
||||
import { useNoteCreator } from "State/NoteCreator";
|
||||
import { LoginUnlock } from "Element/PinPrompt";
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo);
|
||||
const isNoteCreatorShowing = useSelector((s: RootState) => s.noteCreator.show);
|
||||
const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing;
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { publicKey, subscriptions } = useLogin();
|
||||
const currentSubscription = getCurrentSubscription(subscriptions);
|
||||
const [pageClass, setPageClass] = useState("page");
|
||||
|
||||
useLoginFeed();
|
||||
useTheme();
|
||||
useLoginRelays();
|
||||
|
||||
const handleNoteCreatorButtonClick = () => {
|
||||
if (replyTo) {
|
||||
dispatch(reset());
|
||||
}
|
||||
dispatch(setShow(true));
|
||||
};
|
||||
|
||||
const shouldHideNoteCreator = useMemo(() => {
|
||||
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/e", "/subscribe"];
|
||||
return isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a));
|
||||
}, [location, isReplyNoteCreatorShowing]);
|
||||
|
||||
const shouldHideHeader = useMemo(() => {
|
||||
const hideOn = ["/login", "/new"];
|
||||
return hideOn.some(a => location.pathname.startsWith(a));
|
||||
@ -67,43 +47,51 @@ export default function Layout() {
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={pageClass}>
|
||||
{!shouldHideHeader && (
|
||||
<header className="main-content">
|
||||
<Link to="/" className="logo">
|
||||
<h1>Snort</h1>
|
||||
{currentSubscription && (
|
||||
<small className="flex">
|
||||
<Icon name="diamond" size={10} className="mr5" />
|
||||
{mapPlanName(currentSubscription.type)}
|
||||
</small>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{publicKey ? (
|
||||
<LogoHeader />
|
||||
<AccountHeader />
|
||||
) : (
|
||||
<button type="button" onClick={() => navigate("/login")}>
|
||||
<FormattedMessage {...messages.Login} />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
<Outlet />
|
||||
|
||||
{!shouldHideNoteCreator && (
|
||||
<>
|
||||
<button className="primary note-create-button" onClick={handleNoteCreatorButtonClick}>
|
||||
<Icon name="plus" size={16} />
|
||||
</button>
|
||||
<NoteCreator />
|
||||
</>
|
||||
)}
|
||||
<NoteCreatorButton />
|
||||
<Toaster />
|
||||
</div>
|
||||
<LoginUnlock />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const NoteCreatorButton = () => {
|
||||
const location = useLocation();
|
||||
const { show, replyTo, update } = useNoteCreator(v => ({ show: v.show, replyTo: v.replyTo, update: v.update }));
|
||||
|
||||
const shouldHideNoteCreator = useMemo(() => {
|
||||
const isReplyNoteCreatorShowing = replyTo && show;
|
||||
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/e", "/subscribe"];
|
||||
return isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a));
|
||||
}, [location]);
|
||||
|
||||
if (shouldHideNoteCreator) return;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="primary note-create-button"
|
||||
onClick={() =>
|
||||
update(v => {
|
||||
v.replyTo = undefined;
|
||||
v.show = true;
|
||||
})
|
||||
}>
|
||||
<Icon name="plus" size={16} />
|
||||
</button>
|
||||
<NoteCreator key="global-note-creator" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountHeader = () => {
|
||||
const navigate = useNavigate();
|
||||
const { formatMessage } = useIntl();
|
||||
@ -156,6 +144,13 @@ const AccountHeader = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicKey) {
|
||||
return (
|
||||
<button type="button" onClick={() => navigate("/login")}>
|
||||
<FormattedMessage {...messages.Login} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="header-actions">
|
||||
{!location.pathname.startsWith("/search") && (
|
||||
@ -199,3 +194,20 @@ const AccountHeader = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function LogoHeader() {
|
||||
const { subscriptions } = useLogin();
|
||||
const currentSubscription = getCurrentSubscription(subscriptions);
|
||||
|
||||
return (
|
||||
<Link to="/" className="logo">
|
||||
<h1>Snort</h1>
|
||||
{currentSubscription && (
|
||||
<small className="flex">
|
||||
<Icon name="diamond" size={10} className="mr5" />
|
||||
{mapPlanName(currentSubscription.type)}
|
||||
</small>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
@ -3,22 +3,22 @@ import "./LoginPage.css";
|
||||
import { CSSProperties, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { HexKey, Nip46Signer, PrivateKeySigner } from "@snort/system";
|
||||
import { HexKey, Nip46Signer, PinEncrypted, PrivateKeySigner } from "@snort/system";
|
||||
|
||||
import { bech32ToHex, getPublicKey, unwrap } from "SnortUtils";
|
||||
import ZapButton from "Element/ZapButton";
|
||||
import useImgProxy from "Hooks/useImgProxy";
|
||||
import Icon from "Icons/Icon";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { generateNewLogin, LoginSessionType, LoginStore } from "Login";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import useLoginHandler from "Hooks/useLoginHandler";
|
||||
import useLoginHandler, { PinRequiredError } from "Hooks/useLoginHandler";
|
||||
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||
import { bytesToHex } from "@noble/curves/abstract/utils";
|
||||
import Modal from "Element/Modal";
|
||||
import QrCode from "Element/QrCode";
|
||||
import Copy from "Element/Copy";
|
||||
import { delay } from "SnortUtils";
|
||||
import { PinPrompt } from "Element/PinPrompt";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -75,9 +75,10 @@ export async function getNip05PubKey(addr: string): Promise<string> {
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const login = useLogin();
|
||||
const [key, setKey] = useState("");
|
||||
const [nip46Key, setNip46Key] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [pin, setPin] = useState(false);
|
||||
const [art, setArt] = useState<ArtworkEntry>();
|
||||
const [isMasking, setMasking] = useState(true);
|
||||
const { formatMessage } = useIntl();
|
||||
@ -87,22 +88,19 @@ export default function LoginPage() {
|
||||
const hasSubtleCrypto = window.crypto.subtle !== undefined;
|
||||
const [nostrConnect, setNostrConnect] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (login.publicKey) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [login, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const ret = unwrap(Artwork.at(Artwork.length * Math.random()));
|
||||
const url = proxy(ret.link);
|
||||
setArt({ ...ret, link: url });
|
||||
}, []);
|
||||
|
||||
async function doLogin() {
|
||||
async function doLogin(pin?: string) {
|
||||
try {
|
||||
await loginHandler.doLogin(key);
|
||||
await loginHandler.doLogin(key, pin);
|
||||
} catch (e) {
|
||||
if (e instanceof PinRequiredError) {
|
||||
setPin(true);
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
@ -116,10 +114,16 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function makeRandomKey() {
|
||||
await generateNewLogin();
|
||||
async function makeRandomKey(pin: string) {
|
||||
try {
|
||||
await generateNewLogin(pin);
|
||||
window.plausible?.("Generate Account");
|
||||
navigate("/new");
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function doNip07Login() {
|
||||
@ -127,9 +131,10 @@ export default function LoginPage() {
|
||||
"getRelays" in unwrap(window.nostr) ? await unwrap(window.nostr?.getRelays).call(window.nostr) : undefined;
|
||||
const pubKey = await unwrap(window.nostr).getPublicKey();
|
||||
LoginStore.loginWithPubkey(pubKey, LoginSessionType.Nip7, relays);
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
async function startNip46() {
|
||||
function generateNip46() {
|
||||
const meta = {
|
||||
name: "Snort",
|
||||
url: window.location.href,
|
||||
@ -142,26 +147,51 @@ export default function LoginPage() {
|
||||
`metadata=${encodeURIComponent(JSON.stringify(meta))}`,
|
||||
].join("&")}`;
|
||||
setNostrConnect(connectUrl);
|
||||
setNip46Key(newKey);
|
||||
}
|
||||
|
||||
const signer = new Nip46Signer(connectUrl, new PrivateKeySigner(newKey));
|
||||
async function startNip46(pin: string) {
|
||||
if (!nostrConnect || !nip46Key) return;
|
||||
|
||||
const signer = new Nip46Signer(nostrConnect, new PrivateKeySigner(nip46Key));
|
||||
await signer.init();
|
||||
await delay(500);
|
||||
await signer.describe();
|
||||
LoginStore.loginWithPubkey(
|
||||
await signer.getPubKey(),
|
||||
LoginSessionType.Nip46,
|
||||
undefined,
|
||||
["wss://relay.damus.io"],
|
||||
await PinEncrypted.create(nip46Key, pin),
|
||||
);
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
function nip46Buttons() {
|
||||
return null;
|
||||
return (
|
||||
<>
|
||||
<AsyncButton type="button" onClick={startNip46}>
|
||||
<FormattedMessage defaultMessage="Nostr Connect (NIP-46)" description="Login button for NIP-46 signer app" />
|
||||
<AsyncButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
generateNip46();
|
||||
setPin(true);
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Nostr Connect" description="Login button for NIP-46 signer app" />
|
||||
</AsyncButton>
|
||||
{nostrConnect && (
|
||||
<Modal onClose={() => setNostrConnect("")}>
|
||||
<div className="flex f-col">
|
||||
{nostrConnect && !pin && (
|
||||
<Modal id="nostr-connect" onClose={() => setNostrConnect("")}>
|
||||
<>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Nostr Connect" />
|
||||
</h2>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Scan this QR code with your signer app to get started" />
|
||||
</p>
|
||||
<div className="flex-column f-center g12">
|
||||
<QrCode data={nostrConnect} />
|
||||
<Copy text={nostrConnect} />
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
@ -177,7 +207,7 @@ export default function LoginPage() {
|
||||
<>
|
||||
<AsyncButton type="button" onClick={doNip07Login}>
|
||||
<FormattedMessage
|
||||
defaultMessage="Login with Extension (NIP-07)"
|
||||
defaultMessage="Nostr Extension"
|
||||
description="Login button for NIP7 key manager extension"
|
||||
/>
|
||||
</AsyncButton>
|
||||
@ -258,7 +288,7 @@ export default function LoginPage() {
|
||||
<p dir="auto">
|
||||
<FormattedMessage defaultMessage="Your key" description="Label for key input" />
|
||||
</p>
|
||||
<div className="flex">
|
||||
<div className="flex f-center g8">
|
||||
<input
|
||||
dir="auto"
|
||||
type={isMasking ? "password" : "text"}
|
||||
@ -271,7 +301,7 @@ export default function LoginPage() {
|
||||
<Icon
|
||||
name={isMasking ? "openeye" : "closedeye"}
|
||||
size={30}
|
||||
className="highlight btn-sm pointer"
|
||||
className="highlight pointer"
|
||||
onClick={() => setMasking(!isMasking)}
|
||||
/>
|
||||
</div>
|
||||
@ -283,12 +313,32 @@ export default function LoginPage() {
|
||||
/>
|
||||
</p>
|
||||
<div dir="auto" className="login-actions">
|
||||
<AsyncButton type="button" onClick={doLogin}>
|
||||
<AsyncButton type="button" onClick={() => doLogin()}>
|
||||
<FormattedMessage defaultMessage="Login" description="Login button" />
|
||||
</AsyncButton>
|
||||
<AsyncButton onClick={() => makeRandomKey()}>
|
||||
<AsyncButton onClick={() => setPin(true)}>
|
||||
<FormattedMessage defaultMessage="Create Account" />
|
||||
</AsyncButton>
|
||||
{pin && (
|
||||
<PinPrompt
|
||||
subTitle={
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Enter a pin to encrypt your private key, you must enter this pin every time you open Snort." />
|
||||
</p>
|
||||
}
|
||||
onResult={async pin => {
|
||||
setPin(false);
|
||||
if (key) {
|
||||
await doLogin(pin);
|
||||
} else if (nostrConnect) {
|
||||
await startNip46(pin);
|
||||
} else {
|
||||
await makeRandomKey(pin);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setPin(false)}
|
||||
/>
|
||||
)}
|
||||
{altLogins()}
|
||||
</div>
|
||||
{installExtension()}
|
||||
|
@ -210,7 +210,7 @@ function NewChatWindow() {
|
||||
<Icon name="plus" size={16} />
|
||||
</button>
|
||||
{show && (
|
||||
<Modal onClose={() => setShow(false)} className="new-chat-modal">
|
||||
<Modal id="new-chat" onClose={() => setShow(false)} className="new-chat-modal">
|
||||
<div className="flex-column g16">
|
||||
<div className="flex f-space">
|
||||
<h2>
|
||||
|
@ -447,7 +447,7 @@ export default function ProfilePage() {
|
||||
<Icon name="qr" size={16} />
|
||||
</IconButton>
|
||||
{showProfileQr && (
|
||||
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}>
|
||||
<Modal id="profile-qr" className="qr-modal" onClose={() => setShowProfileQr(false)}>
|
||||
<ProfileImage pubkey={id} />
|
||||
<QrCode data={link} className="m10 align-center" />
|
||||
<Copy text={link} className="align-center" />
|
||||
@ -473,7 +473,7 @@ export default function ProfilePage() {
|
||||
navigate(
|
||||
`/messages/${encodeTLVEntries("chat4" as NostrPrefix, {
|
||||
type: TLVEntryType.Author,
|
||||
length: 64,
|
||||
length: 32,
|
||||
value: id,
|
||||
})}`,
|
||||
)
|
||||
|
@ -5,7 +5,7 @@ import { mapEventToProfile } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
import Logo from "Element/Logo";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { UserCache } from "Cache";
|
||||
import AvatarEditor from "Element/AvatarEditor";
|
||||
|
@ -1,57 +1,32 @@
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import { LoginStore } from "Login";
|
||||
import useLoginHandler from "Hooks/useLoginHandler";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import { getActiveSubscriptions } from "Subscription";
|
||||
|
||||
export default function AccountsPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const loginHandler = useLoginHandler();
|
||||
const logins = LoginStore.getSessions();
|
||||
const sub = getActiveSubscriptions(LoginStore.allSubscriptions());
|
||||
|
||||
async function doLogin() {
|
||||
try {
|
||||
setError("");
|
||||
await loginHandler.doLogin(key);
|
||||
setKey("");
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError(
|
||||
formatMessage({
|
||||
defaultMessage: "Unknown login error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-column g12">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Logins" />
|
||||
</h3>
|
||||
{logins.map(a => (
|
||||
<div className="card flex" key={a}>
|
||||
<div className="card flex" key={a.id}>
|
||||
<ProfilePreview
|
||||
pubkey={a}
|
||||
pubkey={a.pubkey}
|
||||
options={{
|
||||
about: false,
|
||||
}}
|
||||
actions={
|
||||
<div className="f-1">
|
||||
<button className="mb10" onClick={() => LoginStore.switchAccount(a)}>
|
||||
<button className="mb10" onClick={() => LoginStore.switchAccount(a.id)}>
|
||||
<FormattedMessage defaultMessage="Switch" />
|
||||
</button>
|
||||
<button onClick={() => LoginStore.removeSession(a)}>
|
||||
<button onClick={() => LoginStore.removeSession(a.id)}>
|
||||
<FormattedMessage defaultMessage="Logout" />
|
||||
</button>
|
||||
</div>
|
||||
@ -61,27 +36,12 @@ export default function AccountsPage() {
|
||||
))}
|
||||
|
||||
{sub && (
|
||||
<>
|
||||
<h3>
|
||||
<Link to={"/login"}>
|
||||
<button type="button">
|
||||
<FormattedMessage defaultMessage="Add Account" />
|
||||
</h3>
|
||||
<div className="flex">
|
||||
<input
|
||||
dir="auto"
|
||||
type="text"
|
||||
placeholder={formatMessage({
|
||||
defaultMessage: "nsec, npub, nip-05, hex, mnemonic",
|
||||
})}
|
||||
className="f-grow mr10"
|
||||
onChange={e => setKey(e.target.value)}
|
||||
/>
|
||||
<AsyncButton onClick={() => doLogin()}>
|
||||
<FormattedMessage defaultMessage="Login" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
{error && <b className="error">{error}</b>}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import "./Keys.css";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { encodeTLV, NostrPrefix } from "@snort/system";
|
||||
import { encodeTLV, NostrPrefix, PinEncrypted } from "@snort/system";
|
||||
|
||||
import Copy from "Element/Copy";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
@ -8,7 +8,7 @@ import { hexToMnemonic } from "nip6";
|
||||
import { hexToBech32 } from "SnortUtils";
|
||||
|
||||
export default function ExportKeys() {
|
||||
const { publicKey, privateKey, generatedEntropy } = useLogin();
|
||||
const { publicKey, privateKeyData, generatedEntropy } = useLogin();
|
||||
return (
|
||||
<div className="flex-column g12">
|
||||
<h3>
|
||||
@ -16,12 +16,12 @@ export default function ExportKeys() {
|
||||
</h3>
|
||||
<Copy text={hexToBech32("npub", publicKey ?? "")} className="dashed" />
|
||||
<Copy text={encodeTLV(NostrPrefix.Profile, publicKey ?? "")} className="dashed" />
|
||||
{privateKey && (
|
||||
{privateKeyData instanceof PinEncrypted && (
|
||||
<>
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Private Key" />
|
||||
</h3>
|
||||
<Copy text={hexToBech32("nsec", privateKey)} className="dashed" />
|
||||
<Copy text={hexToBech32("nsec", privateKeyData.value)} className="dashed" />
|
||||
</>
|
||||
)}
|
||||
{generatedEntropy && (
|
||||
|
@ -6,7 +6,7 @@ import { mapEventToProfile } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
import { System } from "index";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import { openFile } from "SnortUtils";
|
||||
import useFileUpload from "Upload";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
|
@ -4,7 +4,7 @@ import { unixNowMs } from "@snort/shared";
|
||||
|
||||
import { randomSample } from "SnortUtils";
|
||||
import Relay from "Element/Relay";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import { System } from "index";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { setRelays } from "Login";
|
||||
|
@ -5,7 +5,6 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import Icon from "Icons/Icon";
|
||||
import { LoginStore, logout } from "Login";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { unwrap } from "SnortUtils";
|
||||
import { getCurrentSubscription } from "Subscription";
|
||||
|
||||
import messages from "./messages";
|
||||
@ -19,7 +18,7 @@ const SettingsIndex = () => {
|
||||
const sub = getCurrentSubscription(LoginStore.allSubscriptions());
|
||||
|
||||
function handleLogout() {
|
||||
logout(unwrap(login.publicKey));
|
||||
logout(login.id);
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,7 @@ import { LNURL } from "@snort/shared";
|
||||
|
||||
import { ApiHost } from "Const";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
|
||||
|
||||
export default function LNForwardAddress({ handle }: { handle: ManageHandle }) {
|
||||
|
@ -3,7 +3,7 @@ import { FormattedMessage } from "react-intl";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiHost } from "Const";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
|
||||
|
||||
export default function ListHandles() {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ApiHost } from "Const";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import { ServiceError } from "Nip05/ServiceProvider";
|
||||
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
|
||||
import { useState } from "react";
|
||||
|
@ -3,7 +3,7 @@ import { FormattedMessage } from "react-intl";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import PageSpinner from "Element/PageSpinner";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import SnortApi, { Subscription, SubscriptionError } from "SnortApi";
|
||||
import { mapSubscriptionErrorCode } from ".";
|
||||
import SubscriptionCard from "./SubscriptionCard";
|
||||
|
@ -5,7 +5,7 @@ import SnortApi, { Subscription, SubscriptionError } from "SnortApi";
|
||||
import { mapPlanName, mapSubscriptionErrorCode } from ".";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import Icon from "Icons/Icon";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import SendSats from "Element/SendSats";
|
||||
import Nip5Service from "Element/Nip5Service";
|
||||
import { SnortNostrAddressService } from "Pages/NostrAddressPage";
|
||||
|
@ -8,7 +8,7 @@ import { formatShort } from "Number";
|
||||
import { LockedFeatures, Plans, SubscriptionType } from "Subscription";
|
||||
import ManageSubscriptionPage from "Pages/subscribe/ManageSubscription";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
import SnortApi, { SubscriptionError, SubscriptionErrorCode } from "SnortApi";
|
||||
import SendSats from "Element/SendSats";
|
||||
|
||||
|
@ -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;
|
94
packages/app/src/State/NoteCreator.tsx
Normal file
94
packages/app/src/State/NoteCreator.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
@ -8,6 +8,7 @@ import {
|
||||
TLVEntryType,
|
||||
encodeTLVEntries,
|
||||
TaggedNostrEvent,
|
||||
decodeTLV,
|
||||
} from "@snort/system";
|
||||
import { Chat, ChatSystem, ChatType, inChatWith, lastReadInChat } from "chat";
|
||||
import { debug } from "debug";
|
||||
@ -59,26 +60,32 @@ export class Nip4ChatSystem extends ExternalStore<Array<Chat>> implements ChatSy
|
||||
{} as Record<string, Array<NostrEvent>>,
|
||||
);
|
||||
|
||||
return [...Object.entries(chats)].map(([k, v]) => Nip4ChatSystem.createChatObj(k, v));
|
||||
return [...Object.entries(chats)].map(([k, v]) =>
|
||||
Nip4ChatSystem.createChatObj(
|
||||
encodeTLVEntries("chat4" as NostrPrefix, {
|
||||
type: TLVEntryType.Author,
|
||||
value: k,
|
||||
length: 32,
|
||||
}),
|
||||
v,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static createChatObj(id: string, messages: Array<NostrEvent>) {
|
||||
const last = lastReadInChat(id);
|
||||
const participants = decodeTLV(id)
|
||||
.filter(v => v.type === TLVEntryType.Author)
|
||||
.map(v => ({
|
||||
type: "pubkey",
|
||||
id: v.value as string,
|
||||
}));
|
||||
return {
|
||||
type: ChatType.DirectMessage,
|
||||
id: encodeTLVEntries("chat4" as NostrPrefix, {
|
||||
type: TLVEntryType.Author,
|
||||
value: id,
|
||||
length: 0,
|
||||
}),
|
||||
id,
|
||||
unread: messages.reduce((acc, v) => (v.created_at > last ? acc++ : acc), 0),
|
||||
lastMessage: messages.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0),
|
||||
participants: [
|
||||
{
|
||||
type: "pubkey",
|
||||
id: id,
|
||||
},
|
||||
],
|
||||
participants,
|
||||
messages: messages.map(m => ({
|
||||
id: m.id,
|
||||
created_at: m.created_at,
|
||||
@ -91,7 +98,7 @@ export class Nip4ChatSystem extends ExternalStore<Array<Chat>> implements ChatSy
|
||||
},
|
||||
})),
|
||||
createMessage: async (msg, pub) => {
|
||||
return [await pub.sendDm(msg, id)];
|
||||
return await Promise.all(participants.map(v => pub.sendDm(msg, v.id)));
|
||||
},
|
||||
sendMessage: (ev, system: SystemInterface) => {
|
||||
ev.forEach(a => system.BroadcastEvent(a));
|
||||
|
@ -46,6 +46,7 @@
|
||||
--header-padding-tb: 10px;
|
||||
--btn-color: #fff;
|
||||
--primary-gradient: linear-gradient(90deg, rgba(239, 150, 68, 1) 0%, rgba(123, 65, 246, 1) 100%);
|
||||
--cashu-gradient: linear-gradient(90deg, #40b039, #adff2a);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@ -132,10 +133,26 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background: var(--primary-gradient);
|
||||
}
|
||||
|
||||
.br {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.p {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.p24 {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
@ -522,7 +539,7 @@ div.form-col {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
small.xs {
|
||||
.xs {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
|
@ -7,24 +7,13 @@ import WasmPath from "@snort/system-query/pkg/system_query_bg.wasm";
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import * as ReactDOM from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import {
|
||||
EventPublisher,
|
||||
NostrSystem,
|
||||
ProfileLoaderService,
|
||||
Nip7Signer,
|
||||
PowWorker,
|
||||
QueryOptimizer,
|
||||
FlatReqFilter,
|
||||
ReqFilter,
|
||||
} from "@snort/system";
|
||||
import { NostrSystem, ProfileLoaderService, PowWorker, QueryOptimizer, FlatReqFilter, ReqFilter } from "@snort/system";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
|
||||
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
|
||||
import { IntlProvider } from "IntlProvider";
|
||||
import { unwrap } from "SnortUtils";
|
||||
import Store from "State/Store";
|
||||
import Layout from "Pages/Layout";
|
||||
import LoginPage from "Pages/LoginPage";
|
||||
import ProfilePage from "Pages/ProfilePage";
|
||||
@ -73,13 +62,9 @@ export const System = new NostrSystem({
|
||||
relayMetrics: RelayMetrics,
|
||||
queryOptimizer: WasmQueryOptimizer,
|
||||
authHandler: async (c, r) => {
|
||||
const { publicKey, privateKey } = LoginStore.snapshot();
|
||||
if (privateKey) {
|
||||
const pub = EventPublisher.privateKey(privateKey);
|
||||
return await pub.nip42Auth(c, r);
|
||||
}
|
||||
if (publicKey) {
|
||||
const pub = new EventPublisher(new Nip7Signer(), publicKey);
|
||||
const { id } = LoginStore.snapshot();
|
||||
const pub = LoginStore.getPublisher(id);
|
||||
if (pub) {
|
||||
return await pub.nip42Auth(c, r);
|
||||
}
|
||||
},
|
||||
@ -218,12 +203,10 @@ export const router = createBrowserRouter([
|
||||
const root = ReactDOM.createRoot(unwrap(document.getElementById("root")));
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<Provider store={Store}>
|
||||
<IntlProvider>
|
||||
<SnortContext.Provider value={System}>
|
||||
<RouterProvider router={router} />
|
||||
</SnortContext.Provider>
|
||||
</IntlProvider>
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
@ -142,6 +142,9 @@
|
||||
"3yk8fB": {
|
||||
"defaultMessage": "Wallet"
|
||||
},
|
||||
"40VR6s": {
|
||||
"defaultMessage": "Nostr Connect"
|
||||
},
|
||||
"450Fty": {
|
||||
"defaultMessage": "None"
|
||||
},
|
||||
@ -190,6 +193,12 @@
|
||||
"5ykRmX": {
|
||||
"defaultMessage": "Send zap"
|
||||
},
|
||||
"6/SF6e": {
|
||||
"defaultMessage": "<h1>{n}</h1> Cashu sats"
|
||||
},
|
||||
"6/hB3S": {
|
||||
"defaultMessage": "Watch Replay"
|
||||
},
|
||||
"65BmHb": {
|
||||
"defaultMessage": "Failed to proxy image from {host}, click here to load directly"
|
||||
},
|
||||
@ -223,6 +232,9 @@
|
||||
"89q5wc": {
|
||||
"defaultMessage": "Confirm Reposts"
|
||||
},
|
||||
"8Kboo2": {
|
||||
"defaultMessage": "Scan this QR code with your signer app to get started"
|
||||
},
|
||||
"8QDesP": {
|
||||
"defaultMessage": "Zap {n} sats"
|
||||
},
|
||||
@ -238,6 +250,10 @@
|
||||
"8v1NN+": {
|
||||
"defaultMessage": "Pairing phrase"
|
||||
},
|
||||
"8xNnhi": {
|
||||
"defaultMessage": "Nostr Extension",
|
||||
"description": "Login button for NIP7 key manager extension"
|
||||
},
|
||||
"9+Ddtu": {
|
||||
"defaultMessage": "Next"
|
||||
},
|
||||
@ -352,6 +368,9 @@
|
||||
"Dh3hbq": {
|
||||
"defaultMessage": "Auto Zap"
|
||||
},
|
||||
"Dn82AL": {
|
||||
"defaultMessage": "Live"
|
||||
},
|
||||
"DtYelJ": {
|
||||
"defaultMessage": "Transfer"
|
||||
},
|
||||
@ -424,6 +443,9 @@
|
||||
"GL8aXW": {
|
||||
"defaultMessage": "Bookmarks ({n})"
|
||||
},
|
||||
"GQPtfk": {
|
||||
"defaultMessage": "Join Stream"
|
||||
},
|
||||
"GSye7T": {
|
||||
"defaultMessage": "Lightning Address"
|
||||
},
|
||||
@ -449,9 +471,6 @@
|
||||
"HAlOn1": {
|
||||
"defaultMessage": "Name"
|
||||
},
|
||||
"HF4YnO": {
|
||||
"defaultMessage": "Watch Live!"
|
||||
},
|
||||
"HFls6j": {
|
||||
"defaultMessage": "name will be available later"
|
||||
},
|
||||
@ -539,6 +558,9 @@
|
||||
"KoFlZg": {
|
||||
"defaultMessage": "Enter mint URL"
|
||||
},
|
||||
"KtsyO0": {
|
||||
"defaultMessage": "Enter Pin"
|
||||
},
|
||||
"LF5kYT": {
|
||||
"defaultMessage": "Other Connections"
|
||||
},
|
||||
@ -557,6 +579,10 @@
|
||||
"LwYmVi": {
|
||||
"defaultMessage": "Zaps on this note will be split to the following users."
|
||||
},
|
||||
"M10zFV": {
|
||||
"defaultMessage": "Nostr Connect",
|
||||
"description": "Login button for NIP-46 signer app"
|
||||
},
|
||||
"M3Oirc": {
|
||||
"defaultMessage": "Debug Menus"
|
||||
},
|
||||
@ -708,10 +734,6 @@
|
||||
"SP0+yi": {
|
||||
"defaultMessage": "Buy Subscription"
|
||||
},
|
||||
"SX58hM": {
|
||||
"defaultMessage": "Copy",
|
||||
"description": "Button: Copy Cashu token"
|
||||
},
|
||||
"SYQtZ7": {
|
||||
"defaultMessage": "LN Address Proxy"
|
||||
},
|
||||
@ -730,8 +752,8 @@
|
||||
"TDR5ge": {
|
||||
"defaultMessage": "Media in notes will automatically be shown for selected people, otherwise only the link will show"
|
||||
},
|
||||
"TMfYfY": {
|
||||
"defaultMessage": "Cashu token"
|
||||
"TP/cMX": {
|
||||
"defaultMessage": "Ended"
|
||||
},
|
||||
"TpgeGw": {
|
||||
"defaultMessage": "Hex Salt..",
|
||||
@ -743,9 +765,6 @@
|
||||
"UDYlxu": {
|
||||
"defaultMessage": "Pending Subscriptions"
|
||||
},
|
||||
"ULotH9": {
|
||||
"defaultMessage": "Amount: {amount} sats"
|
||||
},
|
||||
"UT7Nkj": {
|
||||
"defaultMessage": "New Chat"
|
||||
},
|
||||
@ -773,10 +792,6 @@
|
||||
"VnXp8Z": {
|
||||
"defaultMessage": "Avatar"
|
||||
},
|
||||
"VtPV/B": {
|
||||
"defaultMessage": "Login with Extension (NIP-07)",
|
||||
"description": "Login button for NIP7 key manager extension"
|
||||
},
|
||||
"VvaJst": {
|
||||
"defaultMessage": "View Wallets"
|
||||
},
|
||||
@ -887,6 +902,9 @@
|
||||
"defaultMessage": "Install Extension",
|
||||
"description": "Heading for install key manager extension"
|
||||
},
|
||||
"c2DTVd": {
|
||||
"defaultMessage": "Enter a pin to encrypt your private key, you must enter this pin every time you open Snort."
|
||||
},
|
||||
"c35bj2": {
|
||||
"defaultMessage": "If you have an enquiry about your NIP-05 order please DM {link}"
|
||||
},
|
||||
@ -931,6 +949,9 @@
|
||||
"e61Jf3": {
|
||||
"defaultMessage": "Coming soon"
|
||||
},
|
||||
"e7VmYP": {
|
||||
"defaultMessage": "Enter pin to unlock your private key"
|
||||
},
|
||||
"e7qqly": {
|
||||
"defaultMessage": "Mark All Read"
|
||||
},
|
||||
@ -1000,10 +1021,6 @@
|
||||
"hMzcSq": {
|
||||
"defaultMessage": "Messages"
|
||||
},
|
||||
"hWSp+B": {
|
||||
"defaultMessage": "Nostr Connect (NIP-46)",
|
||||
"description": "Login button for NIP-46 signer app"
|
||||
},
|
||||
"hY4lzx": {
|
||||
"defaultMessage": "Supports"
|
||||
},
|
||||
@ -1034,9 +1051,6 @@
|
||||
"iNWbVV": {
|
||||
"defaultMessage": "Handle"
|
||||
},
|
||||
"iUsU2x": {
|
||||
"defaultMessage": "Mint: {url}"
|
||||
},
|
||||
"iXPL0Z": {
|
||||
"defaultMessage": "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead"
|
||||
},
|
||||
@ -1230,6 +1244,9 @@
|
||||
"qtWLmt": {
|
||||
"defaultMessage": "Like"
|
||||
},
|
||||
"qz9fty": {
|
||||
"defaultMessage": "Incorrect pin"
|
||||
},
|
||||
"r3C4x/": {
|
||||
"defaultMessage": "Software"
|
||||
},
|
||||
@ -1410,5 +1427,8 @@
|
||||
},
|
||||
"zvCDao": {
|
||||
"defaultMessage": "Automatically show latest notes"
|
||||
},
|
||||
"zwb6LR": {
|
||||
"defaultMessage": "<b>Mint:</b> {url}"
|
||||
}
|
||||
}
|
||||
|
@ -46,6 +46,7 @@
|
||||
"3tVy+Z": "{n} Followers",
|
||||
"3xCwbZ": "OR",
|
||||
"3yk8fB": "Wallet",
|
||||
"40VR6s": "Nostr Connect",
|
||||
"450Fty": "None",
|
||||
"47FYwb": "Cancel",
|
||||
"4IPzdn": "Primary Developers",
|
||||
@ -62,6 +63,8 @@
|
||||
"5u6iEc": "Transfer to Pubkey",
|
||||
"5vMmmR": "Usernames are not unique on Nostr. The nostr address is your unique human-readable address that is unique to you upon registration.",
|
||||
"5ykRmX": "Send zap",
|
||||
"6/SF6e": "<h1>{n}</h1> Cashu sats",
|
||||
"6/hB3S": "Watch Replay",
|
||||
"65BmHb": "Failed to proxy image from {host}, click here to load directly",
|
||||
"6OSOXl": "Reason: <i>{reason}</i>",
|
||||
"6Yfvvp": "Get an identifier",
|
||||
@ -73,11 +76,13 @@
|
||||
"7hp70g": "NIP-05",
|
||||
"8/vBbP": "Reposts ({n})",
|
||||
"89q5wc": "Confirm Reposts",
|
||||
"8Kboo2": "Scan this QR code with your signer app to get started",
|
||||
"8QDesP": "Zap {n} sats",
|
||||
"8Rkoyb": "Recipient",
|
||||
"8Y6bZQ": "Invalid zap split: {input}",
|
||||
"8g2vyB": "name too long",
|
||||
"8v1NN+": "Pairing phrase",
|
||||
"8xNnhi": "Nostr Extension",
|
||||
"9+Ddtu": "Next",
|
||||
"9HU8vw": "Reply",
|
||||
"9SvQep": "Follows {n}",
|
||||
@ -115,6 +120,7 @@
|
||||
"DZzCem": "Show latest {n} notes",
|
||||
"DcL8P+": "Supporter",
|
||||
"Dh3hbq": "Auto Zap",
|
||||
"Dn82AL": "Live",
|
||||
"DtYelJ": "Transfer",
|
||||
"E8a4yq": "Follow some popular accounts",
|
||||
"ELbg9p": "Data Providers",
|
||||
@ -139,6 +145,7 @@
|
||||
"G1BGCg": "Select Wallet",
|
||||
"GFOoEE": "Salt",
|
||||
"GL8aXW": "Bookmarks ({n})",
|
||||
"GQPtfk": "Join Stream",
|
||||
"GSye7T": "Lightning Address",
|
||||
"GUlSVG": "Claim your included Snort nostr address",
|
||||
"Gcn9NQ": "Magnet Link",
|
||||
@ -147,7 +154,6 @@
|
||||
"H0JBH6": "Log Out",
|
||||
"H6/kLh": "Order Paid!",
|
||||
"HAlOn1": "Name",
|
||||
"HF4YnO": "Watch Live!",
|
||||
"HFls6j": "name will be available later",
|
||||
"HOzFdo": "Muted",
|
||||
"HWbkEK": "Clear cache and reload",
|
||||
@ -177,12 +183,14 @@
|
||||
"KWuDfz": "I have saved my keys, continue",
|
||||
"KahimY": "Unknown event kind: {kind}",
|
||||
"KoFlZg": "Enter mint URL",
|
||||
"KtsyO0": "Enter Pin",
|
||||
"LF5kYT": "Other Connections",
|
||||
"LXxsbk": "Anonymous",
|
||||
"LgbKvU": "Comment",
|
||||
"Lu5/Bj": "Open on Zapstr",
|
||||
"Lw+I+J": "{n,plural,=0{{name} zapped} other{{name} & {n} others zapped}}",
|
||||
"LwYmVi": "Zaps on this note will be split to the following users.",
|
||||
"M10zFV": "Nostr Connect",
|
||||
"M3Oirc": "Debug Menus",
|
||||
"MBAYRO": "Shows \"Copy ID\" and \"Copy Event JSON\" in the context menu on each message",
|
||||
"MI2jkA": "Not available:",
|
||||
@ -232,18 +240,16 @@
|
||||
"SMO+on": "Send zap to {name}",
|
||||
"SOqbe9": "Update Lightning Address",
|
||||
"SP0+yi": "Buy Subscription",
|
||||
"SX58hM": "Copy",
|
||||
"SYQtZ7": "LN Address Proxy",
|
||||
"ShdEie": "Mark all read",
|
||||
"Sjo1P4": "Custom",
|
||||
"Ss0sWu": "Pay Now",
|
||||
"StKzTE": "The author has marked this note as a <i>sensitive topic</i>",
|
||||
"TDR5ge": "Media in notes will automatically be shown for selected people, otherwise only the link will show",
|
||||
"TMfYfY": "Cashu token",
|
||||
"TP/cMX": "Ended",
|
||||
"TpgeGw": "Hex Salt..",
|
||||
"Tpy00S": "People",
|
||||
"UDYlxu": "Pending Subscriptions",
|
||||
"ULotH9": "Amount: {amount} sats",
|
||||
"UT7Nkj": "New Chat",
|
||||
"UUPFlt": "Users must accept the content warning to show the content of your note.",
|
||||
"Up5U7K": "Block",
|
||||
@ -253,7 +259,6 @@
|
||||
"VR5eHw": "Public key (npub/nprofile)",
|
||||
"VlJkSk": "{n} muted",
|
||||
"VnXp8Z": "Avatar",
|
||||
"VtPV/B": "Login with Extension (NIP-07)",
|
||||
"VvaJst": "View Wallets",
|
||||
"Vx7Zm2": "How do keys work?",
|
||||
"W1yoZY": "It looks like you dont have any subscriptions, you can get one {link}",
|
||||
@ -290,6 +295,7 @@
|
||||
"brAXSu": "Pick a username",
|
||||
"bxv59V": "Just now",
|
||||
"c+oiJe": "Install Extension",
|
||||
"c2DTVd": "Enter a pin to encrypt your private key, you must enter this pin every time you open Snort.",
|
||||
"c35bj2": "If you have an enquiry about your NIP-05 order please DM {link}",
|
||||
"c3g2hL": "Broadcast Again",
|
||||
"cFbU1B": "Using Alby? Go to {link} to get your NWC config!",
|
||||
@ -304,6 +310,7 @@
|
||||
"d7d0/x": "LN Address",
|
||||
"dOQCL8": "Display name",
|
||||
"e61Jf3": "Coming soon",
|
||||
"e7VmYP": "Enter pin to unlock your private key",
|
||||
"e7qqly": "Mark All Read",
|
||||
"eHAneD": "Reaction emoji",
|
||||
"eJj8HD": "Get Verified",
|
||||
@ -327,7 +334,6 @@
|
||||
"h8XMJL": "Badges",
|
||||
"hK5ZDk": "the world",
|
||||
"hMzcSq": "Messages",
|
||||
"hWSp+B": "Nostr Connect (NIP-46)",
|
||||
"hY4lzx": "Supports",
|
||||
"hicxcO": "Show replies",
|
||||
"hmZ3Bz": "Media",
|
||||
@ -338,7 +344,6 @@
|
||||
"iEoXYx": "DeepL translations",
|
||||
"iGT1eE": "Prevent fake accounts from imitating you",
|
||||
"iNWbVV": "Handle",
|
||||
"iUsU2x": "Mint: {url}",
|
||||
"iXPL0Z": "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead",
|
||||
"ieGrWo": "Follow",
|
||||
"itPgxd": "Profile",
|
||||
@ -402,6 +407,7 @@
|
||||
"qkvYUb": "Add to Profile",
|
||||
"qmJ8kD": "Translation failed",
|
||||
"qtWLmt": "Like",
|
||||
"qz9fty": "Incorrect pin",
|
||||
"r3C4x/": "Software",
|
||||
"r5srDR": "Enter wallet password",
|
||||
"rT14Ow": "Add Relays",
|
||||
@ -460,5 +466,6 @@
|
||||
"zcaOTs": "Zap amount in sats",
|
||||
"zjJZBd": "You're ready!",
|
||||
"zonsdq": "Failed to load LNURL service",
|
||||
"zvCDao": "Automatically show latest notes"
|
||||
"zvCDao": "Automatically show latest notes",
|
||||
"zwb6LR": "<b>Mint:</b> {url}"
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ export interface HookFilter<TSnapshot> {
|
||||
*/
|
||||
export abstract class ExternalStore<TSnapshot> {
|
||||
#hooks: Array<HookFilter<TSnapshot>> = [];
|
||||
#snapshot: Readonly<TSnapshot> = {} as Readonly<TSnapshot>;
|
||||
#snapshot: TSnapshot = {} as TSnapshot;
|
||||
#changed = true;
|
||||
|
||||
hook(fn: HookFn<TSnapshot>) {
|
||||
|
@ -24,6 +24,8 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -39,6 +41,8 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
|
Binary file not shown.
@ -28,6 +28,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -42,6 +44,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -63,6 +67,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -78,6 +84,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -92,6 +100,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -111,6 +121,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -130,6 +142,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -144,6 +158,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -162,6 +178,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
|
@ -29,6 +29,10 @@ pub struct ReqFilter {
|
||||
pub d_tag: Option<HashSet<String>>,
|
||||
#[serde(rename = "#r", skip_serializing_if = "Option::is_none")]
|
||||
pub r_tag: Option<HashSet<String>>,
|
||||
#[serde(rename = "#a", skip_serializing_if = "Option::is_none")]
|
||||
pub a_tag: Option<HashSet<String>>,
|
||||
#[serde(rename = "#g", skip_serializing_if = "Option::is_none")]
|
||||
pub g_tag: Option<HashSet<String>>,
|
||||
#[serde(rename = "search", skip_serializing_if = "Option::is_none")]
|
||||
pub search: Option<HashSet<String>>,
|
||||
#[serde(rename = "since", skip_serializing_if = "Option::is_none")]
|
||||
@ -64,6 +68,10 @@ pub struct FlatReqFilter {
|
||||
pub d_tag: Option<String>,
|
||||
#[serde(rename = "#r", skip_serializing_if = "Option::is_none")]
|
||||
pub r_tag: Option<String>,
|
||||
#[serde(rename = "#a", skip_serializing_if = "Option::is_none")]
|
||||
pub a_tag: Option<String>,
|
||||
#[serde(rename = "#g", skip_serializing_if = "Option::is_none")]
|
||||
pub g_tag: Option<String>,
|
||||
#[serde(rename = "search", skip_serializing_if = "Option::is_none")]
|
||||
pub search: Option<String>,
|
||||
#[serde(rename = "since", skip_serializing_if = "Option::is_none")]
|
||||
@ -145,6 +153,8 @@ impl From<Vec<&FlatReqFilter>> for ReqFilter {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -159,6 +169,8 @@ impl From<Vec<&FlatReqFilter>> for ReqFilter {
|
||||
array_prop_append(&x.t_tag, &mut acc.t_tag);
|
||||
array_prop_append(&x.d_tag, &mut acc.d_tag);
|
||||
array_prop_append(&x.r_tag, &mut acc.r_tag);
|
||||
array_prop_append(&x.a_tag, &mut acc.a_tag);
|
||||
array_prop_append(&x.g_tag, &mut acc.g_tag);
|
||||
array_prop_append(&x.search, &mut acc.search);
|
||||
acc.since = x.since;
|
||||
acc.until = x.until;
|
||||
@ -180,6 +192,8 @@ impl From<Vec<&ReqFilter>> for ReqFilter {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -194,6 +208,8 @@ impl From<Vec<&ReqFilter>> for ReqFilter {
|
||||
array_prop_append_vec(&x.t_tag, &mut acc.t_tag);
|
||||
array_prop_append_vec(&x.d_tag, &mut acc.d_tag);
|
||||
array_prop_append_vec(&x.r_tag, &mut acc.r_tag);
|
||||
array_prop_append_vec(&x.a_tag, &mut acc.a_tag);
|
||||
array_prop_append_vec(&x.g_tag, &mut acc.g_tag);
|
||||
array_prop_append_vec(&x.search, &mut acc.search);
|
||||
acc.since = x.since;
|
||||
acc.until = x.until;
|
||||
@ -265,6 +281,20 @@ impl Into<Vec<FlatReqFilter>> for &ReqFilter {
|
||||
.collect();
|
||||
inputs.push(t_ids);
|
||||
}
|
||||
if let Some(a_tags) = &self.a_tag {
|
||||
let t_ids = a_tags
|
||||
.iter()
|
||||
.map(|z| StringOrNumberEntry::String(("a_tag", z)))
|
||||
.collect();
|
||||
inputs.push(t_ids);
|
||||
}
|
||||
if let Some(g_tags) = &self.g_tag {
|
||||
let t_ids = g_tags
|
||||
.iter()
|
||||
.map(|z| StringOrNumberEntry::String(("g_tag", z)))
|
||||
.collect();
|
||||
inputs.push(t_ids);
|
||||
}
|
||||
if let Some(search) = &self.search {
|
||||
let t_ids = search
|
||||
.iter()
|
||||
@ -339,6 +369,22 @@ impl Into<Vec<FlatReqFilter>> for &ReqFilter {
|
||||
}
|
||||
None
|
||||
}),
|
||||
a_tag: p.iter().find_map(|q| {
|
||||
if let StringOrNumberEntry::String((k, v)) = q {
|
||||
if (*k).eq("a_tag") {
|
||||
return Some((*v).to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}),
|
||||
g_tag: p.iter().find_map(|q| {
|
||||
if let StringOrNumberEntry::String((k, v)) = q {
|
||||
if (*k).eq("g_tag") {
|
||||
return Some((*v).to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}),
|
||||
search: p.iter().find_map(|q| {
|
||||
if let StringOrNumberEntry::String((k, v)) = q {
|
||||
if (*k).eq("search") {
|
||||
@ -355,6 +401,7 @@ impl Into<Vec<FlatReqFilter>> for &ReqFilter {
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
impl Distance for ReqFilter {
|
||||
fn distance(&self, b: &Self) -> u32 {
|
||||
let mut ret = 0u32;
|
||||
@ -367,6 +414,7 @@ impl Distance for ReqFilter {
|
||||
ret += prop_dist_vec(&self.d_tag, &b.d_tag);
|
||||
ret += prop_dist_vec(&self.r_tag, &b.r_tag);
|
||||
ret += prop_dist_vec(&self.t_tag, &b.t_tag);
|
||||
ret += prop_dist_vec(&self.a_tag, &b.a_tag);
|
||||
ret += prop_dist_vec(&self.search, &b.search);
|
||||
|
||||
ret
|
||||
@ -464,6 +512,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -481,6 +531,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -495,6 +547,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -509,6 +563,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -523,6 +579,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -537,6 +595,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -551,6 +611,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -565,6 +627,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -579,6 +643,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -593,6 +659,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -607,6 +675,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -621,6 +691,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -635,6 +707,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -649,6 +723,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -663,6 +739,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -677,6 +755,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -691,6 +771,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -705,6 +787,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
@ -719,6 +803,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: Some(99),
|
||||
until: None,
|
||||
|
@ -74,6 +74,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
authors: Some(HashSet::from([
|
||||
"kieran".to_string(),
|
||||
"snort".to_string(),
|
||||
@ -94,6 +96,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -109,6 +113,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -122,6 +128,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
|
@ -66,6 +66,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -80,6 +82,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -94,6 +98,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -108,6 +114,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -122,6 +130,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -144,6 +154,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -158,6 +170,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -173,6 +187,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -192,6 +208,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -206,6 +224,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -220,6 +240,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -241,6 +263,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -255,6 +279,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -269,6 +295,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -283,6 +311,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -297,6 +327,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -311,6 +343,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -325,6 +359,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -339,6 +375,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -353,6 +391,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -373,6 +413,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -387,6 +429,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -401,6 +445,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -415,6 +461,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
@ -429,6 +477,8 @@ mod tests {
|
||||
t_tag: None,
|
||||
d_tag: None,
|
||||
r_tag: None,
|
||||
a_tag: None,
|
||||
g_tag: None,
|
||||
search: None,
|
||||
since: None,
|
||||
until: None,
|
||||
|
69
packages/system/src/encrypted.ts
Normal file
69
packages/system/src/encrypted.ts
Normal 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;
|
||||
}
|
@ -9,6 +9,7 @@ import {
|
||||
HexKey,
|
||||
Lists,
|
||||
NostrEvent,
|
||||
NostrLink,
|
||||
NotSignedNostrEvent,
|
||||
PowMiner,
|
||||
PrivateKeySigner,
|
||||
@ -185,10 +186,11 @@ export class EventPublisher {
|
||||
|
||||
const thread = EventExt.extractThread(replyTo);
|
||||
if (thread) {
|
||||
if (thread.root || thread.replyTo) {
|
||||
eb.tag(["e", thread.root?.value ?? thread.replyTo?.value ?? "", "", "root"]);
|
||||
const rootOrReplyAsRoot = thread.root || thread.replyTo;
|
||||
if (rootOrReplyAsRoot) {
|
||||
eb.tag([rootOrReplyAsRoot.key, rootOrReplyAsRoot.value ?? "", rootOrReplyAsRoot.relay ?? "", "root"]);
|
||||
}
|
||||
eb.tag(["e", replyTo.id, replyTo.relays?.[0] ?? "", "reply"]);
|
||||
eb.tag([...(NostrLink.fromEvent(replyTo).toEventTag() ?? []), "reply"]);
|
||||
|
||||
eb.tag(["p", replyTo.pubkey]);
|
||||
for (const pk of thread.pubKeys) {
|
||||
@ -198,7 +200,7 @@ export class EventPublisher {
|
||||
eb.tag(["p", pk]);
|
||||
}
|
||||
} else {
|
||||
eb.tag(["e", replyTo.id, "", "reply"]);
|
||||
eb.tag([...(NostrLink.fromEvent(replyTo).toEventTag() ?? []), "reply"]);
|
||||
// dont tag self in replies
|
||||
if (replyTo.pubkey !== this.#pubKey) {
|
||||
eb.tag(["p", replyTo.pubkey]);
|
||||
|
@ -84,7 +84,7 @@ export function splitByWriteRelays(cache: RelayCache, filter: ReqFilter): Array<
|
||||
},
|
||||
});
|
||||
}
|
||||
debug("GOSSIP")("Picked %o", picked);
|
||||
debug("GOSSIP")("Picked %O => %O", filter, picked);
|
||||
return picked;
|
||||
}
|
||||
|
||||
@ -119,7 +119,7 @@ export function splitFlatByWriteRelays(cache: RelayCache, input: Array<FlatReqFi
|
||||
} as RelayTaggedFlatFilters);
|
||||
}
|
||||
|
||||
debug("GOSSIP")("Picked %o", picked);
|
||||
debug("GOSSIP")("Picked %d relays from %d filters", picked.length, input.length);
|
||||
return picked;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { MessageEncryptor, MessageEncryptorPayload, MessageEncryptorVersion } from "index";
|
||||
|
||||
import { base64 } from "@scure/base";
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
|
||||
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||
|
@ -27,6 +27,7 @@ export * from "./text";
|
||||
export * from "./pow";
|
||||
export * from "./pow-util";
|
||||
export * from "./query-optimizer";
|
||||
export * from "./encrypted";
|
||||
|
||||
export * from "./impl/nip4";
|
||||
export * from "./impl/nip44";
|
||||
|
@ -300,7 +300,7 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
|
||||
{
|
||||
filters: [{ ...f, ids: [...resultIds] }],
|
||||
strategy: RequestStrategy.ExplicitRelays,
|
||||
relay: "",
|
||||
relay: qSend.relay,
|
||||
},
|
||||
cacheResults as Array<TaggedNostrEvent>,
|
||||
);
|
||||
|
@ -202,7 +202,7 @@ export class Query implements QueryBase {
|
||||
*/
|
||||
insertCompletedTrace(subq: BuiltRawReqFilter, data: Readonly<Array<TaggedNostrEvent>>) {
|
||||
const qt = new QueryTrace(
|
||||
"",
|
||||
subq.relay,
|
||||
subq.filters,
|
||||
"",
|
||||
() => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import debug from "debug";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { appendDedupe, sanitizeRelayUrl, unixNowMs, unwrap } from "@snort/shared";
|
||||
import { appendDedupe, dedupe, sanitizeRelayUrl, unixNowMs, unwrap } from "@snort/shared";
|
||||
|
||||
import EventKind from "./event-kind";
|
||||
import { NostrLink, NostrPrefix, SystemInterface } from "index";
|
||||
@ -113,7 +113,7 @@ export class RequestBuilder {
|
||||
|
||||
const diff = system.QueryOptimizer.getDiff(prev, this.buildRaw());
|
||||
const ts = unixNowMs() - start;
|
||||
this.#log("buildDiff %s %d ms +%d %O=>%O", this.id, ts, diff.length, prev, this.buildRaw());
|
||||
this.#log("buildDiff %s %d ms +%d", this.id, ts, diff.length);
|
||||
if (diff.length > 0) {
|
||||
return splitFlatByWriteRelays(system.RelayCache, diff).map(a => {
|
||||
return {
|
||||
@ -219,9 +219,9 @@ export class RequestFilterBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
tag(key: "e" | "p" | "d" | "t" | "r" | "a" | "g", value?: Array<string>) {
|
||||
tag(key: "e" | "p" | "d" | "t" | "r" | "a" | "g" | string, value?: Array<string>) {
|
||||
if (!value) return this;
|
||||
this.#filter[`#${key}`] = appendDedupe(this.#filter[`#${key}`], value);
|
||||
this.#filter[`#${key}`] = appendDedupe(this.#filter[`#${key}`] as Array<string>, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -239,28 +239,25 @@ export class RequestFilterBuilder {
|
||||
this.tag("d", [link.id])
|
||||
.kinds([unwrap(link.kind)])
|
||||
.authors([unwrap(link.author)]);
|
||||
link.relays?.forEach(v => this.relay(v));
|
||||
} else {
|
||||
this.ids([link.id]);
|
||||
link.relays?.forEach(v => this.relay(v));
|
||||
}
|
||||
link.relays?.forEach(v => this.relay(v));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get replies to link with e/a tags
|
||||
*/
|
||||
replyToLink(link: NostrLink) {
|
||||
if (link.type === NostrPrefix.Address) {
|
||||
this.tag("a", [`${link.kind}:${link.author}:${link.id}`]);
|
||||
link.relays?.forEach(v => this.relay(v));
|
||||
} else if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) {
|
||||
this.tag("p", [link.id]);
|
||||
link.relays?.forEach(v => this.relay(v));
|
||||
} else {
|
||||
this.tag("e", [link.id]);
|
||||
link.relays?.forEach(v => this.relay(v));
|
||||
}
|
||||
replyToLink(links: Array<NostrLink>) {
|
||||
const types = dedupe(links.map(a => a.type));
|
||||
if (types.length > 1) throw new Error("Cannot add multiple links of different kinds");
|
||||
|
||||
const tags = links.map(a => unwrap(a.toEventTag()));
|
||||
this.tag(
|
||||
tags[0][0],
|
||||
tags.map(v => v[1]),
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -293,7 +290,7 @@ export class RequestFilterBuilder {
|
||||
|
||||
return [
|
||||
{
|
||||
filters: [this.filter],
|
||||
filters: [this.#filter],
|
||||
relay: "",
|
||||
strategy: RequestStrategy.DefaultRelays,
|
||||
},
|
||||
|
@ -6,19 +6,8 @@ import { ReqFilter } from "nostr";
|
||||
export function trimFilters(filters: Array<ReqFilter>) {
|
||||
const fNew = [];
|
||||
for (const f of filters) {
|
||||
let arrays = 0;
|
||||
for (const [k, v] of Object.entries(f)) {
|
||||
if (Array.isArray(v)) {
|
||||
arrays++;
|
||||
if (v.length === 0) {
|
||||
delete f[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (arrays > 0 && Object.entries(f).some(v => Array.isArray(v))) {
|
||||
fNew.push(f);
|
||||
} else if (arrays === 0) {
|
||||
const ent = Object.entries(f).filter(([, v]) => Array.isArray(v));
|
||||
if (ent.every(([, v]) => (v as Array<string | number>).length > 0)) {
|
||||
fNew.push(f);
|
||||
}
|
||||
}
|
||||
|
100
yarn.lock
100
yarn.lock
@ -1376,7 +1376,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2":
|
||||
"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.8.4":
|
||||
version: 7.22.11
|
||||
resolution: "@babel/runtime@npm:7.22.11"
|
||||
dependencies:
|
||||
@ -2507,26 +2507,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@reduxjs/toolkit@npm:^1.9.1":
|
||||
version: 1.9.5
|
||||
resolution: "@reduxjs/toolkit@npm:1.9.5"
|
||||
dependencies:
|
||||
immer: ^9.0.21
|
||||
redux: ^4.2.1
|
||||
redux-thunk: ^2.4.2
|
||||
reselect: ^4.1.8
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18
|
||||
react-redux: ^7.2.1 || ^8.0.2
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
checksum: 54672c5593d05208af577e948a338f23128d3aa01ef056ab0d40bcfa14400cf6566be99e11715388f12c1d7655cdf7c5c6b63cb92eb0fecf996c454a46a3914c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@remix-run/router@npm:1.8.0":
|
||||
version: 1.8.0
|
||||
resolution: "@remix-run/router@npm:1.8.0"
|
||||
@ -2705,7 +2685,6 @@ __metadata:
|
||||
"@lightninglabs/lnc-web": ^0.2.3-alpha
|
||||
"@noble/curves": ^1.0.0
|
||||
"@noble/hashes": ^1.2.0
|
||||
"@reduxjs/toolkit": ^1.9.1
|
||||
"@scure/base": ^1.1.1
|
||||
"@scure/bip32": ^1.3.0
|
||||
"@scure/bip39": ^1.1.1
|
||||
@ -2719,6 +2698,7 @@ __metadata:
|
||||
"@types/node": ^20.4.1
|
||||
"@types/react": ^18.0.26
|
||||
"@types/react-dom": ^18.0.10
|
||||
"@types/use-sync-external-store": ^0.0.4
|
||||
"@types/uuid": ^9.0.2
|
||||
"@types/webscopeio__react-textarea-autocomplete": ^4.7.2
|
||||
"@types/webtorrent": ^0.109.3
|
||||
@ -2751,7 +2731,6 @@ __metadata:
|
||||
react-dom: ^18.2.0
|
||||
react-intersection-observer: ^9.4.1
|
||||
react-intl: ^6.4.4
|
||||
react-redux: ^8.0.5
|
||||
react-router-dom: ^6.5.0
|
||||
react-textarea-autosize: ^8.4.0
|
||||
react-twitter-embed: ^4.0.4
|
||||
@ -2762,6 +2741,7 @@ __metadata:
|
||||
ts-loader: ^9.4.4
|
||||
typescript: ^5.2.2
|
||||
use-long-press: ^2.0.3
|
||||
use-sync-external-store: ^1.2.0
|
||||
uuid: ^9.0.0
|
||||
webpack: ^5.88.2
|
||||
webpack-bundle-analyzer: ^4.8.0
|
||||
@ -3590,10 +3570,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/use-sync-external-store@npm:^0.0.3":
|
||||
version: 0.0.3
|
||||
resolution: "@types/use-sync-external-store@npm:0.0.3"
|
||||
checksum: 161ddb8eec5dbe7279ac971531217e9af6b99f7783213566d2b502e2e2378ea19cf5e5ea4595039d730aa79d3d35c6567d48599f69773a02ffcff1776ec2a44e
|
||||
"@types/use-sync-external-store@npm:^0.0.4":
|
||||
version: 0.0.4
|
||||
resolution: "@types/use-sync-external-store@npm:0.0.4"
|
||||
checksum: f8bf56b14f28fda6d0215281d50623d5affd17c549ba46bcfcfbb97c6301a583066c0477856260ae6feadcaa714c46cd45678e76b74da0f6f8b364aec07bd854
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -7614,13 +7594,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"immer@npm:^9.0.21":
|
||||
version: 9.0.21
|
||||
resolution: "immer@npm:9.0.21"
|
||||
checksum: 70e3c274165995352f6936695f0ef4723c52c92c92dd0e9afdfe008175af39fa28e76aafb3a2ca9d57d1fb8f796efc4dd1e1cc36f18d33fa5b74f3dfb0375432
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"import-fresh@npm:^3.2.1":
|
||||
version: 3.3.0
|
||||
resolution: "import-fresh@npm:3.3.0"
|
||||
@ -11273,38 +11246,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-redux@npm:^8.0.5":
|
||||
version: 8.1.2
|
||||
resolution: "react-redux@npm:8.1.2"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.12.1
|
||||
"@types/hoist-non-react-statics": ^3.3.1
|
||||
"@types/use-sync-external-store": ^0.0.3
|
||||
hoist-non-react-statics: ^3.3.2
|
||||
react-is: ^18.0.0
|
||||
use-sync-external-store: ^1.0.0
|
||||
peerDependencies:
|
||||
"@types/react": ^16.8 || ^17.0 || ^18.0
|
||||
"@types/react-dom": ^16.8 || ^17.0 || ^18.0
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
react-native: ">=0.59"
|
||||
redux: ^4 || ^5.0.0-beta.0
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
"@types/react-dom":
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
checksum: 4d5976b0f721e4148475871fcabce2fee875cc7f70f9a292f3370d63b38aa1dd474eb303c073c5555f3e69fc732f3bac05303def60304775deb28361e3f4b7cc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-router-dom@npm:^6.5.0":
|
||||
version: 6.15.0
|
||||
resolution: "react-router-dom@npm:6.15.0"
|
||||
@ -11522,24 +11463,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"redux-thunk@npm:^2.4.2":
|
||||
version: 2.4.2
|
||||
resolution: "redux-thunk@npm:2.4.2"
|
||||
peerDependencies:
|
||||
redux: ^4
|
||||
checksum: c7f757f6c383b8ec26152c113e20087818d18ed3edf438aaad43539e9a6b77b427ade755c9595c4a163b6ad3063adf3497e5fe6a36c68884eb1f1cfb6f049a5c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"redux@npm:^4.2.1":
|
||||
version: 4.2.1
|
||||
resolution: "redux@npm:4.2.1"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.9.2
|
||||
checksum: f63b9060c3a1d930ae775252bb6e579b42415aee7a23c4114e21a0b4ba7ec12f0ec76936c00f546893f06e139819f0e2855e0d55ebfce34ca9c026241a6950dd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"regenerate-unicode-properties@npm:^10.1.0":
|
||||
version: 10.1.0
|
||||
resolution: "regenerate-unicode-properties@npm:10.1.0"
|
||||
@ -11670,13 +11593,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"reselect@npm:^4.1.8":
|
||||
version: 4.1.8
|
||||
resolution: "reselect@npm:4.1.8"
|
||||
checksum: a4ac87cedab198769a29be92bc221c32da76cfdad6911eda67b4d3e7136dca86208c3b210e31632eae31ebd2cded18596f0dd230d3ccc9e978df22f233b5583e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"resolve-cwd@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "resolve-cwd@npm:3.0.0"
|
||||
@ -13424,7 +13340,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"use-sync-external-store@npm:^1.0.0":
|
||||
"use-sync-external-store@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "use-sync-external-store@npm:1.2.0"
|
||||
peerDependencies:
|
||||
|
Loading…
x
Reference in New Issue
Block a user