Merge pull request #514 from v0l/multi-account-2

feat: multi-account system
This commit is contained in:
Kieran 2023-04-14 18:35:23 +01:00 committed by GitHub
commit 25df19e37e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 1676 additions and 1635 deletions

View File

@ -394,5 +394,14 @@
stroke-linejoin="round"
/>
</symbol>
<symbol id="wallet" viewBox="0 0 22 18" fill="none">
<path
d="M19 6.5V4.2C19 3.0799 19 2.51984 18.782 2.09202C18.5903 1.7157 18.2843 1.40974 17.908 1.21799C17.4802 1 16.9201 1 15.8 1H4.2C3.0799 1 2.51984 1 2.09202 1.21799C1.7157 1.40973 1.40973 1.71569 1.21799 2.09202C1 2.51984 1 3.0799 1 4.2V13.8C1 14.9201 1 15.4802 1.21799 15.908C1.40973 16.2843 1.71569 16.5903 2.09202 16.782C2.51984 17 3.07989 17 4.2 17L15.8 17C16.9201 17 17.4802 17 17.908 16.782C18.2843 16.5903 18.5903 16.2843 18.782 15.908C19 15.4802 19 14.9201 19 13.8V11.5M14 9C14 8.53535 14 8.30302 14.0384 8.10982C14.1962 7.31644 14.8164 6.69624 15.6098 6.53843C15.803 6.5 16.0353 6.5 16.5 6.5H18.5C18.9647 6.5 19.197 6.5 19.3902 6.53843C20.1836 6.69624 20.8038 7.31644 20.9616 8.10982C21 8.30302 21 8.53535 21 9C21 9.46466 21 9.69698 20.9616 9.89018C20.8038 10.6836 20.1836 11.3038 19.3902 11.4616C19.197 11.5 18.9647 11.5 18.5 11.5H16.5C16.0353 11.5 15.803 11.5 15.6098 11.4616C14.8164 11.3038 14.1962 10.6836 14.0384 9.89018C14 9.69698 14 9.46465 14 9Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -39,9 +39,9 @@ export const ProfileCacheExpire = 1_000 * 60 * 30;
* Default bootstrap relays
*/
export const DefaultRelays = new Map<string, RelaySettings>([
["wss://relay.snort.social", { read: true, write: true }],
["wss://nostr.wine", { read: true, write: false }],
["wss://nos.lol", { read: true, write: true }],
["wss://relay.snort.social/", { read: true, write: true }],
["wss://nostr.wine/", { read: true, write: false }],
["wss://nos.lol/", { read: true, write: true }],
]);
/**
@ -74,6 +74,15 @@ export const RecommendedFollows = [
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha
];
/**
* Snort imgproxy details
*/
export const DefaultImgProxy = {
url: "https://imgproxy.snort.social",
key: "a82fcf26aa0ccb55dfc6b4bd6a1c90744d3be0f38429f21a8828b43449ce7cebe6bdc2b09a827311bef37b18ce35cb1e6b1c60387a254541afa9e5b4264ae942",
salt: "a897770d9abf163de055e9617891214e75a9016d748f8ef865e6ffbcb9ed932295659549773a22a019a5f06d0b440c320be411e3fddfe784e199e4f03d74bd9b",
};
/**
* NIP06-defined derivation path for private keys
*/

View File

@ -1,13 +1,13 @@
import { useState, useMemo, ChangeEvent } from "react";
import { useSelector } from "react-redux";
import { FormattedMessage } from "react-intl";
import { HexKey, TaggedRawEvent } from "@snort/nostr";
import Note from "Element/Note";
import { RootState } from "State/Store";
import useLogin from "Hooks/useLogin";
import { UserCache } from "Cache/UserCache";
import messages from "./messages";
interface BookmarksProps {
pubkey: HexKey;
bookmarks: readonly TaggedRawEvent[];
@ -16,7 +16,7 @@ interface BookmarksProps {
const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
const [onlyPubkey, setOnlyPubkey] = useState<HexKey | "all">("all");
const loginPubKey = useSelector((s: RootState) => s.login.publicKey);
const loginPubKey = useLogin().publicKey;
const ps = useMemo(() => {
return [...new Set(bookmarks.map(ev => ev.pubkey))];
}, [bookmarks]);

View File

@ -1,17 +1,15 @@
import "./DM.css";
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useIntl } from "react-intl";
import { useInView } from "react-intersection-observer";
import { HexKey, TaggedRawEvent } from "@snort/nostr";
import { TaggedRawEvent } from "@snort/nostr";
import useEventPublisher from "Feed/EventPublisher";
import NoteTime from "Element/NoteTime";
import Text from "Element/Text";
import { setLastReadDm } from "Pages/MessagesPage";
import { RootState } from "State/Store";
import { incDmInteraction } from "State/Login";
import { unwrap } from "Util";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
@ -20,8 +18,7 @@ export type DMProps = {
};
export default function DM(props: DMProps) {
const dispatch = useDispatch();
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const pubKey = useLogin().publicKey;
const publisher = useEventPublisher();
const [content, setContent] = useState("Loading...");
const [decrypted, setDecrypted] = useState(false);
@ -31,11 +28,12 @@ export default function DM(props: DMProps) {
const otherPubkey = isMe ? pubKey : unwrap(props.data.tags.find(a => a[0] === "p")?.[1]);
async function decrypt() {
const decrypted = await publisher.decryptDm(props.data);
setContent(decrypted || "<ERROR>");
if (!isMe) {
setLastReadDm(props.data.pubkey);
dispatch(incDmInteraction());
if (publisher) {
const decrypted = await publisher.decryptDm(props.data);
setContent(decrypted || "<ERROR>");
if (!isMe) {
setLastReadDm(props.data.pubkey);
}
}
}

View File

@ -1,10 +1,10 @@
import "./FollowButton.css";
import { useSelector } from "react-redux";
import { FormattedMessage } from "react-intl";
import useEventPublisher from "Feed/EventPublisher";
import { HexKey } from "@snort/nostr";
import { RootState } from "State/Store";
import useEventPublisher from "Feed/EventPublisher";
import { parseId } from "Util";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
@ -14,18 +14,26 @@ export interface FollowButtonProps {
}
export default function FollowButton(props: FollowButtonProps) {
const pubkey = parseId(props.pubkey);
const publiser = useEventPublisher();
const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);
const publisher = useEventPublisher();
const { follows, relays } = useLogin();
const isFollowing = follows.item.includes(pubkey);
const baseClassname = `${props.className} follow-button`;
async function follow(pubkey: HexKey) {
const ev = await publiser.addFollow(pubkey);
publiser.broadcast(ev);
if (publisher) {
const ev = await publisher.contactList([pubkey, ...follows.item], relays.item);
publisher.broadcast(ev);
}
}
async function unfollow(pubkey: HexKey) {
const ev = await publiser.removeFollow(pubkey);
publiser.broadcast(ev);
if (publisher) {
const ev = await publisher.contactList(
follows.item.filter(a => a !== pubkey),
relays.item
);
publisher.broadcast(ev);
}
}
return (

View File

@ -6,6 +6,7 @@ import { HexKey } from "@snort/nostr";
import ProfilePreview from "Element/ProfilePreview";
import messages from "./messages";
import useLogin from "Hooks/useLogin";
export interface FollowListBaseProps {
pubkeys: HexKey[];
@ -15,10 +16,13 @@ export interface FollowListBaseProps {
}
export default function FollowListBase({ pubkeys, title, showFollowAll, showAbout }: FollowListBaseProps) {
const publisher = useEventPublisher();
const { follows, relays } = useLogin();
async function followAll() {
const ev = await publisher.addFollow(pubkeys);
publisher.broadcast(ev);
if (publisher) {
const ev = await publisher.contactList([...pubkeys, ...follows.item], relays.item);
publisher.broadcast(ev);
}
}
return (

View File

@ -1,24 +1,22 @@
import { useDispatch } from "react-redux";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { logout } from "State/Login";
import { logout } from "Login";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
export default function LogoutButton() {
const dispatch = useDispatch();
const navigate = useNavigate();
const publicKey = useLogin().publicKey;
if (!publicKey) return;
return (
<button
className="secondary"
type="button"
onClick={() => {
dispatch(
logout(() => {
navigate("/");
})
);
logout(publicKey);
navigate("/");
}}>
<FormattedMessage {...messages.Logout} />
</button>

View File

@ -1,14 +1,11 @@
import { MixCloudRegex } from "Const";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import useLogin from "Hooks/useLogin";
const MixCloudEmbed = ({ link }: { link: string }) => {
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
const lightTheme = useSelector<RootState, boolean>(s => s.login.preferences.theme === "light");
const lightTheme = useLogin().preferences.theme === "light";
const lightParams = lightTheme ? "light=1" : "light=0";
return (
<>
<br />

View File

@ -1,7 +1,7 @@
import "./Nip05.css";
import { useQuery } from "react-query";
import Icon from "Icons/Icon";
import { HexKey } from "@snort/nostr";
import Icon from "Icons/Icon";
interface NostrJson {
names: Record<string, string>;

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, ChangeEvent } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { UserMetadata } from "@snort/nostr";
import { unwrap } from "Util";
import { formatShort } from "Number";
@ -20,10 +20,9 @@ import Copy from "Element/Copy";
import { useUserProfile } from "Hooks/useUserProfile";
import useEventPublisher from "Feed/EventPublisher";
import { debounce } from "Util";
import { UserMetadata } from "@snort/nostr";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
import { RootState } from "State/Store";
type Nip05ServiceProps = {
name: string;
@ -40,7 +39,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
const navigate = useNavigate();
const { helpText = true } = props;
const { formatMessage } = useIntl();
const pubkey = useSelector((s: RootState) => s.login.publicKey);
const pubkey = useLogin().publicKey;
const user = useUserProfile(pubkey);
const publisher = useEventPublisher();
const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
@ -190,7 +189,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
}
async function updateProfile(handle: string, domain: string) {
if (user) {
if (user && publisher) {
const nip05 = `${handle}@${domain}`;
const newProfile = {
...user,

View File

@ -1,10 +1,9 @@
import "./Note.css";
import React, { useMemo, useState, useLayoutEffect, ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useSelector, useDispatch } from "react-redux";
import { useInView } from "react-intersection-observer";
import { useIntl, FormattedMessage } from "react-intl";
import { TaggedRawEvent, HexKey, EventKind, NostrPrefix } from "@snort/nostr";
import { TaggedRawEvent, HexKey, EventKind, NostrPrefix, Lists } from "@snort/nostr";
import useEventPublisher from "Feed/EventPublisher";
import Icon from "Icons/Icon";
@ -25,13 +24,13 @@ import NoteFooter, { Translation } from "Element/NoteFooter";
import NoteTime from "Element/NoteTime";
import Reveal from "Element/Reveal";
import useModeration from "Hooks/useModeration";
import { setPinned, setBookmarked } from "State/Login";
import type { RootState } from "State/Store";
import { UserCache } from "Cache/UserCache";
import Poll from "Element/Poll";
import { EventExt } from "System/EventExt";
import useLogin from "Hooks/useLogin";
import { setBookmarked, setPinned } from "Login";
import messages from "./messages";
import { EventExt } from "System/EventExt";
export interface NoteProps {
data: TaggedRawEvent;
@ -72,7 +71,6 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => {
export default function Note(props: NoteProps) {
const navigate = useNavigate();
const dispatch = useDispatch();
const { data: ev, related, highlight, options: opt, ignoreModeration = false } = props;
const [showReactions, setShowReactions] = useState(false);
const deletions = useMemo(() => getReactions(related, ev.id, EventKind.Deletion), [related]);
@ -82,7 +80,8 @@ export default function Note(props: NoteProps) {
const [extendable, setExtendable] = useState<boolean>(false);
const [showMore, setShowMore] = useState<boolean>(false);
const baseClassName = `note card ${props.className ? props.className : ""}`;
const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
const login = useLogin();
const { pinned, bookmarked } = login;
const publisher = useEventPublisher();
const [translated, setTranslated] = useState<Translation>();
const { formatMessage } = useIntl();
@ -133,23 +132,23 @@ export default function Note(props: NoteProps) {
};
async function unpin(id: HexKey) {
if (options.canUnpin) {
if (options.canUnpin && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
const es = pinned.filter(e => e !== id);
const ev = await publisher.pinned(es);
const es = pinned.item.filter(e => e !== id);
const ev = await publisher.noteList(es, Lists.Pinned);
publisher.broadcast(ev);
dispatch(setPinned({ keys: es, createdAt: new Date().getTime() }));
setPinned(login, es, ev.created_at * 1000);
}
}
}
async function unbookmark(id: HexKey) {
if (options.canUnbookmark) {
if (options.canUnbookmark && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
const es = bookmarked.filter(e => e !== id);
const ev = await publisher.bookmarked(es);
const es = bookmarked.item.filter(e => e !== id);
const ev = await publisher.noteList(es, Lists.Bookmarked);
publisher.broadcast(ev);
dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() }));
setBookmarked(login, es, ev.created_at * 1000);
}
}
}

View File

@ -29,6 +29,7 @@ import { LNURL } from "LNURL";
import messages from "./messages";
import { ClipboardEventHandler, useState } from "react";
import Spinner from "Icons/Spinner";
import { EventBuilder } from "System";
interface NotePreviewProps {
note: TaggedRawEvent;
@ -64,7 +65,7 @@ export function NoteCreator() {
const dispatch = useDispatch();
async function sendNote() {
if (note) {
if (note && publisher) {
let extraTags: Array<Array<string>> | undefined;
if (zapForward) {
try {
@ -91,9 +92,12 @@ export function NoteCreator() {
extraTags ??= [];
extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
}
const ev = replyTo
? await publisher.reply(replyTo, note, extraTags, kind)
: await publisher.note(note, extraTags, kind);
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);
publisher.broadcast(ev);
dispatch(reset());
}
@ -154,7 +158,7 @@ export function NoteCreator() {
async function loadPreview() {
if (preview) {
dispatch(setPreview(undefined));
} else {
} else if (publisher) {
const tmpNote = await publisher.note(note);
if (tmpNote) {
dispatch(setPreview(tmpNote));

View File

@ -3,7 +3,7 @@ import { useSelector, useDispatch } from "react-redux";
import { useIntl, FormattedMessage } from "react-intl";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useLongPress } from "use-long-press";
import { TaggedRawEvent, HexKey, u256, encodeTLV, NostrPrefix } from "@snort/nostr";
import { TaggedRawEvent, HexKey, u256, encodeTLV, NostrPrefix, Lists } from "@snort/nostr";
import Icon from "Icons/Icon";
import Spinner from "Icons/Spinner";
@ -17,13 +17,14 @@ import SendSats from "Element/SendSats";
import { ParsedZap, ZapsSummary } from "Element/Zap";
import { useUserProfile } from "Hooks/useUserProfile";
import { RootState } from "State/Store";
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
import { setReplyTo, setShow, reset } from "State/NoteCreator";
import useModeration from "Hooks/useModeration";
import { SnortPubKey, TranslateHost } from "Const";
import { LNURL } from "LNURL";
import { DonateLNURL } from "Pages/DonatePage";
import { useWallet } from "Wallet";
import useLogin from "Hooks/useLogin";
import { setBookmarked, setPinned } from "Login";
import messages from "./messages";
@ -94,10 +95,9 @@ export default function NoteFooter(props: NoteFooterProps) {
const { ev, showReactions, setShowReactions, positive, negative, reposts, zaps } = props;
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const login = useLogin();
const { pinned, bookmarked, publicKey, preferences: prefs, relays } = login;
const { mute, block } = useModeration();
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const author = useUserProfile(ev.pubkey);
const publisher = useEventPublisher();
const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show);
@ -108,13 +108,13 @@ export default function NoteFooter(props: NoteFooterProps) {
const walletState = useWallet();
const wallet = walletState.wallet;
const isMine = ev.pubkey === login;
const isMine = ev.pubkey === publicKey;
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = ZapCache.has(ev.id) || zaps.some(a => a.sender === login);
const didZap = ZapCache.has(ev.id) || zaps.some(a => a.sender === publicKey);
const longPress = useLongPress(
e => {
e.stopPropagation();
@ -126,29 +126,29 @@ export default function NoteFooter(props: NoteFooterProps) {
);
function hasReacted(emoji: string) {
return positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login);
return positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey);
}
function hasReposted() {
return reposts.some(a => a.pubkey === login);
return reposts.some(a => a.pubkey === publicKey);
}
async function react(content: string) {
if (!hasReacted(content)) {
if (!hasReacted(content) && publisher) {
const evLike = await publisher.react(ev, content);
publisher.broadcast(evLike);
}
}
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) }))) {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
const evDelete = await publisher.delete(ev.id);
publisher.broadcast(evDelete);
}
}
async function repost() {
if (!hasReposted()) {
if (!hasReposted() && publisher) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev);
publisher.broadcast(evRepost);
@ -196,7 +196,9 @@ export default function NoteFooter(props: NoteFooterProps) {
await barrierZapper(async () => {
const handler = new LNURL(lnurl);
await handler.load();
const zap = handler.canZap ? await publisher.zap(amount * 1000, key, id) : undefined;
const zr = Object.keys(relays.item);
const zap = handler.canZap && publisher ? await publisher.zap(amount * 1000, key, zr, id) : undefined;
const invoice = await handler.getInvoice(amount, undefined, zap);
await wallet?.payInvoice(unwrap(invoice.pr));
});
@ -320,17 +322,21 @@ export default function NoteFooter(props: NoteFooterProps) {
}
async function pin(id: HexKey) {
const es = [...pinned, id];
const ev = await publisher.pinned(es);
publisher.broadcast(ev);
dispatch(setPinned({ keys: es, createdAt: new Date().getTime() }));
if (publisher) {
const es = [...pinned.item, id];
const ev = await publisher.noteList(es, Lists.Pinned);
publisher.broadcast(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
async function bookmark(id: HexKey) {
const es = [...bookmarked, id];
const ev = await publisher.bookmarked(es);
publisher.broadcast(ev);
dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() }));
if (publisher) {
const es = [...bookmarked.item, id];
const ev = await publisher.noteList(es, Lists.Bookmarked);
publisher.broadcast(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
async function copyEvent() {
@ -355,13 +361,13 @@ export default function NoteFooter(props: NoteFooterProps) {
<Icon name="share" />
<FormattedMessage {...messages.Share} />
</MenuItem>
{!pinned.includes(ev.id) && (
{!pinned.item.includes(ev.id) && (
<MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!bookmarked.includes(ev.id) && (
{!bookmarked.item.includes(ev.id) && (
<MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />

View File

@ -1,12 +1,10 @@
import { TaggedRawEvent } from "@snort/nostr";
import { useState } from "react";
import { useSelector } from "react-redux";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { ParsedZap } from "Element/Zap";
import Text from "Element/Text";
import useEventPublisher from "Feed/EventPublisher";
import { RootState } from "State/Store";
import { useWallet } from "Wallet";
import { useUserProfile } from "Hooks/useUserProfile";
import { LNURL } from "LNURL";
@ -14,6 +12,7 @@ import { unwrap } from "Util";
import { formatShort } from "Number";
import Spinner from "Icons/Spinner";
import SendSats from "Element/SendSats";
import useLogin from "Hooks/useLogin";
interface PollProps {
ev: TaggedRawEvent;
@ -24,8 +23,7 @@ export default function Poll(props: PollProps) {
const { formatMessage } = useIntl();
const publisher = useEventPublisher();
const { wallet } = useWallet();
const prefs = useSelector((s: RootState) => s.login.preferences);
const myPubKey = useSelector((s: RootState) => s.login.publicKey);
const { preferences: prefs, publicKey: myPubKey, relays } = useLogin();
const pollerProfile = useUserProfile(props.ev.pubkey);
const [error, setError] = useState("");
const [invoice, setInvoice] = useState("");
@ -37,7 +35,7 @@ export default function Poll(props: PollProps) {
const options = props.ev.tags.filter(a => a[0] === "poll_option").sort((a, b) => Number(a[1]) - Number(b[1]));
async function zapVote(ev: React.MouseEvent, opt: number) {
ev.stopPropagation();
if (voting) return;
if (voting || !publisher) return;
const amount = prefs.defaultZapAmount;
try {
@ -55,17 +53,10 @@ export default function Poll(props: PollProps) {
}
setVoting(opt);
const zap = await publisher.zap(amount * 1000, props.ev.pubkey, props.ev.id, undefined, [
["poll_option", opt.toString()],
]);
if (!zap) {
throw new Error(
formatMessage({
defaultMessage: "Can't create vote, maybe you're not logged in?",
})
);
}
const r = Object.keys(relays.item);
const zap = await publisher.zap(amount * 1000, props.ev.pubkey, r, props.ev.id, undefined, eb =>
eb.tag(["poll_option", opt.toString()])
);
const lnurl = props.ev.tags.find(a => a[0] === "zap")?.[1] || pollerProfile?.lud16 || pollerProfile?.lud06;
if (!lnurl) return;

View File

@ -2,7 +2,6 @@ import "./Relay.css";
import { useMemo } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlug,
@ -16,35 +15,33 @@ import {
import { RelaySettings } from "@snort/nostr";
import useRelayState from "Feed/RelayState";
import { setRelays } from "State/Login";
import { RootState } from "State/Store";
import { System } from "System";
import { getRelayName, unwrap } from "Util";
import { getRelayName, unixNowMs, unwrap } from "Util";
import messages from "./messages";
import useLogin from "Hooks/useLogin";
import { setRelays } from "Login";
export interface RelayProps {
addr: string;
}
export default function Relay(props: RelayProps) {
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const navigate = useNavigate();
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const relaySettings = unwrap(allRelaySettings[props.addr] ?? System.Sockets.get(props.addr)?.Settings ?? {});
const login = useLogin();
const relaySettings = unwrap(login.relays.item[props.addr] ?? System.Sockets.get(props.addr)?.Settings ?? {});
const state = useRelayState(props.addr);
const name = useMemo(() => getRelayName(props.addr), [props.addr]);
function configure(o: RelaySettings) {
dispatch(
setRelays({
relays: {
...allRelaySettings,
[props.addr]: o,
},
createdAt: Math.floor(new Date().getTime() / 1000),
})
setRelays(
login,
{
...login.relays.item,
[props.addr]: o,
},
unixNowMs()
);
}

View File

@ -1,9 +1,8 @@
import { FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import MediaLink from "Element/MediaLink";
import Reveal from "Element/Reveal";
import { RootState } from "State/Store";
import useLogin from "Hooks/useLogin";
interface RevealMediaProps {
creator: string;
@ -11,11 +10,10 @@ interface RevealMediaProps {
}
export default function RevealMedia(props: RevealMediaProps) {
const pref = useSelector((s: RootState) => s.login.preferences);
const follows = useSelector((s: RootState) => s.login.follows);
const publicKey = useSelector((s: RootState) => s.login.publicKey);
const login = useLogin();
const { preferences: pref, follows, publicKey } = login;
const hideNonFollows = pref.autoLoadMedia === "follows-only" && !follows.includes(props.creator);
const hideNonFollows = pref.autoLoadMedia === "follows-only" && !follows.item.includes(props.creator);
const isMine = props.creator === publicKey;
const hideMedia = pref.autoLoadMedia === "none" || (!isMine && hideNonFollows);
const hostname = new URL(props.link).hostname;

View File

@ -1,11 +1,9 @@
import "./SendSats.css";
import React, { useEffect, useMemo, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { HexKey, RawEvent } from "@snort/nostr";
import { formatShort } from "Number";
import { RootState } from "State/Store";
import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher";
import ProfileImage from "Element/ProfileImage";
@ -15,7 +13,9 @@ import Copy from "Element/Copy";
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
import { chunks, debounce } from "Util";
import { useWallet } from "Wallet";
import { EventExt } from "System/EventExt";
import useLogin from "Hooks/useLogin";
import { generateRandomKey } from "Login";
import { EventPublisher } from "System/EventPublisher";
import messages from "./messages";
@ -41,7 +41,8 @@ export interface SendSatsProps {
export default function SendSats(props: SendSatsProps) {
const onClose = props.onClose || (() => undefined);
const { note, author, target } = props;
const defaultZapAmount = useSelector((s: RootState) => s.login.preferences.defaultZapAmount);
const login = useLogin();
const defaultZapAmount = login.preferences.defaultZapAmount;
const amounts = [defaultZapAmount, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000];
const emojis: Record<number, string> = {
1_000: "👍",
@ -119,22 +120,21 @@ export default function SendSats(props: SendSatsProps) {
};
async function loadInvoice() {
if (!amount || !handler) return null;
if (!amount || !handler || !publisher) return null;
let zap: RawEvent | undefined;
if (author && zapType !== ZapType.NonZap) {
const ev = await publisher.zap(amount * 1000, author, note, comment);
if (ev) {
// replace sig for anon-zap
if (zapType === ZapType.AnonZap) {
const randomKey = publisher.newKey();
console.debug("Generated new key for zap: ", randomKey);
ev.pubkey = randomKey.publicKey;
ev.id = "";
ev.tags.push(["anon", ""]);
await EventExt.sign(ev, randomKey.privateKey);
}
zap = ev;
const relays = Object.keys(login.relays.item);
// use random key for anon zaps
if (zapType === ZapType.AnonZap) {
const randomKey = generateRandomKey();
console.debug("Generated new key for zap: ", randomKey);
const publisher = new EventPublisher(randomKey.publicKey, randomKey.privateKey);
zap = await publisher.zap(amount * 1000, author, relays, note, comment, eb => eb.tag(["anon", ""]));
} else {
zap = await publisher.zap(amount * 1000, author, relays, note, comment);
}
}

View File

@ -1,16 +1,15 @@
import "./Zap.css";
import { useMemo } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useSelector } from "react-redux";
import { HexKey, TaggedRawEvent } from "@snort/nostr";
import { decodeInvoice, InvoiceDetails, sha256, unwrap } from "Util";
import { formatShort } from "Number";
import Text from "Element/Text";
import ProfileImage from "Element/ProfileImage";
import { RootState } from "State/Store";
import { findTag } from "Util";
import { UserCache } from "Cache/UserCache";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
@ -103,7 +102,7 @@ export interface ParsedZap {
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean }) => {
const { amount, content, sender, valid, receiver } = zap;
const pubKey = useSelector((s: RootState) => s.login.publicKey);
const pubKey = useLogin().publicKey;
return valid && sender ? (
<div className="zap note card">

View File

@ -0,0 +1,41 @@
type HookFn = () => void;
interface HookFilter {
fn: HookFn;
}
/**
* Simple React hookable store with manual change notifications
*/
export default abstract class ExternalStore<TSnapshot> {
#hooks: Array<HookFilter> = [];
#snapshot: Readonly<TSnapshot> = {} as Readonly<TSnapshot>;
#changed = true;
hook(fn: HookFn) {
this.#hooks.push({
fn,
});
return () => {
const idx = this.#hooks.findIndex(a => a.fn === fn);
if (idx >= 0) {
this.#hooks.splice(idx, 1);
}
};
}
snapshot() {
if (this.#changed) {
this.#snapshot = this.takeSnapshot();
this.#changed = false;
}
return this.#snapshot;
}
protected notifyChange() {
this.#changed = true;
this.#hooks.forEach(h => h.fn());
}
abstract takeSnapshot(): TSnapshot;
}

View File

@ -1,10 +1,9 @@
import { useSelector } from "react-redux";
import { HexKey, Lists } from "@snort/nostr";
import { RootState } from "State/Store";
import useNotelistSubscription from "Hooks/useNotelistSubscription";
import useLogin from "Hooks/useLogin";
export default function useBookmarkFeed(pubkey?: HexKey) {
const { bookmarked } = useSelector((s: RootState) => s.login);
return useNotelistSubscription(pubkey, Lists.Bookmarked, bookmarked);
const { bookmarked } = useLogin();
return useNotelistSubscription(pubkey, Lists.Bookmarked, bookmarked.item);
}

View File

@ -1,422 +1,12 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import * as secp from "@noble/secp256k1";
import { EventKind, RelaySettings, TaggedRawEvent, HexKey, RawEvent, u256, UserMetadata, Lists } from "@snort/nostr";
import { RootState } from "State/Store";
import { bech32ToHex, delay, unwrap } from "Util";
import { DefaultRelays, HashtagRegex } from "Const";
import { System } from "System";
import { EventExt } from "System/EventExt";
declare global {
interface Window {
nostr: {
getPublicKey: () => Promise<HexKey>;
signEvent: (event: RawEvent) => Promise<RawEvent>;
getRelays: () => Promise<Record<string, { read: boolean; write: boolean }>>;
nip04: {
encrypt: (pubkey: HexKey, content: string) => Promise<string>;
decrypt: (pubkey: HexKey, content: string) => Promise<string>;
};
};
}
}
export type EventPublisher = ReturnType<typeof useEventPublisher>;
import useLogin from "Hooks/useLogin";
import { EventPublisher } from "System/EventPublisher";
export default function useEventPublisher() {
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
const relays = useSelector((s: RootState) => s.login.relays);
const hasNip07 = "nostr" in window;
async function signEvent(ev: RawEvent): Promise<RawEvent> {
if (!pubKey) {
throw new Error("Cant sign events when logged out");
const { publicKey, privateKey } = useLogin();
return useMemo(() => {
if (publicKey) {
return new EventPublisher(publicKey, privateKey);
}
if (hasNip07 && !privKey) {
ev.id = await EventExt.createId(ev);
const tmpEv = (await barrierNip07(() => window.nostr.signEvent(ev))) as RawEvent;
ev.sig = tmpEv.sig;
return ev;
} else if (privKey) {
await EventExt.sign(ev, privKey);
} else {
console.warn("Count not sign event, no private keys available");
}
return ev;
}
function processContent(ev: RawEvent, msg: string) {
const replaceNpub = (match: string) => {
const npub = match.slice(1);
try {
const hex = bech32ToHex(npub);
const idx = ev.tags.length;
ev.tags.push(["p", hex]);
return `#[${idx}]`;
} catch (error) {
return match;
}
};
const replaceNoteId = (match: string) => {
const noteId = match.slice(1);
try {
const hex = bech32ToHex(noteId);
const idx = ev.tags.length;
ev.tags.push(["e", hex, "", "mention"]);
return `#[${idx}]`;
} catch (error) {
return match;
}
};
const replaceHashtag = (match: string) => {
const tag = match.slice(1);
ev.tags.push(["t", tag.toLowerCase()]);
return match;
};
const content = msg
.replace(/@npub[a-z0-9]+/g, replaceNpub)
.replace(/@note1[acdefghjklmnpqrstuvwxyz023456789]{58}/g, replaceNoteId)
.replace(HashtagRegex, replaceHashtag);
ev.content = content;
}
const ret = {
nip42Auth: async (challenge: string, relay: string) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.Auth);
ev.tags.push(["relay", relay]);
ev.tags.push(["challenge", challenge]);
return await signEvent(ev);
}
},
broadcast: (ev: RawEvent | undefined) => {
if (ev) {
console.debug(ev);
System.BroadcastEvent(ev);
}
},
/**
* Write event to DefaultRelays, this is important for profiles / relay lists to prevent bugs
* If a user removes all the DefaultRelays from their relay list and saves that relay list,
* When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state
*/
broadcastForBootstrap: (ev: RawEvent | undefined) => {
if (ev) {
for (const [k] of DefaultRelays) {
System.WriteOnceToRelay(k, ev);
}
}
},
/**
* Write event to all given relays.
*/
broadcastAll: (ev: RawEvent | undefined, relays: string[]) => {
if (ev) {
for (const k of relays) {
System.WriteOnceToRelay(k, ev);
}
}
},
muted: async (keys: HexKey[], priv: HexKey[]) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.PubkeyLists);
ev.tags.push(["d", Lists.Muted]);
keys.forEach(p => {
ev.tags.push(["p", p]);
});
let content = "";
if (priv.length > 0) {
const ps = priv.map(p => ["p", p]);
const plaintext = JSON.stringify(ps);
if (hasNip07 && !privKey) {
content = await barrierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext));
} else if (privKey) {
content = await EventExt.encryptData(plaintext, pubKey, privKey);
}
}
ev.content = content;
return await signEvent(ev);
}
},
pinned: async (notes: HexKey[]) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.NoteLists);
ev.tags.push(["d", Lists.Pinned]);
notes.forEach(n => {
ev.tags.push(["e", n]);
});
return await signEvent(ev);
}
},
bookmarked: async (notes: HexKey[]) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.NoteLists);
ev.tags.push(["d", Lists.Bookmarked]);
notes.forEach(n => {
ev.tags.push(["e", n]);
});
return await signEvent(ev);
}
},
tags: async (tags: string[]) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.TagLists);
ev.tags.push(["d", Lists.Followed]);
tags.forEach(t => {
ev.tags.push(["t", t]);
});
return await signEvent(ev);
}
},
metadata: async (obj: UserMetadata) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.SetMetadata);
ev.content = JSON.stringify(obj);
return await signEvent(ev);
}
},
note: async (msg: string, extraTags?: Array<Array<string>>, kind?: EventKind) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, kind ?? EventKind.TextNote);
processContent(ev, msg);
if (extraTags) {
for (const et of extraTags) {
ev.tags.push(et);
}
}
return await signEvent(ev);
}
},
/**
* Create a zap request event for a given target event/profile
* @param amount Millisats amout!
* @param author Author pubkey to tag in the zap
* @param note Note Id to tag in the zap
* @param msg Custom message to be included in the zap
* @param extraTags Any extra tags to include on the zap request event
* @returns
*/
zap: async (amount: number, author: HexKey, note?: HexKey, msg?: string, extraTags?: Array<Array<string>>) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.ZapRequest);
if (note) {
ev.tags.push(["e", note]);
}
ev.tags.push(["p", author]);
const relayTag = ["relays", ...Object.keys(relays).map(a => a.trim())];
ev.tags.push(relayTag);
ev.tags.push(["amount", amount.toString()]);
ev.tags.push(...(extraTags ?? []));
processContent(ev, msg || "");
return await signEvent(ev);
}
},
/**
* Reply to a note
*/
reply: async (replyTo: TaggedRawEvent, msg: string, extraTags?: Array<Array<string>>, kind?: EventKind) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, kind ?? EventKind.TextNote);
const thread = EventExt.extractThread(ev);
if (thread) {
if (thread.root || thread.replyTo) {
ev.tags.push(["e", thread.root?.Event ?? thread.replyTo?.Event ?? "", "", "root"]);
}
ev.tags.push(["e", replyTo.id, replyTo.relays[0] ?? "", "reply"]);
// dont tag self in replies
if (replyTo.pubkey !== pubKey) {
ev.tags.push(["p", replyTo.pubkey]);
}
for (const pk of thread.pubKeys) {
if (pk === pubKey) {
continue; // dont tag self in replies
}
ev.tags.push(["p", pk]);
}
} else {
ev.tags.push(["e", replyTo.id, "", "reply"]);
// dont tag self in replies
if (replyTo.pubkey !== pubKey) {
ev.tags.push(["p", replyTo.pubkey]);
}
}
processContent(ev, msg);
if (extraTags) {
for (const et of extraTags) {
ev.tags.push(et);
}
}
return await signEvent(ev);
}
},
react: async (evRef: RawEvent, content = "+") => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.Reaction);
ev.content = content;
ev.tags.push(["e", evRef.id]);
ev.tags.push(["p", evRef.pubkey]);
return await signEvent(ev);
}
},
saveRelays: async () => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
ev.content = JSON.stringify(relays);
for (const pk of follows) {
ev.tags.push(["p", pk]);
}
return await signEvent(ev);
}
},
saveRelaysSettings: async () => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.Relays);
for (const [url, settings] of Object.entries(relays)) {
const rTag = ["r", url];
if (settings.read && !settings.write) {
rTag.push("read");
}
if (settings.write && !settings.read) {
rTag.push("write");
}
ev.tags.push(rTag);
}
return await signEvent(ev);
}
},
addFollow: async (pkAdd: HexKey | HexKey[], newRelays?: Record<string, RelaySettings>) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
ev.content = JSON.stringify(newRelays ?? relays);
const temp = new Set(follows);
if (Array.isArray(pkAdd)) {
pkAdd.forEach(a => temp.add(a));
} else {
temp.add(pkAdd);
}
for (const pk of temp) {
if (pk.length !== 64) {
continue;
}
ev.tags.push(["p", pk.toLowerCase()]);
}
return await signEvent(ev);
}
},
removeFollow: async (pkRemove: HexKey) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
ev.content = JSON.stringify(relays);
for (const pk of follows) {
if (pk === pkRemove || pk.length !== 64) {
continue;
}
ev.tags.push(["p", pk]);
}
return await signEvent(ev);
}
},
/**
* Delete an event (NIP-09)
*/
delete: async (id: u256) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.Deletion);
ev.tags.push(["e", id]);
return await signEvent(ev);
}
},
/**
* Repost a note (NIP-18)
*/
repost: async (note: TaggedRawEvent) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.Repost);
ev.tags.push(["e", note.id, ""]);
ev.tags.push(["p", note.pubkey]);
return await signEvent(ev);
}
},
decryptDm: async (note: RawEvent): Promise<string | undefined> => {
if (pubKey) {
if (note.pubkey !== pubKey && !note.tags.some(a => a[1] === pubKey)) {
return "<CANT DECRYPT>";
}
try {
const otherPubKey = note.pubkey === pubKey ? unwrap(note.tags.find(a => a[0] === "p")?.[1]) : note.pubkey;
if (hasNip07 && !privKey) {
return await barrierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.content));
} else if (privKey) {
return await EventExt.decryptDm(note.content, privKey, otherPubKey);
}
} catch (e) {
console.error("Decryption failed", e);
return "<DECRYPTION FAILED>";
}
}
},
sendDm: async (content: string, to: HexKey) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.DirectMessage);
ev.content = content;
ev.tags.push(["p", to]);
try {
if (hasNip07 && !privKey) {
const cx: string = await barrierNip07(() => window.nostr.nip04.encrypt(to, content));
ev.content = cx;
return await signEvent(ev);
} else if (privKey) {
ev.content = await EventExt.encryptData(content, to, privKey);
return await signEvent(ev);
}
} catch (e) {
console.error("Encryption failed", e);
}
}
},
newKey: () => {
const privKey = secp.utils.bytesToHex(secp.utils.randomPrivateKey());
const pubKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(privKey));
return {
privateKey: privKey,
publicKey: pubKey,
};
},
generic: async (content: string, kind: EventKind, tags?: Array<Array<string>>) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, kind);
ev.content = content;
ev.tags = tags ?? [];
return await signEvent(ev);
}
},
};
return useMemo(() => ret, [pubKey, relays, follows]);
}, [publicKey, privateKey]);
}
let isNip07Busy = false;
export const barrierNip07 = async <T>(then: () => Promise<T>): Promise<T> => {
while (isNip07Busy) {
await delay(10);
}
isNip07Busy = true;
try {
return await then();
} finally {
isNip07Busy = false;
}
};

View File

@ -1,13 +1,12 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { HexKey, TaggedRawEvent, EventKind } from "@snort/nostr";
import { RootState } from "State/Store";
import { PubkeyReplaceableNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
import useLogin from "Hooks/useLogin";
export default function useFollowsFeed(pubkey?: HexKey) {
const { publicKey, follows } = useSelector((s: RootState) => s.login);
const { publicKey, follows } = useLogin();
const isMe = publicKey === pubkey;
const sub = useMemo(() => {
@ -20,7 +19,7 @@ export default function useFollowsFeed(pubkey?: HexKey) {
const contactFeed = useRequestBuilder<PubkeyReplaceableNoteStore>(PubkeyReplaceableNoteStore, sub);
return useMemo(() => {
if (isMe) {
return follows;
return follows.item;
}
return getFollowing(contactFeed.data ?? [], pubkey);

View File

@ -1,30 +1,16 @@
import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit";
import { TaggedRawEvent, HexKey, Lists, EventKind } from "@snort/nostr";
import { TaggedRawEvent, Lists, EventKind } from "@snort/nostr";
import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "Util";
import { makeNotification } from "Notifications";
import {
setFollows,
setRelays,
setMuted,
setTags,
setPinned,
setBookmarked,
setBlocked,
sendNotification,
setLatestNotifications,
addSubscription,
} from "State/Login";
import { RootState } from "State/Store";
import useEventPublisher, { barrierNip07 } from "Feed/EventPublisher";
import { makeNotification, sendNotification } from "Notifications";
import useEventPublisher from "Feed/EventPublisher";
import { getMutedKeys } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
import { FlatNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
import { EventExt } from "System/EventExt";
import { DmCache } from "Cache";
import useLogin from "Hooks/useLogin";
import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPinned, setRelays, setTags } from "Login";
import { SnortPubKey } from "Const";
import { SubscriptionEvent } from "Subscription";
@ -32,13 +18,8 @@ import { SubscriptionEvent } from "Subscription";
* Managed loading data for the current logged in user
*/
export default function useLoginFeed() {
const dispatch = useDispatch();
const {
publicKey: pubKey,
privateKey: privKey,
latestMuted,
readNotifications,
} = useSelector((s: RootState) => s.login);
const login = useLogin();
const { publicKey: pubKey, readNotifications } = login;
const { isMuted } = useModeration();
const publisher = useEventPublisher();
@ -81,15 +62,15 @@ export default function useLoginFeed() {
// update relays and follow lists
useEffect(() => {
if (loginFeed.data) {
if (loginFeed.data && publisher) {
const contactList = getNewest(loginFeed.data.filter(a => a.kind === EventKind.ContactList));
if (contactList) {
if (contactList.content !== "" && contactList.content !== "{}") {
const relays = JSON.parse(contactList.content);
dispatch(setRelays({ relays, createdAt: contactList.created_at }));
setRelays(login, relays, contactList.created_at * 1000);
}
const pTags = contactList.tags.filter(a => a[0] === "p").map(a => a[1]);
dispatch(setFollows({ keys: pTags, createdAt: contactList.created_at }));
setFollows(login, pTags, contactList.created_at * 1000);
}
const dms = loginFeed.data.filter(a => a.kind === EventKind.DirectMessage);
@ -109,9 +90,9 @@ export default function useLoginFeed() {
} as SubscriptionEvent;
}
})
).then(a => dispatch(addSubscription(a.filter(a => a !== undefined).map(unwrap))));
).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap)));
}
}, [dispatch, loginFeed]);
}, [loginFeed, publisher]);
// send out notifications
useEffect(() => {
@ -119,34 +100,27 @@ export default function useLoginFeed() {
const replies = loginFeed.data.filter(
a => a.kind === EventKind.TextNote && !isMuted(a.pubkey) && a.created_at > readNotifications
);
replies.forEach(nx => {
dispatch(setLatestNotifications(nx.created_at));
makeNotification(nx).then(notification => {
if (notification) {
(dispatch as ThunkDispatch<RootState, undefined, AnyAction>)(sendNotification(notification));
}
});
replies.forEach(async nx => {
const n = await makeNotification(nx);
if (n) {
sendNotification(login, n);
}
});
}
}, [dispatch, loginFeed, readNotifications]);
}, [loginFeed, readNotifications]);
function handleMutedFeed(mutedFeed: TaggedRawEvent[]) {
const muted = getMutedKeys(mutedFeed);
dispatch(setMuted(muted));
setMuted(login, muted.keys, muted.createdAt * 1000);
const newest = getNewest(mutedFeed);
if (newest && newest.content.length > 0 && pubKey && newest.created_at > latestMuted) {
decryptBlocked(newest, pubKey, privKey)
if (muted.raw && (muted.raw?.content?.length ?? 0) > 0 && pubKey) {
publisher
?.nip4Decrypt(muted.raw.content, pubKey)
.then(plaintext => {
try {
const blocked = JSON.parse(plaintext);
const keys = blocked.filter((p: string) => p && p.length === 2 && p[0] === "p").map((p: string) => p[1]);
dispatch(
setBlocked({
keys,
createdAt: newest.created_at,
})
);
setBlocked(login, keys, unwrap(muted.raw).created_at * 1000);
} catch (error) {
console.debug("Couldn't parse JSON");
}
@ -158,26 +132,21 @@ export default function useLoginFeed() {
function handlePinnedFeed(pinnedFeed: TaggedRawEvent[]) {
const newest = getNewestEventTagsByKey(pinnedFeed, "e");
if (newest) {
dispatch(setPinned(newest));
setPinned(login, newest.keys, newest.createdAt * 1000);
}
}
function handleTagFeed(tagFeed: TaggedRawEvent[]) {
const newest = getNewestEventTagsByKey(tagFeed, "t");
if (newest) {
dispatch(
setTags({
tags: newest.keys,
createdAt: newest.createdAt,
})
);
setTags(login, newest.keys, newest.createdAt * 1000);
}
}
function handleBookmarkFeed(bookmarkFeed: TaggedRawEvent[]) {
const newest = getNewestEventTagsByKey(bookmarkFeed, "e");
if (newest) {
dispatch(setBookmarked(newest));
setBookmarked(login, newest.keys, newest.createdAt * 1000);
}
}
@ -200,18 +169,10 @@ export default function useLoginFeed() {
const bookmarkFeed = getList(listsFeed.data, Lists.Bookmarked);
handleBookmarkFeed(bookmarkFeed);
}
}, [dispatch, listsFeed]);
}, [listsFeed]);
/*const fRelays = useRelaysFeedFollows(follows);
useEffect(() => {
FollowsRelays.bulkSet(fRelays).catch(console.error);
}, [dispatch, fRelays]);*/
}
async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) {
if (pubKey && privKey) {
return await EventExt.decryptData(raw.content, privKey, pubKey);
} else {
return await barrierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content));
}
}

View File

@ -1,15 +1,13 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { getNewest } from "Util";
import { HexKey, TaggedRawEvent, Lists, EventKind } from "@snort/nostr";
import { RootState } from "State/Store";
import { getNewest } from "Util";
import { ParameterizedReplaceableNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
import useLogin from "Hooks/useLogin";
export default function useMutedFeed(pubkey?: HexKey) {
const { publicKey, muted } = useSelector((s: RootState) => s.login);
const { publicKey, muted } = useLogin();
const isMe = publicKey === pubkey;
const sub = useMemo(() => {
@ -28,18 +26,20 @@ export default function useMutedFeed(pubkey?: HexKey) {
return [];
}, [mutedFeed, pubkey]);
return isMe ? muted : mutedList;
return isMe ? muted.item : mutedList;
}
export function getMutedKeys(rawNotes: TaggedRawEvent[]): {
createdAt: number;
keys: HexKey[];
raw?: TaggedRawEvent;
} {
const newest = getNewest(rawNotes);
if (newest) {
const { created_at, tags } = newest;
const keys = tags.filter(t => t[0] === "p").map(t => t[1]);
return {
raw: newest,
keys,
createdAt: created_at,
};

View File

@ -1,10 +1,8 @@
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { HexKey, Lists } from "@snort/nostr";
import useNotelistSubscription from "Hooks/useNotelistSubscription";
import useLogin from "Hooks/useLogin";
export default function usePinnedFeed(pubkey?: HexKey) {
const { pinned } = useSelector((s: RootState) => s.login);
const pinned = useLogin().pinned.item;
return useNotelistSubscription(pubkey, Lists.Pinned, pinned);
}

View File

@ -1,17 +1,15 @@
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { u256, EventKind } from "@snort/nostr";
import { RootState } from "State/Store";
import { UserPreferences } from "State/Login";
import { appendDedupe, NostrLink } from "Util";
import { FlatNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
import useLogin from "Hooks/useLogin";
export default function useThreadFeed(link: NostrLink) {
const [trackingEvents, setTrackingEvent] = useState<u256[]>([link.id]);
const [allEvents, setAllEvents] = useState<u256[]>([link.id]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const pref = useLogin().preferences;
const sub = useMemo(() => {
const sub = new RequestBuilder(`thread:${link.id.substring(0, 8)}`);

View File

@ -1,13 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { EventKind, u256 } from "@snort/nostr";
import { unixNow, unwrap, tagFilterOfTextRepost } from "Util";
import { RootState } from "State/Store";
import { UserPreferences } from "State/Login";
import { FlatNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
import useTimelineWindow from "Hooks/useTimelineWindow";
import useLogin from "Hooks/useLogin";
export interface TimelineFeedOptions {
method: "TIME_RANGE" | "LIMIT_UNTIL";
@ -31,7 +29,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
});
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const pref = useLogin().preferences;
const createBuilder = useCallback(() => {
if (subject.type !== "global" && subject.items.length === 0) {

View File

@ -1,8 +1,7 @@
import * as secp from "@noble/secp256k1";
import * as base64 from "@protobufjs/base64";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { hmacSha256, unwrap } from "Util";
import useLogin from "Hooks/useLogin";
export interface ImgProxySettings {
url: string;
@ -11,7 +10,7 @@ export interface ImgProxySettings {
}
export default function useImgProxy() {
const settings = useSelector((s: RootState) => s.login.preferences.imgProxyConfig);
const settings = useLogin().preferences.imgProxyConfig;
const te = new TextEncoder();
function urlSafe(s: string) {

View File

@ -0,0 +1,9 @@
import { LoginStore } from "Login";
import { useSyncExternalStore } from "react";
export default function useLogin() {
return useSyncExternalStore(
s => LoginStore.hook(s),
() => LoginStore.snapshot()
);
}

View File

@ -1,95 +1,68 @@
import { useSelector, useDispatch } from "react-redux";
import type { RootState } from "State/Store";
import { HexKey } from "@snort/nostr";
import useEventPublisher from "Feed/EventPublisher";
import { setMuted, setBlocked } from "State/Login";
import useLogin from "Hooks/useLogin";
import { setBlocked, setMuted } from "Login";
import { appendDedupe } from "Util";
export default function useModeration() {
const dispatch = useDispatch();
const { blocked, muted } = useSelector((s: RootState) => s.login);
const login = useLogin();
const { muted, blocked } = login;
const publisher = useEventPublisher();
async function setMutedList(pub: HexKey[], priv: HexKey[]) {
try {
if (publisher) {
const ev = await publisher.muted(pub, priv);
console.debug(ev);
publisher.broadcast(ev);
} catch (error) {
console.debug("Couldn't change mute list");
return ev.created_at * 1000;
}
return 0;
}
function isMuted(id: HexKey) {
return muted.includes(id) || blocked.includes(id);
return muted.item.includes(id) || blocked.item.includes(id);
}
function isBlocked(id: HexKey) {
return blocked.includes(id);
return blocked.item.includes(id);
}
function unmute(id: HexKey) {
const newMuted = muted.filter(p => p !== id);
dispatch(
setMuted({
createdAt: new Date().getTime(),
keys: newMuted,
})
);
setMutedList(newMuted, blocked);
async function unmute(id: HexKey) {
const newMuted = muted.item.filter(p => p !== id);
const ts = await setMutedList(newMuted, blocked.item);
setMuted(login, newMuted, ts);
}
function unblock(id: HexKey) {
const newBlocked = blocked.filter(p => p !== id);
dispatch(
setBlocked({
createdAt: new Date().getTime(),
keys: newBlocked,
})
);
setMutedList(muted, newBlocked);
async function unblock(id: HexKey) {
const newBlocked = blocked.item.filter(p => p !== id);
const ts = await setMutedList(muted.item, newBlocked);
setBlocked(login, newBlocked, ts);
}
function mute(id: HexKey) {
const newMuted = muted.includes(id) ? muted : muted.concat([id]);
setMutedList(newMuted, blocked);
dispatch(
setMuted({
createdAt: new Date().getTime(),
keys: newMuted,
})
);
async function mute(id: HexKey) {
const newMuted = muted.item.includes(id) ? muted.item : muted.item.concat([id]);
const ts = await setMutedList(newMuted, blocked.item);
setMuted(login, newMuted, ts);
}
function block(id: HexKey) {
const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id]);
setMutedList(muted, newBlocked);
dispatch(
setBlocked({
createdAt: new Date().getTime(),
keys: newBlocked,
})
);
async function block(id: HexKey) {
const newBlocked = blocked.item.includes(id) ? blocked.item : blocked.item.concat([id]);
const ts = await setMutedList(muted.item, newBlocked);
setBlocked(login, newBlocked, ts);
}
function muteAll(ids: HexKey[]) {
const newMuted = Array.from(new Set(muted.concat(ids)));
setMutedList(newMuted, blocked);
dispatch(
setMuted({
createdAt: new Date().getTime(),
keys: newMuted,
})
);
async function muteAll(ids: HexKey[]) {
const newMuted = appendDedupe(muted.item, ids);
const ts = await setMutedList(newMuted, blocked.item);
setMuted(login, newMuted, ts);
}
return {
muted,
muted: muted.item,
mute,
muteAll,
unmute,
isMuted,
blocked,
blocked: blocked.item,
block,
unblock,
isBlocked,

View File

@ -1,13 +1,12 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { HexKey, Lists, EventKind } from "@snort/nostr";
import { RootState } from "State/Store";
import { FlatNoteStore, ParameterizedReplaceableNoteStore, RequestBuilder } from "System";
import useRequestBuilder from "Hooks/useRequestBuilder";
import useLogin from "Hooks/useLogin";
export default function useNotelistSubscription(pubkey: HexKey | undefined, l: Lists, defaultIds: HexKey[]) {
const { preferences, publicKey } = useSelector((s: RootState) => s.login);
const { preferences, publicKey } = useLogin();
const isMe = publicKey === pubkey;
const sub = useMemo(() => {

View File

@ -1,7 +1,6 @@
import { type ReactNode } from "react";
import { IntlProvider as ReactIntlProvider } from "react-intl";
import { ReadPreferences } from "State/Login";
import enMessages from "translations/en.json";
import esMessages from "translations/es_ES.json";
import zhMessages from "translations/zh_CN.json";
@ -16,6 +15,7 @@ import deMessages from "translations/de_DE.json";
import ruMessages from "translations/ru_RU.json";
import svMessages from "translations/sv_SE.json";
import hrMessages from "translations/hr_HR.json";
import useLogin from "Hooks/useLogin";
const DefaultLocale = "en-US";
@ -73,7 +73,7 @@ const getMessages = (locale: string) => {
};
export const IntlProvider = ({ children }: { children: ReactNode }) => {
const { language } = ReadPreferences();
const { language } = useLogin().preferences;
const locale = language ?? getLocale();
return (

View File

@ -0,0 +1,154 @@
import { HexKey, RelaySettings } from "@snort/nostr";
import * as secp from "@noble/secp256k1";
import { DefaultRelays, SnortPubKey } from "Const";
import { LoginStore, UserPreferences, LoginSession } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unixNowMs, unwrap } from "Util";
import { getCurrentSubscription, SubscriptionEvent } from "Subscription";
import { EventPublisher } from "System/EventPublisher";
export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) {
if (state.relays.timestamp > createdAt) {
return;
}
// filter out non-websocket urls
const filtered = new Map<string, RelaySettings>();
for (const [k, v] of Object.entries(relays)) {
if (k.startsWith("wss://") || k.startsWith("ws://")) {
const url = sanitizeRelayUrl(k);
if (url) {
filtered.set(url, v as RelaySettings);
}
}
}
state.relays.item = Object.fromEntries(filtered.entries());
state.relays.timestamp = createdAt;
LoginStore.updateSession(state);
}
export function removeRelay(state: LoginSession, addr: string) {
delete state.relays.item[addr];
LoginStore.updateSession(state);
}
export function updatePreferences(state: LoginSession, p: UserPreferences) {
state.preferences = p;
LoginStore.updateSession(state);
}
export function logout(k: HexKey) {
LoginStore.removeSession(k);
}
export function markNotificationsRead(state: LoginSession) {
state.readNotifications = unixNowMs();
LoginStore.updateSession(state);
}
export function clearEntropy(state: LoginSession) {
state.generatedEntropy = undefined;
LoginStore.updateSession(state);
}
/**
* Generate a new key and login with this generated key
*/
export async function generateNewLogin() {
const ent = generateBip39Entropy();
const entropy = secp.utils.bytesToHex(ent);
const privateKey = entropyToPrivateKey(ent);
let newRelays: Record<string, RelaySettings> = {};
try {
const rsp = await fetch("https://api.nostr.watch/v1/online");
if (rsp.ok) {
const online: string[] = await rsp.json();
const pickRandom = randomSample(online, 4);
const relayObjects = pickRandom.map(a => [unwrap(sanitizeRelayUrl(a)), { read: true, write: true }]);
newRelays = {
...Object.fromEntries(relayObjects),
...Object.fromEntries(DefaultRelays.entries()),
};
}
} catch (e) {
console.warn(e);
}
const publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(privateKey));
const publisher = new EventPublisher(publicKey, privateKey);
const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey], newRelays);
publisher.broadcast(ev);
LoginStore.loginWithPrivateKey(privateKey, entropy, newRelays);
}
export function generateRandomKey() {
const privateKey = secp.utils.bytesToHex(secp.utils.randomPrivateKey());
const publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(privateKey));
return {
privateKey,
publicKey,
};
}
export function setTags(state: LoginSession, tags: Array<string>, ts: number) {
if (state.tags.timestamp > ts) {
return;
}
state.tags.item = tags;
state.tags.timestamp = ts;
LoginStore.updateSession(state);
}
export function setMuted(state: LoginSession, muted: Array<string>, ts: number) {
if (state.muted.timestamp > ts) {
return;
}
state.muted.item = muted;
state.muted.timestamp = ts;
LoginStore.updateSession(state);
}
export function setBlocked(state: LoginSession, blocked: Array<string>, ts: number) {
if (state.blocked.timestamp > ts) {
return;
}
state.blocked.item = blocked;
state.blocked.timestamp = ts;
LoginStore.updateSession(state);
}
export function setFollows(state: LoginSession, follows: Array<string>, ts: number) {
if (state.follows.timestamp > ts) {
return;
}
state.follows.item = follows;
state.follows.timestamp = ts;
LoginStore.updateSession(state);
}
export function setPinned(state: LoginSession, pinned: Array<string>, ts: number) {
if (state.pinned.timestamp > ts) {
return;
}
state.pinned.item = pinned;
state.pinned.timestamp = ts;
LoginStore.updateSession(state);
}
export function setBookmarked(state: LoginSession, bookmarked: Array<string>, ts: number) {
if (state.bookmarked.timestamp > ts) {
return;
}
state.bookmarked.item = bookmarked;
state.bookmarked.timestamp = ts;
LoginStore.updateSession(state);
}
export function addSubscription(state: LoginSession, ...subs: SubscriptionEvent[]) {
state.subscriptions = dedupeById([...(state.subscriptions || []), ...subs]);
state.currentSubscription = getCurrentSubscription(state.subscriptions);
LoginStore.updateSession(state);
}

View File

@ -0,0 +1,88 @@
import { HexKey, RelaySettings, u256 } from "@snort/nostr";
import { UserPreferences } from "Login";
import { SubscriptionEvent } from "Subscription";
/**
* Stores latest copy of an item
*/
interface Newest<T> {
item: T;
timestamp: number;
}
export interface LoginSession {
/**
* Current user private key
*/
privateKey?: HexKey;
/**
* BIP39-generated, hex-encoded entropy
*/
generatedEntropy?: string;
/**
* Current users public key
*/
publicKey?: HexKey;
/**
* All the logged in users relays
*/
relays: Newest<Record<string, RelaySettings>>;
/**
* A list of pubkeys this user follows
*/
follows: Newest<Array<HexKey>>;
/**
* A list of tags this user follows
*/
tags: Newest<Array<string>>;
/**
* A list of event ids this user has pinned
*/
pinned: Newest<Array<u256>>;
/**
* A list of event ids this user has bookmarked
*/
bookmarked: Newest<Array<u256>>;
/**
* A list of pubkeys this user has muted
*/
muted: Newest<Array<HexKey>>;
/**
* A list of pubkeys this user has muted privately
*/
blocked: Newest<Array<HexKey>>;
/**
* Latest notification
*/
latestNotification: number;
/**
* Timestamp of last read notification
*/
readNotifications: number;
/**
* Users cusom preferences
*/
preferences: UserPreferences;
/**
* Snort subscriptions licences
*/
subscriptions: Array<SubscriptionEvent>;
/**
* Current active subscription
*/
currentSubscription?: SubscriptionEvent;
}

View File

@ -0,0 +1,188 @@
import * as secp from "@noble/secp256k1";
import { HexKey, RelaySettings } from "@snort/nostr";
import { DefaultRelays } from "Const";
import ExternalStore from "ExternalStore";
import { LoginSession } from "Login";
import { deepClone, sanitizeRelayUrl, unwrap } from "Util";
import { DefaultPreferences, UserPreferences } from "./Preferences";
const AccountStoreKey = "sessions";
const LoggedOut = {
preferences: DefaultPreferences,
tags: {
item: [],
timestamp: 0,
},
follows: {
item: [],
timestamp: 0,
},
muted: {
item: [],
timestamp: 0,
},
blocked: {
item: [],
timestamp: 0,
},
bookmarked: {
item: [],
timestamp: 0,
},
pinned: {
item: [],
timestamp: 0,
},
relays: {
item: Object.fromEntries([...DefaultRelays.entries()].map(a => [unwrap(sanitizeRelayUrl(a[0])), a[1]])),
timestamp: 0,
},
latestNotification: 0,
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>;
constructor() {
super();
const existing = window.localStorage.getItem(AccountStoreKey);
if (existing) {
this.#accounts = new Map((JSON.parse(existing) as Array<LoginSession>).map(a => [unwrap(a.publicKey), a]));
} else {
this.#accounts = new Map();
}
this.#migrate();
if (!this.#activeAccount) {
this.#activeAccount = this.#accounts.keys().next().value;
}
}
getSessions() {
return [...this.#accounts.keys()];
}
loginWithPubkey(key: HexKey, relays?: Record<string, RelaySettings>) {
if (this.#accounts.has(key)) {
throw new Error("Already logged in with this pubkey");
}
const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries());
const newSession = {
...LoggedOut,
publicKey: key,
relays: {
item: initRelays,
timestamp: 1,
},
preferences: deepClone(DefaultPreferences),
} as LoginSession;
this.#accounts.set(key, newSession);
this.#activeAccount = key;
this.#save();
return newSession;
}
loginWithPrivateKey(key: HexKey, entropy?: string, relays?: Record<string, RelaySettings>) {
const pubKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(key));
if (this.#accounts.has(pubKey)) {
throw new Error("Already logged in with this pubkey");
}
const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries());
const newSession = {
...LoggedOut,
privateKey: key,
publicKey: pubKey,
generatedEntropy: entropy,
relays: {
item: initRelays,
timestamp: 1,
},
preferences: deepClone(DefaultPreferences),
} as LoginSession;
this.#accounts.set(pubKey, newSession);
this.#activeAccount = pubKey;
this.#save();
return newSession;
}
updateSession(s: LoginSession) {
const pk = unwrap(s.publicKey);
if (this.#accounts.has(pk)) {
this.#accounts.set(pk, s);
this.#save();
}
}
removeSession(k: string) {
if (this.#accounts.delete(k)) {
this.#save();
}
}
takeSnapshot(): LoginSession {
const s = this.#activeAccount ? this.#accounts.get(this.#activeAccount) : undefined;
if (!s) return LoggedOut;
return deepClone(s);
}
#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 = secp.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);
if (didMigrate) {
console.debug("Finished migration to MultiAccountStore");
this.#save();
}
}
#save() {
if (!this.#activeAccount && this.#accounts.size > 0) {
this.#activeAccount = [...this.#accounts.keys()][0];
}
window.localStorage.setItem(AccountStoreKey, JSON.stringify([...this.#accounts.values()]));
this.notifyChange();
}
}

View File

@ -0,0 +1,90 @@
import { DefaultImgProxy } from "Const";
import { ImgProxySettings } from "Hooks/useImgProxy";
export interface UserPreferences {
/**
* User selected language
*/
language?: string;
/**
* Enable reactions / reposts / zaps
*/
enableReactions: boolean;
/**
* Reaction emoji
*/
reactionEmoji: string;
/**
* Automatically load media (show link only) (bandwidth/privacy)
*/
autoLoadMedia: "none" | "follows-only" | "all";
/**
* Select between light/dark theme
*/
theme: "system" | "light" | "dark";
/**
* Ask for confirmation when reposting notes
*/
confirmReposts: boolean;
/**
* Automatically show the latests notes
*/
autoShowLatest: boolean;
/**
* Show debugging menus to help diagnose issues
*/
showDebugMenus: boolean;
/**
* File uploading service to upload attachments to
*/
fileUploader: "void.cat" | "nostr.build" | "nostrimg.com";
/**
* Use imgproxy to optimize images
*/
imgProxyConfig: ImgProxySettings | null;
/**
* Default page to select on load
*/
defaultRootTab: "posts" | "conversations" | "global";
/**
* Default zap amount
*/
defaultZapAmount: number;
/**
* For each fast zap an additional X% will be sent to Snort donate address
*/
fastZapDonate: number;
/**
* Auto-zap every post
*/
autoZap: boolean;
}
export const DefaultPreferences = {
enableReactions: true,
reactionEmoji: "+",
autoLoadMedia: "follows-only",
theme: "system",
confirmReposts: false,
showDebugMenus: false,
autoShowLatest: false,
fileUploader: "void.cat",
imgProxyConfig: DefaultImgProxy,
defaultRootTab: "posts",
defaultZapAmount: 50,
fastZapDonate: 0.0,
autoZap: false,
} as UserPreferences;

View File

@ -0,0 +1,6 @@
import { MultiAccountStore } from "./MultiAccountStore";
export const LoginStore = new MultiAccountStore();
export * from "./Preferences";
export * from "./LoginSession";
export * from "./Functions";

View File

@ -1,5 +1,5 @@
import { EventKind } from "@snort/nostr";
import { EventPublisher } from "Feed/EventPublisher";
import { EventPublisher } from "System/EventPublisher";
import { ServiceError, ServiceProvider } from "./ServiceProvider";
export interface ManageHandle {
@ -48,10 +48,12 @@ export default class SnortServiceProvider extends ServiceProvider {
body?: unknown,
headers?: { [key: string]: string }
): Promise<T | ServiceError> {
const auth = await this.#publisher.generic("", EventKind.HttpAuthentication, [
["url", `${this.url}${path}`],
["method", method ?? "GET"],
]);
const auth = await this.#publisher.generic(eb => {
eb.kind(EventKind.HttpAuthentication);
eb.tag(["url", `${this.url}${path}`]);
eb.tag(["method", method ?? "GET"]);
return eb;
});
if (!auth) {
return {
error: "INVALID_TOKEN",

View File

@ -2,12 +2,19 @@ import Nostrich from "nostrich.webp";
import { TaggedRawEvent } from "@snort/nostr";
import { EventKind } from "@snort/nostr";
import type { NotificationRequest } from "State/Login";
import { MetadataCache } from "Cache";
import { getDisplayName } from "Element/ProfileImage";
import { MentionRegex } from "Const";
import { tagFilterOfTextRepost, unwrap } from "Util";
import { UserCache } from "Cache/UserCache";
import { LoginSession } from "Login";
export interface NotificationRequest {
title: string;
body: string;
icon: string;
timestamp: number;
}
export async function makeNotification(ev: TaggedRawEvent): Promise<NotificationRequest | null> {
switch (ev.kind) {
@ -52,3 +59,20 @@ function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) {
})
.join();
}
export async function sendNotification(state: LoginSession, req: NotificationRequest) {
const hasPermission = "Notification" in window && Notification.permission === "granted";
const shouldShowNotification = hasPermission && req.timestamp > state.readNotifications;
if (shouldShowNotification) {
try {
const worker = await navigator.serviceWorker.ready;
worker.showNotification(req.title, {
tag: "notification",
vibrate: [500],
...req,
});
} catch (error) {
console.warn(error);
}
}
}

View File

@ -1,19 +1,17 @@
import "./ChatPage.css";
import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { useParams } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { RawEvent, TaggedRawEvent } from "@snort/nostr";
import ProfileImage from "Element/ProfileImage";
import { bech32ToHex } from "Util";
import useEventPublisher from "Feed/EventPublisher";
import DM from "Element/DM";
import { RawEvent, TaggedRawEvent } from "@snort/nostr";
import { dmsInChat, isToSelf } from "Pages/MessagesPage";
import NoteToSelf from "Element/NoteToSelf";
import { RootState } from "State/Store";
import { FormattedMessage } from "react-intl";
import { useDmCache } from "Hooks/useDmsCache";
import useLogin from "Hooks/useLogin";
type RouterParams = {
id: string;
@ -23,7 +21,7 @@ export default function ChatPage() {
const params = useParams<RouterParams>();
const publisher = useEventPublisher();
const id = bech32ToHex(params.id ?? "");
const pubKey = useSelector((s: RootState) => s.login.publicKey);
const pubKey = useLogin().publicKey;
const [content, setContent] = useState<string>();
const dmListRef = useRef<HTMLDivElement>(null);
const dms = filterDms(useDmCache());
@ -43,9 +41,8 @@ export default function ChatPage() {
}, [dmListRef.current?.scrollHeight]);
async function sendDm() {
if (content) {
if (content && publisher) {
const ev = await publisher.sendDm(content, id);
console.debug(ev);
publisher.broadcast(ev);
setContent("");
}

View File

@ -1,30 +1,27 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { useSelector, useDispatch } from "react-redux";
import Timeline from "Element/Timeline";
import useEventPublisher from "Feed/EventPublisher";
import { setTags } from "State/Login";
import type { RootState } from "State/Store";
import useLogin from "Hooks/useLogin";
import { setTags } from "Login";
const HashTagsPage = () => {
const params = useParams();
const tag = (params.tag ?? "").toLowerCase();
const dispatch = useDispatch();
const { tags } = useSelector((s: RootState) => s.login);
const login = useLogin();
const isFollowing = useMemo(() => {
return tags.includes(tag);
}, [tags, tag]);
return login.tags.item.includes(tag);
}, [login, tag]);
const publisher = useEventPublisher();
function followTags(ts: string[]) {
dispatch(
setTags({
tags: ts,
createdAt: new Date().getTime(),
})
);
publisher.tags(ts).then(ev => publisher.broadcast(ev));
async function followTags(ts: string[]) {
if (publisher) {
const ev = await publisher.tags(ts);
publisher.broadcast(ev);
setTags(login, ts, ev.created_at * 1000);
}
}
return (
@ -33,11 +30,14 @@ const HashTagsPage = () => {
<div className="action-heading">
<h2>#{tag}</h2>
{isFollowing ? (
<button type="button" className="secondary" onClick={() => followTags(tags.filter(t => t !== tag))}>
<button
type="button"
className="secondary"
onClick={() => followTags(login.tags.item.filter(t => t !== tag))}>
<FormattedMessage defaultMessage="Unfollow" />
</button>
) : (
<button type="button" onClick={() => followTags(tags.concat([tag]))}>
<button type="button" onClick={() => followTags(login.tags.item.concat([tag]))}>
<FormattedMessage defaultMessage="Follow" />
</button>
)}

View File

@ -18,8 +18,10 @@ header {
padding: 4px 12px;
}
header .pfp .avatar-wrapper {
margin-right: 0;
.header-actions .avatar {
width: 48px;
height: 48px;
cursor: pointer;
}
.header-actions {
@ -28,11 +30,6 @@ header .pfp .avatar-wrapper {
align-items: center;
}
.header-actions .avatar {
width: 40px;
height: 40px;
}
.header-actions .btn-rnd {
position: relative;
margin-right: 8px;

View File

@ -4,27 +4,26 @@ import { useDispatch, useSelector } from "react-redux";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { RelaySettings } from "@snort/nostr";
import messages from "./messages";
import { bech32ToHex, randomSample, unixNowMs, unwrap } from "Util";
import Icon from "Icons/Icon";
import { RootState } from "State/Store";
import { init, setRelays } from "State/Login";
import { setShow, reset } from "State/NoteCreator";
import { System } from "System";
import ProfileImage from "Element/ProfileImage";
import useLoginFeed from "Feed/LoginFeed";
import { totalUnread } from "Pages/MessagesPage";
import useModeration from "Hooks/useModeration";
import { NoteCreator } from "Element/NoteCreator";
import { db } from "Db";
import useEventPublisher from "Feed/EventPublisher";
import { DefaultRelays, SnortPubKey } from "Const";
import SubDebug from "Element/SubDebug";
import { preload } from "Cache";
import { useDmCache } from "Hooks/useDmsCache";
import { mapPlanName } from "./subscribe";
import useLogin from "Hooks/useLogin";
import Avatar from "Element/Avatar";
import { useUserProfile } from "Hooks/useUserProfile";
import { profileLink } from "Util";
export default function Layout() {
const location = useLocation();
@ -33,9 +32,7 @@ export default function Layout() {
const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing;
const dispatch = useDispatch();
const navigate = useNavigate();
const { loggedOut, publicKey, relays, preferences, newUserKey, subscription } = useSelector(
(s: RootState) => s.login
);
const { publicKey, relays, preferences, currentSubscription } = useLogin();
const [pageClass, setPageClass] = useState("page");
const pub = useEventPublisher();
useLoginFeed();
@ -66,17 +63,19 @@ export default function Layout() {
}, [location]);
useEffect(() => {
System.HandleAuth = pub.nip42Auth;
if (pub) {
System.HandleAuth = pub.nip42Auth;
}
}, [pub]);
useEffect(() => {
if (relays) {
(async () => {
for (const [k, v] of Object.entries(relays)) {
for (const [k, v] of Object.entries(relays.item)) {
await System.ConnectToRelay(k, v);
}
for (const [k, c] of System.Sockets) {
if (!relays[k] && !c.Ephemeral) {
if (!relays.item[k] && !c.Ephemeral) {
System.DisconnectRelay(k);
}
}
@ -117,7 +116,6 @@ export default function Layout() {
await preload();
}
console.debug(`Using db: ${a ? "IndexedDB" : "In-Memory"}`);
dispatch(init());
try {
if ("registerProtocolHandler" in window.navigator) {
@ -133,53 +131,16 @@ export default function Layout() {
});
}, []);
async function handleNewUser() {
let newRelays: Record<string, RelaySettings> = {};
try {
const rsp = await fetch("https://api.nostr.watch/v1/online");
if (rsp.ok) {
const online: string[] = await rsp.json();
const pickRandom = randomSample(online, 4);
const relayObjects = pickRandom.map(a => [a, { read: true, write: true }]);
newRelays = {
...Object.fromEntries(relayObjects),
...Object.fromEntries(DefaultRelays.entries()),
};
dispatch(
setRelays({
relays: newRelays,
createdAt: unixNowMs(),
})
);
}
} catch (e) {
console.warn(e);
}
const ev = await pub.addFollow([bech32ToHex(SnortPubKey), unwrap(publicKey)], newRelays);
pub.broadcast(ev);
}
useEffect(() => {
if (newUserKey === true) {
handleNewUser().catch(console.warn);
}
}, [newUserKey]);
if (typeof loggedOut !== "boolean") {
return null;
}
return (
<div className={pageClass}>
{!shouldHideHeader && (
<header>
<div className="logo" onClick={() => navigate("/")}>
<h1>Snort</h1>
{subscription && (
{currentSubscription && (
<small className="flex">
<Icon name="diamond" size={10} className="mr5" />
{mapPlanName(subscription.type)}
{mapPlanName(currentSubscription.type)}
</small>
)}
</div>
@ -214,8 +175,9 @@ const AccountHeader = () => {
const navigate = useNavigate();
const { isMuted } = useModeration();
const { publicKey, latestNotification, readNotifications } = useSelector((s: RootState) => s.login);
const { publicKey, latestNotification, readNotifications } = useLogin();
const dms = useDmCache();
const profile = useUserProfile(publicKey);
const hasNotifications = useMemo(
() => latestNotification > readNotifications,
@ -251,7 +213,7 @@ const AccountHeader = () => {
return (
<div className="header-actions">
<div className="btn btn-rnd" onClick={() => navigate("/wallet")}>
<Icon name="bitcoin" />
<Icon name="wallet" />
</div>
<div className="btn btn-rnd" onClick={() => navigate("/search")}>
<Icon name="search" />
@ -264,7 +226,14 @@ const AccountHeader = () => {
<Icon name="bell" />
{hasNotifications && <span className="has-unread"></span>}
</div>
<ProfileImage pubkey={publicKey || ""} showUsername={false} />
<Avatar
user={profile}
onClick={() => {
if (profile) {
navigate(profileLink(profile.pubkey));
}
}}
/>
</div>
);
};

View File

@ -1,22 +1,22 @@
import "./Login.css";
import { CSSProperties, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import * as secp from "@noble/secp256k1";
import { useIntl, FormattedMessage } from "react-intl";
import { HexKey } from "@snort/nostr";
import { RootState } from "State/Store";
import { setPrivateKey, setPublicKey, setRelays, setGeneratedPrivateKey } from "State/Login";
import { DefaultRelays, EmailRegex, MnemonicRegex } from "Const";
import { EmailRegex, MnemonicRegex } from "Const";
import { bech32ToHex, unwrap } from "Util";
import { generateBip39Entropy, entropyToDerivedKey } from "nip6";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import ZapButton from "Element/ZapButton";
import useImgProxy from "Hooks/useImgProxy";
import Icon from "Icons/Icon";
import useLogin from "Hooks/useLogin";
import { generateNewLogin, LoginStore } from "Login";
import AsyncButton from "Element/AsyncButton";
import messages from "./messages";
import Icon from "Icons/Icon";
interface ArtworkEntry {
name: string;
@ -24,26 +24,28 @@ interface ArtworkEntry {
link: string;
}
const KarnageKey = bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac");
// todo: fill more
const Artwork: Array<ArtworkEntry> = [
{
name: "",
pubkey: bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"),
pubkey: KarnageKey,
link: "https://void.cat/d/VKhPayp9ekeXYZGzAL9CxP",
},
{
name: "",
pubkey: bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"),
pubkey: KarnageKey,
link: "https://void.cat/d/3H2h8xxc3aEN6EVeobd8tw",
},
{
name: "",
pubkey: bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"),
pubkey: KarnageKey,
link: "https://void.cat/d/7i9W9PXn3TV86C4RUefNC9",
},
{
name: "",
pubkey: bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"),
pubkey: KarnageKey,
link: "https://void.cat/d/KtoX4ei6RYHY7HESg3Ve3k",
},
];
@ -64,9 +66,8 @@ export async function getNip05PubKey(addr: string): Promise<string> {
}
export default function LoginPage() {
const dispatch = useDispatch();
const navigate = useNavigate();
const publicKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const login = useLogin();
const [key, setKey] = useState("");
const [error, setError] = useState("");
const [art, setArt] = useState<ArtworkEntry>();
@ -77,10 +78,10 @@ export default function LoginPage() {
const hasSubtleCrypto = window.crypto.subtle !== undefined;
useEffect(() => {
if (publicKey) {
if (login.publicKey) {
navigate("/");
}
}, [publicKey, navigate]);
}, [login, navigate]);
useEffect(() => {
const ret = unwrap(Artwork.at(Artwork.length * Math.random()));
@ -99,28 +100,28 @@ export default function LoginPage() {
}
const hexKey = bech32ToHex(key);
if (secp.utils.isValidPrivateKey(hexKey)) {
dispatch(setPrivateKey(hexKey));
LoginStore.loginWithPrivateKey(hexKey);
} else {
throw new Error("INVALID PRIVATE KEY");
}
} else if (key.startsWith("npub")) {
const hexKey = bech32ToHex(key);
dispatch(setPublicKey(hexKey));
LoginStore.loginWithPubkey(hexKey);
} else if (key.match(EmailRegex)) {
const hexKey = await getNip05PubKey(key);
dispatch(setPublicKey(hexKey));
LoginStore.loginWithPubkey(hexKey);
} else if (key.match(MnemonicRegex)) {
if (!hasSubtleCrypto) {
throw new Error(insecureMsg);
}
const ent = generateBip39Entropy(key);
const keyHex = entropyToDerivedKey(ent);
dispatch(setPrivateKey(keyHex));
const keyHex = entropyToPrivateKey(ent);
LoginStore.loginWithPrivateKey(keyHex);
} else if (secp.utils.isValidPrivateKey(key)) {
if (!hasSubtleCrypto) {
throw new Error(insecureMsg);
}
dispatch(setPrivateKey(key));
LoginStore.loginWithPrivateKey(key);
} else {
throw new Error("INVALID PRIVATE KEY");
}
@ -139,29 +140,14 @@ export default function LoginPage() {
}
async function makeRandomKey() {
const ent = generateBip39Entropy();
const entHex = secp.utils.bytesToHex(ent);
const newKeyHex = entropyToDerivedKey(ent);
dispatch(setGeneratedPrivateKey({ key: newKeyHex, entropy: entHex }));
await generateNewLogin();
navigate("/new");
}
async function doNip07Login() {
const relays = "getRelays" in window.nostr ? await window.nostr.getRelays() : undefined;
const pubKey = await window.nostr.getPublicKey();
dispatch(setPublicKey(pubKey));
if ("getRelays" in window.nostr) {
const relays = await window.nostr.getRelays();
dispatch(
setRelays({
relays: {
...relays,
...Object.fromEntries(DefaultRelays.entries()),
},
createdAt: 1,
})
);
}
LoginStore.loginWithPubkey(pubKey, relays);
}
function altLogins() {
@ -198,9 +184,9 @@ export default function LoginPage() {
/>
</p>
<div className="login-actions">
<button type="button" onClick={() => makeRandomKey()}>
<AsyncButton onClick={() => makeRandomKey()}>
<FormattedMessage defaultMessage="Generate Key" description="Button: Generate a new key" />
</button>
</AsyncButton>
</div>
</>
);

View File

@ -1,18 +1,16 @@
import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
import { HexKey, RawEvent } from "@snort/nostr";
import UnreadCount from "Element/UnreadCount";
import ProfileImage from "Element/ProfileImage";
import { hexToBech32 } from "../Util";
import { incDmInteraction } from "State/Login";
import { RootState } from "State/Store";
import { hexToBech32 } from "Util";
import NoteToSelf from "Element/NoteToSelf";
import useModeration from "Hooks/useModeration";
import { useDmCache } from "Hooks/useDmsCache";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
import { useDmCache } from "Hooks/useDmsCache";
type DmChat = {
pubkey: HexKey;
@ -21,18 +19,19 @@ type DmChat = {
};
export default function MessagesPage() {
const dispatch = useDispatch();
const myPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const dmInteraction = useSelector<RootState, number>(s => s.login.dmInteraction);
const login = useLogin();
const { isMuted } = useModeration();
const dms = useDmCache();
const chats = useMemo(() => {
return extractChats(
dms.filter(a => !isMuted(a.pubkey)),
myPubKey ?? ""
);
}, [dms, myPubKey, dmInteraction]);
if (login.publicKey) {
return extractChats(
dms.filter(a => !isMuted(a.pubkey)),
login.publicKey
);
}
return [];
}, [dms, login]);
const unreadCount = useMemo(() => chats.reduce((p, c) => p + c.unreadMessages, 0), [chats]);
@ -50,7 +49,7 @@ export default function MessagesPage() {
}
function person(chat: DmChat) {
if (chat.pubkey === myPubKey) return noteToSelf(chat);
if (chat.pubkey === login.publicKey) return noteToSelf(chat);
return (
<div className="flex mb10" key={chat.pubkey}>
<ProfileImage pubkey={chat.pubkey} className="f-grow" link={`/messages/${hexToBech32("npub", chat.pubkey)}`} />
@ -63,7 +62,6 @@ export default function MessagesPage() {
for (const c of chats) {
setLastReadDm(c.pubkey);
}
dispatch(incDmInteraction());
}
return (
@ -78,7 +76,11 @@ export default function MessagesPage() {
</div>
{chats
.sort((a, b) => {
return a.pubkey === myPubKey ? -1 : b.pubkey === myPubKey ? 1 : b.newestMessage - a.newestMessage;
return a.pubkey === login.publicKey
? -1
: b.pubkey === login.publicKey
? 1
: b.newestMessage - a.newestMessage;
})
.map(person)}
</div>

View File

@ -1,32 +1,25 @@
import { NostrPrefix } from "@snort/nostr";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import Spinner from "Icons/Spinner";
import { setRelays } from "State/Login";
import { parseNostrLink, profileLink, unixNowMs, unwrap } from "Util";
import { parseNostrLink, profileLink } from "Util";
import { getNip05PubKey } from "Pages/Login";
import { System } from "System";
export default function NostrLinkHandler() {
const params = useParams();
const [loading, setLoading] = useState(true);
const dispatch = useDispatch();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const link = decodeURIComponent(params["*"] ?? "").toLowerCase();
async function handleLink(link: string) {
const nav = parseNostrLink(link);
if (nav) {
if ((nav.relays?.length ?? 0) > 0) {
// todo: add as ephemerial connection
dispatch(
setRelays({
relays: Object.fromEntries(unwrap(nav.relays).map(a => [a, { read: true, write: false }])),
createdAt: unixNowMs(),
})
);
nav.relays?.map(a => System.ConnectEphemeralRelay(a));
}
if (nav.type === NostrPrefix.Event || nav.type === NostrPrefix.Note || nav.type === NostrPrefix.Address) {
navigate(`/e/${nav.encode()}`);

View File

@ -1,17 +1,15 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { HexKey } from "@snort/nostr";
import { markNotificationsRead } from "State/Login";
import { RootState } from "State/Store";
import Timeline from "Element/Timeline";
import { TaskList } from "Tasks/TaskList";
import useLogin from "Hooks/useLogin";
import { markNotificationsRead } from "Login";
export default function NotificationsPage() {
const dispatch = useDispatch();
const pubkey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const login = useLogin();
useEffect(() => {
dispatch(markNotificationsRead());
markNotificationsRead(login);
}, []);
return (
@ -19,12 +17,12 @@ export default function NotificationsPage() {
<div className="main-content">
<TaskList />
</div>
{pubkey && (
{login.publicKey && (
<Timeline
subject={{
type: "ptag",
items: [pubkey],
discriminator: pubkey.slice(0, 12),
items: [login.publicKey],
discriminator: login.publicKey.slice(0, 12),
}}
postsOnly={false}
method={"TIME_RANGE"}

View File

@ -1,7 +1,6 @@
import "./ProfilePage.css";
import { useEffect, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import { encodeTLV, EventKind, HexKey, NostrPrefix } from "@snort/nostr";
@ -36,7 +35,6 @@ import BlockList from "Element/BlockList";
import MutedList from "Element/MutedList";
import FollowsList from "Element/FollowListBase";
import IconButton from "Element/IconButton";
import { RootState } from "State/Store";
import FollowsYou from "Element/FollowsYou";
import QrCode from "Element/QrCode";
import Modal from "Element/Modal";
@ -46,6 +44,7 @@ import useHorizontalScroll from "Hooks/useHorizontalScroll";
import { EmailRegex } from "Const";
import { getNip05PubKey } from "Pages/Login";
import { LNURL } from "LNURL";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
@ -106,7 +105,7 @@ export default function ProfilePage() {
const navigate = useNavigate();
const [id, setId] = useState<string>();
const user = useUserProfile(id);
const loginPubKey = useSelector((s: RootState) => s.login.publicKey);
const loginPubKey = useLogin().publicKey;
const isMe = loginPubKey === id;
const [showLnQr, setShowLnQr] = useState<boolean>(false);
const [showProfileQr, setShowProfileQr] = useState<boolean>(false);

View File

@ -1,15 +1,14 @@
import "./Root.css";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { Link, Outlet, RouteObject, useLocation, useNavigate, useParams } from "react-router-dom";
import { useIntl, FormattedMessage } from "react-intl";
import Tabs, { Tab } from "Element/Tabs";
import { RootState } from "State/Store";
import Timeline from "Element/Timeline";
import { System } from "System";
import { TimelineSubject } from "Feed/TimelineFeed";
import { debounce, getRelayName, sha256, unixNow, unwrap } from "Util";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
@ -22,7 +21,7 @@ export default function RootPage() {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const location = useLocation();
const { publicKey: pubKey, tags, preferences } = useSelector((s: RootState) => s.login);
const { publicKey: pubKey, tags, preferences } = useLogin();
const RootTab: Record<string, Tab> = {
Posts: {
@ -65,7 +64,7 @@ export default function RootPage() {
}
}, [location]);
const tagTabs = tags.map((t, idx) => {
const tagTabs = tags.item.map((t, idx) => {
return { text: `#${t}`, value: idx + 3, data: `/tag/${t}` };
});
const tabs = [RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global, ...tagTabs];
@ -81,8 +80,8 @@ export default function RootPage() {
}
const FollowsHint = () => {
const { publicKey: pubKey, follows } = useSelector((s: RootState) => s.login);
if (follows?.length === 0 && pubKey) {
const { publicKey: pubKey, follows } = useLogin();
if (follows.item?.length === 0 && pubKey) {
return (
<FormattedMessage
{...messages.NoFollows}
@ -100,7 +99,7 @@ const FollowsHint = () => {
};
const GlobalTab = () => {
const { relays } = useSelector((s: RootState) => s.login);
const { relays } = useLogin();
const [relay, setRelay] = useState<RelayOption>();
const [allRelays, setAllRelays] = useState<RelayOption[]>();
const [now] = useState(unixNow());
@ -177,8 +176,8 @@ const GlobalTab = () => {
};
const PostsTab = () => {
const follows = useSelector((s: RootState) => s.login.follows);
const subject: TimelineSubject = { type: "pubkey", items: follows, discriminator: "follows" };
const { follows } = useLogin();
const subject: TimelineSubject = { type: "pubkey", items: follows.item, discriminator: "follows" };
return (
<>
@ -189,8 +188,8 @@ const PostsTab = () => {
};
const ConversationsTab = () => {
const { follows } = useSelector((s: RootState) => s.login);
const subject: TimelineSubject = { type: "pubkey", items: follows, discriminator: "follows" };
const { follows } = useLogin();
const subject: TimelineSubject = { type: "pubkey", items: follows.item, discriminator: "follows" };
return <Timeline subject={subject} postsOnly={false} method={"TIME_RANGE"} window={undefined} relay={undefined} />;
};

View File

@ -1,24 +1,25 @@
import { useMemo } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useDispatch } from "react-redux";
import { useNavigate, Link } from "react-router-dom";
import { RecommendedFollows } from "Const";
import Logo from "Element/Logo";
import FollowListBase from "Element/FollowListBase";
import { useMemo } from "react";
import { clearEntropy } from "State/Login";
import { clearEntropy } from "Login";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
export default function DiscoverFollows() {
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const login = useLogin();
const navigate = useNavigate();
const sortedReccomends = useMemo(() => {
return RecommendedFollows.sort(() => (Math.random() >= 0.5 ? -1 : 1)).map(a => a.toLowerCase());
}, []);
async function clearEntropyAndGo() {
dispatch(clearEntropy());
clearEntropy(login);
navigate("/");
}

View File

@ -1,20 +1,19 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import Logo from "Element/Logo";
import { services } from "Pages/Verification";
import Nip5Service from "Element/Nip5Service";
import ProfileImage from "Element/ProfileImage";
import type { RootState } from "State/Store";
import { useUserProfile } from "Hooks/useUserProfile";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
export default function GetVerified() {
const navigate = useNavigate();
const { publicKey } = useSelector((s: RootState) => s.login);
const { publicKey } = useLogin();
const user = useUserProfile(publicKey);
const [isVerified, setIsVerified] = useState(false);
const name = user?.name || "nostrich";

View File

@ -1,5 +1,4 @@
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { useIntl, FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
@ -7,15 +6,15 @@ import { ApiHost } from "Const";
import Logo from "Element/Logo";
import AsyncButton from "Element/AsyncButton";
import FollowListBase from "Element/FollowListBase";
import { RootState } from "State/Store";
import { bech32ToHex } from "Util";
import SnortApi from "SnortApi";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
export default function ImportFollows() {
const navigate = useNavigate();
const currentFollows = useSelector((s: RootState) => s.login.follows);
const currentFollows = useLogin().follows;
const { formatMessage } = useIntl();
const [twitterUsername, setTwitterUsername] = useState<string>("");
const [follows, setFollows] = useState<string[]>([]);
@ -23,7 +22,7 @@ export default function ImportFollows() {
const api = new SnortApi(ApiHost);
const sortedTwitterFollows = useMemo(() => {
return follows.map(a => bech32ToHex(a)).sort(a => (currentFollows.includes(a) ? 1 : -1));
return follows.map(a => bech32ToHex(a)).sort(a => (currentFollows.item.includes(a) ? 1 : -1));
}, [follows, currentFollows]);
async function loadFollows() {

View File

@ -1,13 +1,12 @@
import { useSelector } from "react-redux";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import Logo from "Element/Logo";
import { CollapsedSection } from "Element/Collapsed";
import Copy from "Element/Copy";
import { RootState } from "State/Store";
import { hexToBech32 } from "Util";
import { hexToMnemonic } from "nip6";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
@ -69,7 +68,7 @@ const Extensions = () => {
};
export default function NewUserFlow() {
const { publicKey, privateKey, generatedEntropy } = useSelector((s: RootState) => s.login);
const { publicKey, generatedEntropy } = useLogin();
const navigate = useNavigate();
return (
@ -88,10 +87,6 @@ export default function NewUserFlow() {
<FormattedMessage {...messages.YourPubkey} />
</h2>
<Copy text={hexToBech32("npub", publicKey ?? "")} />
<h2>
<FormattedMessage {...messages.YourPrivkey} />
</h2>
<Copy text={hexToBech32("nsec", privateKey ?? "")} />
<h2>
<FormattedMessage {...messages.YourMnemonic} />
</h2>

View File

@ -14,9 +14,8 @@ export default function NewUserName() {
const navigate = useNavigate();
const onNext = async () => {
if (username.length > 0) {
if (username.length > 0 && publisher) {
const ev = await publisher.metadata({ name: username });
console.debug(ev);
publisher.broadcast(ev);
}
navigate("/new/verify");

View File

@ -1,22 +1,20 @@
import "./Index.css";
import { FormattedMessage } from "react-intl";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import Icon from "Icons/Icon";
import { logout } from "State/Login";
import { logout } from "Login";
import useLogin from "Hooks/useLogin";
import { unwrap } from "Util";
import messages from "./messages";
const SettingsIndex = () => {
const dispatch = useDispatch();
const login = useLogin();
const navigate = useNavigate();
function handleLogout() {
dispatch(
logout(() => {
navigate("/");
})
);
logout(unwrap(login.publicKey));
navigate("/");
}
return (
@ -38,7 +36,7 @@ const SettingsIndex = () => {
<Icon name="arrowFront" />
</div>
<div className="settings-row" onClick={() => navigate("wallet")}>
<Icon name="bitcoin" />
<Icon name="wallet" />
<FormattedMessage defaultMessage="Wallet" />
<Icon name="arrowFront" />
</div>

View File

@ -1,20 +1,20 @@
import "./Preferences.css";
import { useDispatch, useSelector } from "react-redux";
import { FormattedMessage, useIntl } from "react-intl";
import { Link } from "react-router-dom";
import emoji from "@jukben/emoji-search";
import { DefaultImgProxy, setPreferences, UserPreferences } from "State/Login";
import { RootState } from "State/Store";
import useLogin from "Hooks/useLogin";
import { updatePreferences, UserPreferences } from "Login";
import { DefaultImgProxy } from "Const";
import { unwrap } from "Util";
import messages from "./messages";
const PreferencesPage = () => {
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const perf = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const login = useLogin();
const perf = login.preferences;
return (
<div className="preferences">
@ -32,12 +32,10 @@ const PreferencesPage = () => {
<select
value={perf.language}
onChange={e =>
dispatch(
setPreferences({
...perf,
language: e.target.value,
} as UserPreferences)
)
updatePreferences(login, {
...perf,
language: e.target.value,
})
}
style={{ textTransform: "capitalize" }}>
{["en", "ja", "es", "hu", "zh-CN", "zh-TW", "fr", "ar", "it", "id", "de", "ru", "sv", "hr"]
@ -62,12 +60,10 @@ const PreferencesPage = () => {
<select
value={perf.theme}
onChange={e =>
dispatch(
setPreferences({
...perf,
theme: e.target.value,
} as UserPreferences)
)
updatePreferences(login, {
...perf,
theme: e.target.value,
} as UserPreferences)
}>
<option value="system">
<FormattedMessage {...messages.System} />
@ -91,12 +87,10 @@ const PreferencesPage = () => {
<select
value={perf.defaultRootTab}
onChange={e =>
dispatch(
setPreferences({
...perf,
defaultRootTab: e.target.value,
} as UserPreferences)
)
updatePreferences(login, {
...perf,
defaultRootTab: e.target.value,
} as UserPreferences)
}>
<option value="posts">
<FormattedMessage {...messages.Posts} />
@ -122,12 +116,10 @@ const PreferencesPage = () => {
<select
value={perf.autoLoadMedia}
onChange={e =>
dispatch(
setPreferences({
...perf,
autoLoadMedia: e.target.value,
} as UserPreferences)
)
updatePreferences(login, {
...perf,
autoLoadMedia: e.target.value,
} as UserPreferences)
}>
<option value="none">
<FormattedMessage {...messages.None} />
@ -153,7 +145,7 @@ const PreferencesPage = () => {
type="number"
defaultValue={perf.defaultZapAmount}
min={1}
onChange={e => dispatch(setPreferences({ ...perf, defaultZapAmount: parseInt(e.target.value || "0") }))}
onChange={e => updatePreferences(login, { ...perf, defaultZapAmount: parseInt(e.target.value || "0") })}
/>
</div>
</div>
@ -189,7 +181,7 @@ const PreferencesPage = () => {
defaultValue={perf.fastZapDonate * 100}
min={0}
max={100}
onChange={e => dispatch(setPreferences({ ...perf, fastZapDonate: parseInt(e.target.value || "0") / 100 }))}
onChange={e => updatePreferences(login, { ...perf, fastZapDonate: parseInt(e.target.value || "0") / 100 })}
/>
</div>
</div>
@ -206,7 +198,7 @@ const PreferencesPage = () => {
<input
type="checkbox"
checked={perf.autoZap}
onChange={e => dispatch(setPreferences({ ...perf, autoZap: e.target.checked }))}
onChange={e => updatePreferences(login, { ...perf, autoZap: e.target.checked })}
/>
</div>
</div>
@ -225,12 +217,10 @@ const PreferencesPage = () => {
type="checkbox"
checked={perf.imgProxyConfig !== null}
onChange={e =>
dispatch(
setPreferences({
...perf,
imgProxyConfig: e.target.checked ? DefaultImgProxy : null,
})
)
updatePreferences(login, {
...perf,
imgProxyConfig: e.target.checked ? DefaultImgProxy : null,
})
}
/>
</div>
@ -250,15 +240,13 @@ const PreferencesPage = () => {
description: "Placeholder text for imgproxy url textbox",
})}
onChange={e =>
dispatch(
setPreferences({
...perf,
imgProxyConfig: {
...unwrap(perf.imgProxyConfig),
url: e.target.value,
},
})
)
updatePreferences(login, {
...perf,
imgProxyConfig: {
...unwrap(perf.imgProxyConfig),
url: e.target.value,
},
})
}
/>
</div>
@ -276,15 +264,13 @@ const PreferencesPage = () => {
description: "Hexidecimal 'key' input for improxy",
})}
onChange={e =>
dispatch(
setPreferences({
...perf,
imgProxyConfig: {
...unwrap(perf.imgProxyConfig),
key: e.target.value,
},
})
)
updatePreferences(login, {
...perf,
imgProxyConfig: {
...unwrap(perf.imgProxyConfig),
key: e.target.value,
},
})
}
/>
</div>
@ -302,15 +288,13 @@ const PreferencesPage = () => {
description: "Hexidecimal 'salt' input for imgproxy",
})}
onChange={e =>
dispatch(
setPreferences({
...perf,
imgProxyConfig: {
...unwrap(perf.imgProxyConfig),
salt: e.target.value,
},
})
)
updatePreferences(login, {
...perf,
imgProxyConfig: {
...unwrap(perf.imgProxyConfig),
salt: e.target.value,
},
})
}
/>
</div>
@ -331,7 +315,7 @@ const PreferencesPage = () => {
<input
type="checkbox"
checked={perf.enableReactions}
onChange={e => dispatch(setPreferences({ ...perf, enableReactions: e.target.checked }))}
onChange={e => updatePreferences(login, { ...perf, enableReactions: e.target.checked })}
/>
</div>
</div>
@ -348,12 +332,10 @@ const PreferencesPage = () => {
className="emoji-selector"
value={perf.reactionEmoji}
onChange={e =>
dispatch(
setPreferences({
...perf,
reactionEmoji: e.target.value,
} as UserPreferences)
)
updatePreferences(login, {
...perf,
reactionEmoji: e.target.value,
})
}>
<option value="+">
+ <FormattedMessage {...messages.Default} />
@ -382,7 +364,7 @@ const PreferencesPage = () => {
<input
type="checkbox"
checked={perf.confirmReposts}
onChange={e => dispatch(setPreferences({ ...perf, confirmReposts: e.target.checked }))}
onChange={e => updatePreferences(login, { ...perf, confirmReposts: e.target.checked })}
/>
</div>
</div>
@ -399,7 +381,7 @@ const PreferencesPage = () => {
<input
type="checkbox"
checked={perf.autoShowLatest}
onChange={e => dispatch(setPreferences({ ...perf, autoShowLatest: e.target.checked }))}
onChange={e => updatePreferences(login, { ...perf, autoShowLatest: e.target.checked })}
/>
</div>
</div>
@ -415,12 +397,10 @@ const PreferencesPage = () => {
<select
value={perf.fileUploader}
onChange={e =>
dispatch(
setPreferences({
...perf,
fileUploader: e.target.value,
} as UserPreferences)
)
updatePreferences(login, {
...perf,
fileUploader: e.target.value,
} as UserPreferences)
}>
<option value="void.cat">
void.cat <FormattedMessage {...messages.Default} />
@ -444,7 +424,7 @@ const PreferencesPage = () => {
<input
type="checkbox"
checked={perf.showDebugMenus}
onChange={e => dispatch(setPreferences({ ...perf, showDebugMenus: e.target.checked }))}
onChange={e => updatePreferences(login, { ...perf, showDebugMenus: e.target.checked })}
/>
</div>
</div>

View File

@ -2,22 +2,21 @@ import "./Profile.css";
import Nostrich from "nostrich.webp";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faShop } from "@fortawesome/free-solid-svg-icons";
import { HexKey, TaggedRawEvent } from "@snort/nostr";
import { TaggedRawEvent } from "@snort/nostr";
import useEventPublisher from "Feed/EventPublisher";
import { useUserProfile } from "Hooks/useUserProfile";
import { hexToBech32, openFile } from "Util";
import Copy from "Element/Copy";
import { RootState } from "State/Store";
import useFileUpload from "Upload";
import messages from "./messages";
import AsyncButton from "Element/AsyncButton";
import { mapEventToProfile, UserCache } from "Cache";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
export interface ProfileSettingsProps {
avatar?: boolean;
@ -27,8 +26,7 @@ export interface ProfileSettingsProps {
export default function ProfileSettings(props: ProfileSettingsProps) {
const navigate = useNavigate();
const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const { publicKey: id, privateKey: privKey } = useLogin();
const user = useUserProfile(id ?? "");
const publisher = useEventPublisher();
const uploader = useFileUpload();
@ -78,13 +76,14 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
delete userCopy["zapService"];
console.debug(userCopy);
const ev = await publisher.metadata(userCopy);
console.debug(ev);
publisher.broadcast(ev);
if (publisher) {
const ev = await publisher.metadata(userCopy);
publisher.broadcast(ev);
const newProfile = mapEventToProfile(ev as TaggedRawEvent);
if (newProfile) {
await UserCache.set(newProfile);
const newProfile = mapEventToProfile(ev as TaggedRawEvent);
if (newProfile) {
await UserCache.set(newProfile);
}
}
}

View File

@ -1,18 +1,18 @@
import { FormattedMessage } from "react-intl";
import ProfilePreview from "Element/ProfilePreview";
import useRelayState from "Feed/RelayState";
import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import { removeRelay } from "State/Login";
import { parseId, unwrap } from "Util";
import { System } from "System";
import { removeRelay } from "Login";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
const RelayInfo = () => {
const params = useParams();
const navigate = useNavigate();
const dispatch = useDispatch();
const login = useLogin();
const conn = Array.from(System.Sockets.values()).find(a => a.Id === params.id);
const stats = useRelayState(conn?.Address ?? "");
@ -105,7 +105,7 @@ const RelayInfo = () => {
<div
className="btn error"
onClick={() => {
dispatch(removeRelay(unwrap(conn).Address));
removeRelay(login, unwrap(conn).Address);
navigate("/settings/relays");
}}>
<FormattedMessage {...messages.Remove} />

View File

@ -1,37 +1,37 @@
import { useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
import { randomSample } from "Util";
import { randomSample, unixNowMs } from "Util";
import Relay from "Element/Relay";
import useEventPublisher from "Feed/EventPublisher";
import { RootState } from "State/Store";
import { setRelays } from "State/Login";
import { System } from "System";
import useLogin from "Hooks/useLogin";
import { setRelays } from "Login";
import messages from "./messages";
const RelaySettingsPage = () => {
const dispatch = useDispatch();
const publisher = useEventPublisher();
const relays = useSelector((s: RootState) => s.login.relays);
const login = useLogin();
const relays = login.relays;
const [newRelay, setNewRelay] = useState<string>();
const otherConnections = useMemo(() => {
return [...System.Sockets.keys()].filter(a => relays[a] === undefined);
return [...System.Sockets.keys()].filter(a => relays.item[a] === undefined);
}, [relays]);
async function saveRelays() {
const ev = await publisher.saveRelays();
publisher.broadcast(ev);
publisher.broadcastForBootstrap(ev);
try {
const onlineRelays = await fetch("https://api.nostr.watch/v1/online").then(r => r.json());
const settingsEv = await publisher.saveRelaysSettings();
const rs = Object.keys(relays).concat(randomSample(onlineRelays, 20));
publisher.broadcastAll(settingsEv, rs);
} catch (error) {
console.error(error);
if (publisher) {
const ev = await publisher.contactList(login.follows.item, login.relays.item);
publisher.broadcast(ev);
publisher.broadcastForBootstrap(ev);
try {
const onlineRelays = await fetch("https://api.nostr.watch/v1/online").then(r => r.json());
const relayList = await publisher.relayList(login.relays.item);
const rs = Object.keys(relays).concat(randomSample(onlineRelays, 20));
publisher.broadcastAll(relayList, rs);
} catch (error) {
console.error(error);
}
}
}
@ -69,13 +69,10 @@ const RelaySettingsPage = () => {
if ((newRelay?.length ?? 0) > 0) {
const parsed = new URL(newRelay ?? "");
const payload = {
relays: {
...relays,
[parsed.toString()]: { read: false, write: false },
},
createdAt: Math.floor(new Date().getTime() / 1000),
...relays.item,
[parsed.toString()]: { read: true, write: true },
};
dispatch(setRelays(payload));
setRelays(login, payload, unixNowMs());
}
}
@ -85,7 +82,7 @@ const RelaySettingsPage = () => {
<FormattedMessage {...messages.Relays} />
</h3>
<div className="flex f-col mb10">
{Object.keys(relays || {}).map(a => (
{Object.keys(relays.item || {}).map(a => (
<Relay addr={a} key={a} />
))}
</div>

View File

@ -10,12 +10,13 @@ import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
export default function LNForwardAddress({ handle }: { handle: ManageHandle }) {
const { formatMessage } = useIntl();
const publisher = useEventPublisher();
const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`);
const [newAddress, setNewAddress] = useState(handle.lnAddress ?? "");
const [error, setError] = useState("");
async function startUpdate() {
if (!publisher) return;
const req = {
lnAddress: newAddress,
};
@ -33,6 +34,7 @@ export default function LNForwardAddress({ handle }: { handle: ManageHandle }) {
return;
}
const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`);
const rsp = await sp.patch(handle.id, req);
if ("error" in rsp) {
setError(rsp.error);

View File

@ -10,13 +10,14 @@ export default function ListHandles() {
const navigate = useNavigate();
const publisher = useEventPublisher();
const [handles, setHandles] = useState<Array<ManageHandle>>([]);
const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`);
useEffect(() => {
loadHandles().catch(console.error);
}, []);
}, [publisher]);
async function loadHandles() {
if (!publisher) return;
const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`);
const list = await sp.list();
setHandles(list as Array<ManageHandle>);
}

View File

@ -11,13 +11,13 @@ export default function TransferHandle({ handle }: { handle: ManageHandle }) {
const publisher = useEventPublisher();
const navigate = useNavigate();
const { formatMessage } = useIntl();
const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`);
const [newKey, setNewKey] = useState("");
const [error, setError] = useState<Array<string>>([]);
async function startTransfer() {
if (!newKey) return;
if (!newKey || !publisher) return;
const sp = new SnortServiceProvider(publisher, `${ApiHost}/api/v1/n5sp`);
setError([]);
const rsp = await sp.transfer(handle.id, newKey);
if ("error" in rsp) {

View File

@ -58,9 +58,10 @@ export function SubscribePage() {
<h2>{mapPlanName(a.id)}</h2>
<p>
<FormattedMessage
defaultMessage="Support Snort every month for {price} sats and receive the following rewards"
defaultMessage="Subscribe to Snort {plan} for {price} and receive the following rewards"
values={{
price: <b>{formatShort(a.price)}</b>,
plan: mapPlanName(a.id),
price: <b>{formatShort(a.price)} sats/mo</b>,
}}
/>
:
@ -86,10 +87,7 @@ export function SubscribePage() {
{a.disabled ? (
<FormattedMessage defaultMessage="Coming soon" />
) : (
<FormattedMessage
defaultMessage="Subscribe for {amount}/mo"
values={{ amount: formatShort(a.price) }}
/>
<FormattedMessage defaultMessage="Subscribe" />
)}
</AsyncButton>
</div>

View File

@ -1,6 +1,7 @@
import { EventKind } from "@snort/nostr";
import { ApiHost } from "Const";
import { EventPublisher } from "Feed/EventPublisher";
import { SubscriptionType } from "Subscription";
import { EventPublisher } from "System/EventPublisher";
export interface RevenueToday {
donations: number;
@ -61,10 +62,12 @@ export default class SnortApi {
if (!this.#publisher) {
throw new Error("Publisher not set");
}
const auth = await this.#publisher.generic("", 27_235, [
["url", `${this.#url}${path}`],
["method", method ?? "GET"],
]);
const auth = await this.#publisher.generic(eb => {
eb.kind(EventKind.HttpAuthentication);
eb.tag(["url", `${this.#url}${path}`]);
eb.tag(["method", method ?? "GET"]);
return eb;
});
if (!auth) {
throw new Error("Failed to create auth event");
}

View File

@ -1,537 +0,0 @@
import { AnyAction, createSlice, PayloadAction, ThunkAction } from "@reduxjs/toolkit";
import * as secp from "@noble/secp256k1";
import { HexKey } from "@snort/nostr";
import { DefaultRelays } from "Const";
import { RelaySettings } from "@snort/nostr";
import type { AppDispatch, RootState } from "State/Store";
import { ImgProxySettings } from "Hooks/useImgProxy";
import { dedupeById, sanitizeRelayUrl, unwrap } from "Util";
import { DmCache } from "Cache";
import { getCurrentSubscription, SubscriptionEvent } from "Subscription";
const PrivateKeyItem = "secret";
const PublicKeyItem = "pubkey";
const NotificationsReadItem = "notifications-read";
const UserPreferencesKey = "preferences";
const RelayListKey = "last-relays";
const FollowList = "last-follows";
export interface NotificationRequest {
title: string;
body: string;
icon: string;
timestamp: number;
}
export interface UserPreferences {
/**
* User selected language
*/
language?: string;
/**
* Enable reactions / reposts / zaps
*/
enableReactions: boolean;
/**
* Reaction emoji
*/
reactionEmoji: string;
/**
* Automatically load media (show link only) (bandwidth/privacy)
*/
autoLoadMedia: "none" | "follows-only" | "all";
/**
* Select between light/dark theme
*/
theme: "system" | "light" | "dark";
/**
* Ask for confirmation when reposting notes
*/
confirmReposts: boolean;
/**
* Automatically show the latests notes
*/
autoShowLatest: boolean;
/**
* Show debugging menus to help diagnose issues
*/
showDebugMenus: boolean;
/**
* File uploading service to upload attachments to
*/
fileUploader: "void.cat" | "nostr.build" | "nostrimg.com";
/**
* Use imgproxy to optimize images
*/
imgProxyConfig: ImgProxySettings | null;
/**
* Default page to select on load
*/
defaultRootTab: "posts" | "conversations" | "global";
/**
* Default zap amount
*/
defaultZapAmount: number;
/**
* For each fast zap an additional X% will be sent to Snort donate address
*/
fastZapDonate: number;
/**
* Auto-zap every post
*/
autoZap: boolean;
}
export interface LoginStore {
/**
* If there is no login
*/
loggedOut?: boolean;
/**
* Current user private key
*/
privateKey?: HexKey;
/**
* BIP39-generated, hex-encoded entropy
*/
generatedEntropy?: string;
/**
* Current users public key
*/
publicKey?: HexKey;
/**
* If user generated key on snort
*/
newUserKey: boolean;
/**
* All the logged in users relays
*/
relays: Record<string, RelaySettings>;
/**
* Newest relay list timestamp
*/
latestRelays: number;
/**
* A list of pubkeys this user follows
*/
follows: HexKey[];
/**
* Newest relay list timestamp
*/
latestFollows: number;
/**
* A list of tags this user follows
*/
tags: string[];
/**
* Newest tag list timestamp
*/
latestTags: number;
/**
* A list of event ids this user has pinned
*/
pinned: HexKey[];
/**
* Last seen pinned list event timestamp
*/
latestPinned: number;
/**
* A list of event ids this user has bookmarked
*/
bookmarked: HexKey[];
/**
* Last seen bookmark list event timestamp
*/
latestBookmarked: number;
/**
* A list of pubkeys this user has muted
*/
muted: HexKey[];
/**
* Last seen mute list event timestamp
*/
latestMuted: number;
/**
* A list of pubkeys this user has muted privately
*/
blocked: HexKey[];
/**
* Latest notification
*/
latestNotification: number;
/**
* Timestamp of last read notification
*/
readNotifications: number;
/**
* Counter to trigger refresh of unread dms
*/
dmInteraction: 0;
/**
* Users cusom preferences
*/
preferences: UserPreferences;
/**
* Subscription events for Snort subscriptions
*/
subscriptions: Array<SubscriptionEvent>;
/**
* Current Snort subscription
*/
subscription?: SubscriptionEvent;
}
export const DefaultImgProxy = {
url: "https://imgproxy.snort.social",
key: "a82fcf26aa0ccb55dfc6b4bd6a1c90744d3be0f38429f21a8828b43449ce7cebe6bdc2b09a827311bef37b18ce35cb1e6b1c60387a254541afa9e5b4264ae942",
salt: "a897770d9abf163de055e9617891214e75a9016d748f8ef865e6ffbcb9ed932295659549773a22a019a5f06d0b440c320be411e3fddfe784e199e4f03d74bd9b",
};
export const InitState = {
loggedOut: undefined,
publicKey: undefined,
privateKey: undefined,
newUserKey: false,
relays: {},
latestRelays: 0,
follows: [],
latestFollows: 0,
tags: [],
latestTags: 0,
pinned: [],
latestPinned: 0,
bookmarked: [],
latestBookmarked: 0,
muted: [],
blocked: [],
latestMuted: 0,
latestNotification: 0,
readNotifications: new Date().getTime(),
dms: [],
dmInteraction: 0,
subscriptions: [],
preferences: {
enableReactions: true,
reactionEmoji: "+",
autoLoadMedia: "follows-only",
theme: "system",
confirmReposts: false,
showDebugMenus: false,
autoShowLatest: false,
fileUploader: "void.cat",
imgProxyConfig: DefaultImgProxy,
defaultRootTab: "posts",
defaultZapAmount: 50,
fastZapDonate: 0.0,
autoZap: false,
},
} as LoginStore;
export interface SetRelaysPayload {
relays: Record<string, RelaySettings>;
createdAt: number;
}
export interface SetFollowsPayload {
keys: HexKey[];
createdAt: number;
}
export interface SetGeneratedKeyPayload {
key: HexKey;
entropy: HexKey;
}
export const ReadPreferences = () => {
const pref = window.localStorage.getItem(UserPreferencesKey);
if (pref) {
return JSON.parse(pref) as UserPreferences;
}
return InitState.preferences;
};
const LoginSlice = createSlice({
name: "Login",
initialState: InitState,
reducers: {
init: state => {
state.privateKey = window.localStorage.getItem(PrivateKeyItem) ?? undefined;
if (state.privateKey) {
window.localStorage.removeItem(PublicKeyItem); // reset nip07 if using private key
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(state.privateKey));
state.loggedOut = false;
} else {
state.loggedOut = true;
}
// check pub key only
const pubKey = window.localStorage.getItem(PublicKeyItem);
if (pubKey && !state.privateKey) {
state.publicKey = pubKey;
state.loggedOut = false;
}
const lastRelayList = window.localStorage.getItem(RelayListKey);
if (lastRelayList) {
state.relays = JSON.parse(lastRelayList);
} else {
state.relays = Object.fromEntries(
[...DefaultRelays.entries()].map(a => [unwrap(sanitizeRelayUrl(a[0])), a[1]])
);
}
const lastFollows = window.localStorage.getItem(FollowList);
if (lastFollows) {
state.follows = JSON.parse(lastFollows);
}
// notifications
const readNotif = parseInt(window.localStorage.getItem(NotificationsReadItem) ?? "0");
if (!isNaN(readNotif)) {
state.readNotifications = readNotif;
}
// preferences
const pref = ReadPreferences();
state.preferences = pref;
},
setPrivateKey: (state, action: PayloadAction<HexKey>) => {
state.loggedOut = false;
state.privateKey = action.payload;
window.localStorage.setItem(PrivateKeyItem, action.payload);
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(action.payload));
},
setGeneratedPrivateKey: (state, action: PayloadAction<SetGeneratedKeyPayload>) => {
state.loggedOut = false;
state.newUserKey = true;
state.privateKey = action.payload.key;
state.generatedEntropy = action.payload.entropy;
window.localStorage.setItem(PrivateKeyItem, action.payload.key);
state.publicKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(action.payload.key));
},
clearEntropy: state => {
state.generatedEntropy = undefined;
},
setPublicKey: (state, action: PayloadAction<HexKey>) => {
window.localStorage.setItem(PublicKeyItem, action.payload);
state.loggedOut = false;
state.publicKey = action.payload;
},
setRelays: (state, action: PayloadAction<SetRelaysPayload>) => {
const relays = action.payload.relays;
const createdAt = action.payload.createdAt;
if (state.latestRelays > createdAt) {
return;
}
// filter out non-websocket urls
const filtered = new Map<string, RelaySettings>();
for (const [k, v] of Object.entries(relays)) {
if (k.startsWith("wss://") || k.startsWith("ws://")) {
const url = sanitizeRelayUrl(k);
if (url) {
filtered.set(url, v as RelaySettings);
}
}
}
state.relays = Object.fromEntries(filtered.entries());
state.latestRelays = createdAt;
window.localStorage.setItem(RelayListKey, JSON.stringify(state.relays));
},
removeRelay: (state, action: PayloadAction<string>) => {
delete state.relays[action.payload];
state.relays = { ...state.relays };
window.localStorage.setItem(RelayListKey, JSON.stringify(state.relays));
},
setFollows: (state, action: PayloadAction<SetFollowsPayload>) => {
const { keys, createdAt } = action.payload;
if (state.latestFollows > createdAt) {
return;
}
const existing = new Set(state.follows);
const update = Array.isArray(keys) ? keys : [keys];
let changes = false;
for (const pk of update.filter(a => a.length === 64)) {
if (!existing.has(pk)) {
existing.add(pk);
changes = true;
}
}
for (const pk of existing) {
if (!update.includes(pk)) {
existing.delete(pk);
changes = true;
}
}
if (changes) {
state.follows = Array.from(existing);
state.latestFollows = createdAt;
}
window.localStorage.setItem(FollowList, JSON.stringify(state.follows));
},
setTags(state, action: PayloadAction<{ createdAt: number; tags: string[] }>) {
const { createdAt, tags } = action.payload;
if (createdAt >= state.latestTags) {
const newTags = new Set([...tags]);
state.tags = Array.from(newTags);
state.latestTags = createdAt;
}
},
setMuted(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) {
const { createdAt, keys } = action.payload;
if (createdAt >= state.latestMuted) {
const muted = new Set([...keys]);
state.muted = Array.from(muted);
state.latestMuted = createdAt;
}
},
setPinned(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) {
const { createdAt, keys } = action.payload;
if (createdAt >= state.latestPinned) {
const pinned = new Set([...keys]);
state.pinned = Array.from(pinned);
state.latestPinned = createdAt;
}
},
setBookmarked(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) {
const { createdAt, keys } = action.payload;
if (createdAt >= state.latestBookmarked) {
const bookmarked = new Set([...keys]);
state.bookmarked = Array.from(bookmarked);
state.latestBookmarked = createdAt;
}
},
setBlocked(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) {
const { createdAt, keys } = action.payload;
if (createdAt >= state.latestMuted) {
const blocked = new Set([...keys]);
state.blocked = Array.from(blocked);
state.latestMuted = createdAt;
}
},
incDmInteraction: state => {
state.dmInteraction += 1;
},
logout: (state, payload: PayloadAction<() => void>) => {
const relays = { ...state.relays };
state = Object.assign(state, InitState);
state.loggedOut = true;
window.localStorage.clear();
state.relays = relays;
window.localStorage.setItem(RelayListKey, JSON.stringify(relays));
queueMicrotask(async () => {
await DmCache.clear();
payload.payload();
});
},
markNotificationsRead: state => {
state.readNotifications = Math.ceil(new Date().getTime() / 1000);
window.localStorage.setItem(NotificationsReadItem, state.readNotifications.toString());
},
setLatestNotifications: (state, action: PayloadAction<number>) => {
state.latestNotification = action.payload;
},
setPreferences: (state, action: PayloadAction<UserPreferences>) => {
state.preferences = action.payload;
window.localStorage.setItem(UserPreferencesKey, JSON.stringify(state.preferences));
},
addSubscription: (state, action: PayloadAction<Array<SubscriptionEvent>>) => {
state.subscriptions = dedupeById([...state.subscriptions, ...action.payload]);
state.subscription = getCurrentSubscription(state.subscriptions);
},
},
});
export const {
init,
setPrivateKey,
setGeneratedPrivateKey,
clearEntropy,
setPublicKey,
setRelays,
removeRelay,
setFollows,
setTags,
setMuted,
setPinned,
setBookmarked,
setBlocked,
incDmInteraction,
logout,
markNotificationsRead,
setLatestNotifications,
setPreferences,
addSubscription,
} = LoginSlice.actions;
export function sendNotification({
title,
body,
icon,
timestamp,
}: NotificationRequest): ThunkAction<void, RootState, undefined, AnyAction> {
return async (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const { readNotifications } = state.login;
const hasPermission = "Notification" in window && Notification.permission === "granted";
const shouldShowNotification = hasPermission && timestamp > readNotifications;
if (shouldShowNotification) {
try {
const worker = await navigator.serviceWorker.ready;
worker.showNotification(title, {
tag: "notification",
vibrate: [500],
body,
icon,
timestamp,
});
} catch (error) {
console.warn(error);
}
}
};
}
export const reducer = LoginSlice.reducer;

View File

@ -1,10 +1,8 @@
import { configureStore } from "@reduxjs/toolkit";
import { reducer as LoginReducer } from "State/Login";
import { reducer as NoteCreatorReducer } from "State/NoteCreator";
const store = configureStore({
reducer: {
login: LoginReducer,
noteCreator: NoteCreatorReducer,
},
});

View File

@ -18,7 +18,7 @@ export const Plans = [
{
id: SubscriptionType.Supporter,
price: 5_000,
disabled: true,
disabled: false,
unlocks: [LockedFeatures.MultiAccount, LockedFeatures.NostrAddress, LockedFeatures.Badge],
},
{

View File

@ -0,0 +1,101 @@
import { EventKind, HexKey, NostrPrefix, RawEvent } from "@snort/nostr";
import { HashtagRegex } from "Const";
import { parseNostrLink, unixNow } from "Util";
import { EventExt } from "./EventExt";
export class EventBuilder {
#kind?: EventKind;
#content?: string;
#createdAt?: number;
#pubkey?: string;
#tags: Array<Array<string>> = [];
kind(k: EventKind) {
this.#kind = k;
return this;
}
content(c: string) {
this.#content = c;
return this;
}
createdAt(n: number) {
this.#createdAt = n;
return this;
}
pubKey(k: string) {
this.#pubkey = k;
return this;
}
tag(t: Array<string>) {
this.#tags.push(t);
return this;
}
/**
* Extract mentions
*/
processContent() {
if (this.#content) {
this.#content = this.#content
.replace(/@n(pub|profile|event|ote|addr|)1[acdefghjklmnpqrstuvwxyz023456789]+/g, m => this.#replaceMention(m))
.replace(HashtagRegex, m => this.#replaceHashtag(m));
}
return this;
}
build() {
this.#validate();
const ev = {
id: "",
pubkey: this.#pubkey ?? "",
content: this.#content ?? "",
kind: this.#kind,
created_at: this.#createdAt ?? unixNow(),
tags: this.#tags,
} as RawEvent;
ev.id = EventExt.createId(ev);
return ev;
}
/**
* Build and sign event
* @param pk Private key to sign event with
*/
async buildAndSign(pk: HexKey) {
const ev = this.build();
await EventExt.sign(ev, pk);
return ev;
}
#validate() {
if (!this.#kind) {
throw new Error("Kind must be set");
}
if (!this.#pubkey) {
throw new Error("Pubkey must be set");
}
}
#replaceMention(match: string) {
const npub = match.slice(1);
const link = parseNostrLink(npub);
if (link) {
if (link.type === NostrPrefix.Profile || link.type === NostrPrefix.PublicKey) {
this.tag(["p", link.id]);
}
return `nostr:${link.encode()}`;
} else {
return match;
}
}
#replaceHashtag(match: string) {
const tag = match.slice(1);
this.tag(["t", tag.toLowerCase()]);
return match;
}
}

View File

@ -26,7 +26,7 @@ export abstract class EventExt {
* Sign this message with a private key
*/
static async sign(e: RawEvent, key: HexKey) {
e.id = await this.createId(e);
e.id = this.createId(e);
const sig = await secp.schnorr.sign(e.id, key);
e.sig = secp.utils.bytesToHex(sig);
@ -40,12 +40,12 @@ export abstract class EventExt {
* @returns True if valid signature
*/
static async verify(e: RawEvent) {
const id = await this.createId(e);
const id = this.createId(e);
const result = await secp.schnorr.verify(e.sig, id, e.pubkey);
return result;
}
static async createId(e: RawEvent) {
static createId(e: RawEvent) {
const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content];
const hash = sha256(JSON.stringify(payload));

View File

@ -0,0 +1,346 @@
import * as secp from "@noble/secp256k1";
import {
EventKind,
FullRelaySettings,
HexKey,
Lists,
RawEvent,
RelaySettings,
TaggedRawEvent,
u256,
UserMetadata,
} from "@snort/nostr";
import { DefaultRelays } from "Const";
import { System } from "System";
import { unwrap } from "Util";
import { EventBuilder } from "./EventBuilder";
import { EventExt } from "./EventExt";
declare global {
interface Window {
nostr: {
getPublicKey: () => Promise<HexKey>;
signEvent: (event: RawEvent) => Promise<RawEvent>;
getRelays: () => Promise<Record<string, { read: boolean; write: boolean }>>;
nip04: {
encrypt: (pubkey: HexKey, content: string) => Promise<string>;
decrypt: (pubkey: HexKey, content: string) => Promise<string>;
};
};
}
}
interface Nip7QueueItem {
next: () => Promise<unknown>;
resolve(v: unknown): void;
reject(e: unknown): void;
}
const Nip7QueueDelay = 200;
const Nip7Queue: Array<Nip7QueueItem> = [];
async function processQueue() {
while (Nip7Queue.length > 0) {
const v = Nip7Queue.shift();
if (v) {
try {
const ret = await v.next();
v.resolve(ret);
} catch (e) {
v.reject(e);
}
}
}
setTimeout(processQueue, Nip7QueueDelay);
}
processQueue();
export const barrierNip07 = async <T>(then: () => Promise<T>): Promise<T> => {
return new Promise<T>((resolve, reject) => {
Nip7Queue.push({
next: then,
resolve,
reject,
});
});
};
export type EventBuilderHook = (ev: EventBuilder) => EventBuilder;
export class EventPublisher {
#pubKey: string;
#privateKey?: string;
#hasNip07 = "nostr" in window;
constructor(pubKey: string, privKey?: string) {
if (privKey) {
this.#privateKey = privKey;
this.#pubKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(privKey));
} else {
this.#pubKey = pubKey;
}
}
#eb(k: EventKind) {
const eb = new EventBuilder();
return eb.pubKey(this.#pubKey).kind(k);
}
async #sign(eb: EventBuilder) {
if (this.#hasNip07 && !this.#privateKey) {
const nip7PubKey = await barrierNip07(() => window.nostr.getPublicKey());
if (nip7PubKey !== this.#pubKey) {
throw new Error("Can't sign event, NIP-07 pubkey does not match");
}
const ev = eb.build();
return await barrierNip07(() => window.nostr.signEvent(ev));
} else if (this.#privateKey) {
return await eb.buildAndSign(this.#privateKey);
} else {
throw new Error("Can't sign event, no private keys available");
}
}
async nip4Encrypt(content: string, key: HexKey) {
if (this.#hasNip07 && !this.#privateKey) {
const nip7PubKey = await barrierNip07(() => window.nostr.getPublicKey());
if (nip7PubKey !== this.#pubKey) {
throw new Error("Can't encrypt content, NIP-07 pubkey does not match");
}
return await barrierNip07(() => window.nostr.nip04.encrypt(key, content));
} else if (this.#privateKey) {
return await EventExt.encryptData(content, key, this.#privateKey);
} else {
throw new Error("Can't encrypt content, no private keys available");
}
}
async nip4Decrypt(content: string, otherKey: HexKey) {
if (this.#hasNip07 && !this.#privateKey) {
return await barrierNip07(() => window.nostr.nip04.decrypt(otherKey, content));
} else if (this.#privateKey) {
return await EventExt.decryptDm(content, this.#privateKey, otherKey);
} else {
throw new Error("Can't decrypt content, no private keys available");
}
}
async nip42Auth(challenge: string, relay: string) {
const eb = this.#eb(EventKind.Auth);
eb.tag(["relay", relay]);
eb.tag(["challenge", challenge]);
return await this.#sign(eb);
}
broadcast(ev: RawEvent) {
console.debug(ev);
System.BroadcastEvent(ev);
}
/**
* Write event to DefaultRelays, this is important for profiles / relay lists to prevent bugs
* If a user removes all the DefaultRelays from their relay list and saves that relay list,
* When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state
*/
broadcastForBootstrap(ev: RawEvent) {
for (const [k] of DefaultRelays) {
System.WriteOnceToRelay(k, ev);
}
}
/**
* Write event to all given relays.
*/
broadcastAll(ev: RawEvent, relays: string[]) {
for (const k of relays) {
System.WriteOnceToRelay(k, ev);
}
}
async muted(keys: HexKey[], priv: HexKey[]) {
const eb = this.#eb(EventKind.PubkeyLists);
eb.tag(["d", Lists.Muted]);
keys.forEach(p => {
eb.tag(["p", p]);
});
if (priv.length > 0) {
const ps = priv.map(p => ["p", p]);
const plaintext = JSON.stringify(ps);
eb.content(await this.nip4Encrypt(plaintext, this.#pubKey));
}
return await this.#sign(eb);
}
async noteList(notes: u256[], list: Lists) {
const eb = this.#eb(EventKind.NoteLists);
eb.tag(["d", list]);
notes.forEach(n => {
eb.tag(["e", n]);
});
return await this.#sign(eb);
}
async tags(tags: string[]) {
const eb = this.#eb(EventKind.TagLists);
eb.tag(["d", Lists.Followed]);
tags.forEach(t => {
eb.tag(["t", t]);
});
return await this.#sign(eb);
}
async metadata(obj: UserMetadata) {
const eb = this.#eb(EventKind.SetMetadata);
eb.content(JSON.stringify(obj));
return await this.#sign(eb);
}
/**
* Create a basic text note
*/
async note(msg: string, fnExtra?: EventBuilderHook) {
const eb = this.#eb(EventKind.TextNote);
eb.content(msg);
eb.processContent();
fnExtra?.(eb);
return await this.#sign(eb);
}
/**
* Create a zap request event for a given target event/profile
* @param amount Millisats amout!
* @param author Author pubkey to tag in the zap
* @param note Note Id to tag in the zap
* @param msg Custom message to be included in the zap
*/
async zap(
amount: number,
author: HexKey,
relays: Array<string>,
note?: HexKey,
msg?: string,
fnExtra?: EventBuilderHook
) {
const eb = this.#eb(EventKind.ZapRequest);
eb.content(msg ?? "");
if (note) {
eb.tag(["e", note]);
}
eb.tag(["p", author]);
eb.tag(["relays", ...relays.map(a => a.trim())]);
eb.tag(["amount", amount.toString()]);
eb.processContent();
fnExtra?.(eb);
return await this.#sign(eb);
}
/**
* Reply to a note
*/
async reply(replyTo: TaggedRawEvent, msg: string, fnExtra?: EventBuilderHook) {
const eb = this.#eb(EventKind.TextNote);
eb.content(msg);
const thread = EventExt.extractThread(replyTo);
if (thread) {
if (thread.root || thread.replyTo) {
eb.tag(["e", thread.root?.Event ?? thread.replyTo?.Event ?? "", "", "root"]);
}
eb.tag(["e", replyTo.id, replyTo.relays?.[0] ?? "", "reply"]);
for (const pk of thread.pubKeys) {
if (pk === this.#pubKey) {
continue;
}
eb.tag(["p", pk]);
}
} else {
eb.tag(["e", replyTo.id, "", "reply"]);
// dont tag self in replies
if (replyTo.pubkey !== this.#pubKey) {
eb.tag(["p", replyTo.pubkey]);
}
}
eb.processContent();
fnExtra?.(eb);
return await this.#sign(eb);
}
async react(evRef: RawEvent, content = "+") {
const eb = this.#eb(EventKind.Reaction);
eb.content(content);
eb.tag(["e", evRef.id]);
eb.tag(["p", evRef.pubkey]);
return await this.#sign(eb);
}
async relayList(relays: Array<FullRelaySettings> | Record<string, RelaySettings>) {
if (!Array.isArray(relays)) {
relays = Object.entries(relays).map(([k, v]) => ({
url: k,
settings: v,
}));
}
const eb = this.#eb(EventKind.Relays);
for (const rx of relays) {
const rTag = ["r", rx.url];
if (rx.settings.read && !rx.settings.write) {
rTag.push("read");
}
if (rx.settings.write && !rx.settings.read) {
rTag.push("write");
}
eb.tag(rTag);
}
return await this.#sign(eb);
}
async contactList(follows: Array<HexKey>, relays: Record<string, RelaySettings>) {
const eb = this.#eb(EventKind.ContactList);
eb.content(JSON.stringify(relays));
const temp = new Set(follows.filter(a => a.length === 64).map(a => a.toLowerCase()));
temp.forEach(a => eb.tag(["p", a]));
return await this.#sign(eb);
}
/**
* Delete an event (NIP-09)
*/
async delete(id: u256) {
const eb = this.#eb(EventKind.Deletion);
eb.tag(["e", id]);
return await this.#sign(eb);
}
/**
* Repost a note (NIP-18)
*/
async repost(note: RawEvent) {
const eb = this.#eb(EventKind.Repost);
eb.tag(["e", note.id, ""]);
eb.tag(["p", note.pubkey]);
return await this.#sign(eb);
}
async decryptDm(note: RawEvent) {
if (note.pubkey !== this.#pubKey && !note.tags.some(a => a[1] === this.#pubKey)) {
throw new Error("Can't decrypt, DM does not belong to this user");
}
const otherPubKey = note.pubkey === this.#pubKey ? unwrap(note.tags.find(a => a[0] === "p")?.[1]) : note.pubkey;
return await this.nip4Decrypt(note.content, otherPubKey);
}
async sendDm(content: string, to: HexKey) {
const eb = this.#eb(EventKind.DirectMessage);
eb.content(await this.nip4Encrypt(content, to));
eb.tag(["p", to]);
return await this.#sign(eb);
}
async generic(fnHook: EventBuilderHook) {
const eb = new EventBuilder();
fnHook(eb);
return await this.#sign(eb);
}
}

View File

@ -2,6 +2,7 @@ import { AuthHandler, TaggedRawEvent, RelaySettings, Connection, RawReqFilter, R
import { sanitizeRelayUrl, unixNowMs, unwrap } from "Util";
import { RequestBuilder } from "./RequestBuilder";
import { EventBuilder } from "./EventBuilder";
import {
FlatNoteStore,
NoteStore,
@ -18,6 +19,7 @@ export {
PubkeyReplaceableNoteStore,
ParameterizedReplaceableNoteStore,
Query,
EventBuilder,
};
export interface SystemSnapshot {

View File

@ -1,8 +1,7 @@
import useLogin from "Hooks/useLogin";
import { useUserProfile } from "Hooks/useUserProfile";
import Icon from "Icons/Icon";
import { useState } from "react";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { UITask } from "Tasks";
import { Nip5Task } from "./Nip5Task";
@ -10,7 +9,7 @@ const AllTasks: Array<UITask> = [new Nip5Task()];
AllTasks.forEach(a => a.load());
export const TaskList = () => {
const publicKey = useSelector((s: RootState) => s.login.publicKey);
const publicKey = useLogin().publicKey;
const user = useUserProfile(publicKey);
const [, setTick] = useState<number>(0);

View File

@ -1,5 +1,4 @@
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import useLogin from "Hooks/useLogin";
import NostrBuild from "Upload/NostrBuild";
import VoidCat from "Upload/VoidCat";
import NostrImg from "./NostrImg";
@ -14,7 +13,7 @@ export interface Uploader {
}
export default function useFileUpload(): Uploader {
const fileUploader = useSelector((s: RootState) => s.login.preferences.fileUploader);
const fileUploader = useLogin().preferences.fileUploader;
switch (fileUploader) {
case "nostr.build": {

View File

@ -154,6 +154,14 @@ export function unixNowMs() {
return new Date().getTime();
}
export function deepClone<T>(obj: T) {
if ("structuredClone" in window) {
return structuredClone(obj);
} else {
return JSON.parse(JSON.stringify(obj));
}
}
/**
* Simple debounce
*/

View File

@ -81,6 +81,9 @@
"2k0Cv+": {
"defaultMessage": "Dislikes ({n})"
},
"2ukA4d": {
"defaultMessage": "{n} hours"
},
"3Rx6Qo": {
"defaultMessage": "Advanced"
},
@ -205,6 +208,9 @@
"Adk34V": {
"defaultMessage": "Setup your Profile"
},
"Ai8VHU": {
"defaultMessage": "Unlimited note retention on Snort relay"
},
"AkCxS/": {
"defaultMessage": "Reason"
},
@ -233,6 +239,9 @@
"BcGMo+": {
"defaultMessage": "Notes hold text content, the most popular usage of these notes is to store \"tweet like\" messages."
},
"C5xzTC": {
"defaultMessage": "Premium"
},
"C81/uG": {
"defaultMessage": "Logout"
},
@ -257,6 +266,9 @@
"DZzCem": {
"defaultMessage": "Show latest {n} notes"
},
"DcL8P+": {
"defaultMessage": "Supporter"
},
"Dh3hbq": {
"defaultMessage": "Auto Zap"
},
@ -363,6 +375,9 @@
"Iwm6o2": {
"defaultMessage": "NIP-05 Shop"
},
"J+dIsA": {
"defaultMessage": "Subscriptions"
},
"JCIgkj": {
"defaultMessage": "Username"
},
@ -402,9 +417,6 @@
"LF5kYT": {
"defaultMessage": "Other Connections"
},
"LQahqW": {
"defaultMessage": "Manage Nostr Adddress (NIP-05)"
},
"LXxsbk": {
"defaultMessage": "Anonymous"
},
@ -461,6 +473,9 @@
"OLEm6z": {
"defaultMessage": "Unknown login error"
},
"ORGv1Q": {
"defaultMessage": "Created"
},
"P04gQm": {
"defaultMessage": "All zaps sent to this note will be received by the following LNURL"
},
@ -570,6 +585,9 @@
"Vx7Zm2": {
"defaultMessage": "How do keys work?"
},
"W1yoZY": {
"defaultMessage": "It looks like you dont have any subscriptions, you can get one {link}"
},
"W2PiAr": {
"defaultMessage": "{n} Blocked"
},
@ -600,6 +618,9 @@
"YXA3AH": {
"defaultMessage": "Enable reactions"
},
"Z0FDj+": {
"defaultMessage": "Subscribe to Snort {plan} for {price} and receive the following rewards"
},
"Z4BMCZ": {
"defaultMessage": "Enter pairing phrase"
},
@ -653,6 +674,9 @@
"cg1VJ2": {
"defaultMessage": "Connect Wallet"
},
"cuP16y": {
"defaultMessage": "Multi account support"
},
"cuV2gK": {
"defaultMessage": "name is registered"
},
@ -669,6 +693,9 @@
"dOQCL8": {
"defaultMessage": "Display name"
},
"e61Jf3": {
"defaultMessage": "Coming soon"
},
"e7qqly": {
"defaultMessage": "Mark All Read"
},
@ -714,6 +741,9 @@
"gXgY3+": {
"defaultMessage": "Not all clients support this yet"
},
"gczcC5": {
"defaultMessage": "Subscribe"
},
"gjBiyj": {
"defaultMessage": "Loading..."
},
@ -735,12 +765,18 @@
"hicxcO": {
"defaultMessage": "Show replies"
},
"hniz8Z": {
"defaultMessage": "here"
},
"iCqGww": {
"defaultMessage": "Reactions ({n})"
},
"iDGAbc": {
"defaultMessage": "Get a Snort identifier"
},
"iEoXYx": {
"defaultMessage": "DeepL translations"
},
"iGT1eE": {
"defaultMessage": "Prevent fake accounts from imitating you"
},
@ -787,6 +823,9 @@
"kaaf1E": {
"defaultMessage": "now"
},
"l+ikU1": {
"defaultMessage": "Everything in {plan}"
},
"lBboHo": {
"defaultMessage": "If you want to try out some others, check out {link} for more!"
},
@ -796,6 +835,9 @@
"lD3+8a": {
"defaultMessage": "Pay"
},
"lPWASz": {
"defaultMessage": "Snort nostr address"
},
"lTbT3s": {
"defaultMessage": "Wallet password"
},
@ -829,6 +871,9 @@
"mKhgP9": {
"defaultMessage": "{n,plural,=0{} =1{zapped} other{zapped}}"
},
"mLcajD": {
"defaultMessage": "Snort Subscription"
},
"mfe8RW": {
"defaultMessage": "Option: {n}"
},
@ -847,6 +892,9 @@
"nOaArs": {
"defaultMessage": "Setup Profile"
},
"nWQFic": {
"defaultMessage": "Renew"
},
"nn1qb3": {
"defaultMessage": "Your donations are greatly appreciated"
},
@ -863,6 +911,9 @@
"oJ+JJN": {
"defaultMessage": "Nothing found :/"
},
"oVSg7o": {
"defaultMessage": "Snort Nostr Adddress"
},
"odFwjL": {
"defaultMessage": "Follows only"
},
@ -879,6 +930,9 @@
"p85Uwy": {
"defaultMessage": "Active Subscriptions"
},
"pI+77w": {
"defaultMessage": "Downloadable backups from Snort relay"
},
"puLNUJ": {
"defaultMessage": "Pin"
},
@ -918,6 +972,9 @@
"rfuMjE": {
"defaultMessage": "(Default)"
},
"rmdsT4": {
"defaultMessage": "{n} days"
},
"rrfdTe": {
"defaultMessage": "This is the same technology which is used by Bitcoin and has been proven to be extremely secure."
},
@ -939,6 +996,9 @@
"thnRpU": {
"defaultMessage": "Getting NIP-05 verified can help:"
},
"ttxS0b": {
"defaultMessage": "Supporter Badge"
},
"u/vOPu": {
"defaultMessage": "Paid"
},
@ -1021,6 +1081,9 @@
"xbVgIm": {
"defaultMessage": "Automatically load media"
},
"xhQMeQ": {
"defaultMessage": "Expires"
},
"xmcVZ0": {
"defaultMessage": "Search"
},

View File

@ -23,11 +23,9 @@ export function hexToMnemonic(hex: string): string {
}
/**
* Convert mnemonic phrase into hex-encoded private key
* using the derivation path specified in NIP06
* @param mnemonic the mnemonic-encoded entropy
* Derrive NIP-06 private key from master key
*/
export function entropyToDerivedKey(entropy: Uint8Array): string {
export function entropyToPrivateKey(entropy: Uint8Array): string {
const masterKey = HDKey.fromMasterSeed(entropy);
const newKey = masterKey.derive(DerivationPath);

View File

@ -26,6 +26,7 @@
"2LbrkB": "Enter password",
"2a2YiP": "{n} Bookmarks",
"2k0Cv+": "Dislikes ({n})",
"2ukA4d": "{n} hours",
"3Rx6Qo": "Advanced",
"3cc4Ct": "Light",
"3gOsZq": "Translators",
@ -66,6 +67,7 @@
"ADmfQT": "Parent",
"ASRK0S": "This author has been muted",
"Adk34V": "Setup your Profile",
"Ai8VHU": "Unlimited note retention on Snort relay",
"AkCxS/": "Reason",
"AnLrRC": "Non-Zap",
"AyGauy": "Login",
@ -75,6 +77,7 @@
"BOr9z/": "Snort is an open source project built by passionate people in their free time",
"BWpuKl": "Update",
"BcGMo+": "Notes hold text content, the most popular usage of these notes is to store \"tweet like\" messages.",
"C5xzTC": "Premium",
"C81/uG": "Logout",
"CHTbO3": "Failed to load invoice",
"CmZ9ls": "{n} Muted",
@ -83,6 +86,7 @@
"D3idYv": "Settings",
"DKnriN": "Send sats",
"DZzCem": "Show latest {n} notes",
"DcL8P+": "Supporter",
"Dh3hbq": "Auto Zap",
"Dt/Zd5": "Media in posts will automatically be shown for selected people, otherwise only the link will show",
"DtYelJ": "Transfer",
@ -118,6 +122,7 @@
"INSqIz": "Twitter username...",
"IUZC+0": "This means that nobody can modify notes which you have created and everybody can easily verify that the notes they are reading are created by you.",
"Iwm6o2": "NIP-05 Shop",
"J+dIsA": "Subscriptions",
"JCIgkj": "Username",
"JHEHCk": "Zaps ({n})",
"JXtsQW": "Fast Zap Donation",
@ -131,7 +136,6 @@
"KahimY": "Unknown event kind: {kind}",
"L7SZPr": "For more information about donations see {link}.",
"LF5kYT": "Other Connections",
"LQahqW": "Manage Nostr Adddress (NIP-05)",
"LXxsbk": "Anonymous",
"LgbKvU": "Comment",
"LxY9tW": "Generate Key",
@ -150,6 +154,7 @@
"OEW7yJ": "Zaps",
"OKhRC6": "Share",
"OLEm6z": "Unknown login error",
"ORGv1Q": "Created",
"P04gQm": "All zaps sent to this note will be received by the following LNURL",
"P61BTu": "Copy Event JSON",
"P7FD0F": "System (Default)",
@ -185,6 +190,7 @@
"VnXp8Z": "Avatar",
"VtPV/B": "Login with Extension (NIP-07)",
"Vx7Zm2": "How do keys work?",
"W1yoZY": "It looks like you dont have any subscriptions, you can get one {link}",
"W2PiAr": "{n} Blocked",
"W9355R": "Unmute",
"WONP5O": "Find your twitter follows on nostr (Data provided by {provider})",
@ -195,6 +201,7 @@
"Y31HTH": "Help fund the development of Snort",
"YDURw6": "Service URL",
"YXA3AH": "Enable reactions",
"Z0FDj+": "Subscribe to Snort {plan} for {price} and receive the following rewards",
"Z4BMCZ": "Enter pairing phrase",
"ZKORll": "Activate Now",
"ZLmyG9": "Contributors",
@ -212,11 +219,13 @@
"cQfLWb": "URL..",
"cWx9t8": "Mute all",
"cg1VJ2": "Connect Wallet",
"cuP16y": "Multi account support",
"cuV2gK": "name is registered",
"cyR7Kh": "Back",
"d6CyG5": "History",
"d7d0/x": "LN Address",
"dOQCL8": "Display name",
"e61Jf3": "Coming soon",
"e7qqly": "Mark All Read",
"eHAneD": "Reaction emoji",
"eJj8HD": "Get Verified",
@ -232,6 +241,7 @@
"gDZkld": "Snort is a Nostr UI, nostr is a decentralised protocol for saving and distributing \"notes\".",
"gDzDRs": "Emoji to send when reactiong to a note",
"gXgY3+": "Not all clients support this yet",
"gczcC5": "Subscribe",
"gjBiyj": "Loading...",
"h8XMJL": "Badges",
"hCUivF": "Notes will stream in real time into global and posts tab",
@ -239,8 +249,10 @@
"hMzcSq": "Messages",
"hY4lzx": "Supports",
"hicxcO": "Show replies",
"hniz8Z": "here",
"iCqGww": "Reactions ({n})",
"iDGAbc": "Get a Snort identifier",
"iEoXYx": "DeepL translations",
"iGT1eE": "Prevent fake accounts from imitating you",
"iNWbVV": "Handle",
"iXPL0Z": "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead",
@ -256,9 +268,11 @@
"k2veDA": "Write",
"k7sKNy": "Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!",
"kaaf1E": "now",
"l+ikU1": "Everything in {plan}",
"lBboHo": "If you want to try out some others, check out {link} for more!",
"lCILNz": "Buy Now",
"lD3+8a": "Pay",
"lPWASz": "Snort nostr address",
"lTbT3s": "Wallet password",
"lgg1KN": "account page",
"ll3xBp": "Image proxy service",
@ -270,22 +284,26 @@
"mKAr6h": "Follow all",
"mKh2HS": "File upload service",
"mKhgP9": "{n,plural,=0{} =1{zapped} other{zapped}}",
"mLcajD": "Snort Subscription",
"mfe8RW": "Option: {n}",
"n1xHAH": "Get an identifier (optional)",
"nDejmx": "Unblock",
"nGBrvw": "Bookmarks",
"nN9XTz": "Share your thoughts with {link}",
"nOaArs": "Setup Profile",
"nWQFic": "Renew",
"nn1qb3": "Your donations are greatly appreciated",
"nwZXeh": "{n} blocked",
"o6Uy3d": "Only the secret key can be used to publish (sign events), everything else logs you in read-only mode.",
"o7e+nJ": "{n} followers",
"oJ+JJN": "Nothing found :/",
"oVSg7o": "Snort Nostr Adddress",
"odFwjL": "Follows only",
"odhABf": "Login",
"osUr8O": "You can also use these extensions to login to most Nostr sites.",
"oxCa4R": "Getting an identifier helps confirm the real you to people who know you. Many people can have a username @jack, but there is only one jack@cash.app.",
"p85Uwy": "Active Subscriptions",
"pI+77w": "Downloadable backups from Snort relay",
"puLNUJ": "Pin",
"pzTOmv": "Followers",
"qDwvZ4": "Unknown error",
@ -299,6 +317,7 @@
"rT14Ow": "Add Relays",
"reJ6SM": "It is recommended to use one of the following browser extensions if you are on a desktop computer to secure your key:",
"rfuMjE": "(Default)",
"rmdsT4": "{n} days",
"rrfdTe": "This is the same technology which is used by Bitcoin and has been proven to be extremely secure.",
"rudscU": "Failed to load follows, please try again later",
"sBz4+I": "For each Fast Zap an additional {percentage}% ({amount} sats) of the zap amount will be sent to the Snort developers as a donation.",
@ -306,6 +325,7 @@
"svOoEH": "Name-squatting and impersonation is not allowed. Snort and our partners reserve the right to terminate your handle (not your account - nobody can take that away) for violating this rule.",
"tOdNiY": "Dark",
"thnRpU": "Getting NIP-05 verified can help:",
"ttxS0b": "Supporter Badge",
"u/vOPu": "Paid",
"u4bHcR": "Check out the code here: {link}",
"uD/N6c": "Zap {target} {n} sats",
@ -332,6 +352,7 @@
"xKflGN": "{username}''s Follows on Nostr",
"xQtL3v": "Unlock",
"xbVgIm": "Automatically load media",
"xhQMeQ": "Expires",
"xmcVZ0": "Search",
"y1Z3or": "Language",
"yCLnBC": "LNURL or Lightning Address",
@ -343,4 +364,4 @@
"zjJZBd": "You're ready!",
"zonsdq": "Failed to load LNURL service",
"zvCDao": "Automatically show latest notes"
}
}