diff --git a/packages/app/public/icons.svg b/packages/app/public/icons.svg index 873faecf..e6e5d47f 100644 --- a/packages/app/public/icons.svg +++ b/packages/app/public/icons.svg @@ -394,5 +394,14 @@ stroke-linejoin="round" /> + + + \ No newline at end of file diff --git a/packages/app/src/Const.ts b/packages/app/src/Const.ts index 47bd2ef9..6228798d 100644 --- a/packages/app/src/Const.ts +++ b/packages/app/src/Const.ts @@ -39,9 +39,9 @@ export const ProfileCacheExpire = 1_000 * 60 * 30; * Default bootstrap relays */ export const DefaultRelays = new Map([ - ["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 */ diff --git a/packages/app/src/Element/Bookmarks.tsx b/packages/app/src/Element/Bookmarks.tsx index f74d6dbb..dda75eec 100644 --- a/packages/app/src/Element/Bookmarks.tsx +++ b/packages/app/src/Element/Bookmarks.tsx @@ -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("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]); diff --git a/packages/app/src/Element/DM.tsx b/packages/app/src/Element/DM.tsx index 8ebb5498..9c4bf72f 100644 --- a/packages/app/src/Element/DM.tsx +++ b/packages/app/src/Element/DM.tsx @@ -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(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 || ""); - if (!isMe) { - setLastReadDm(props.data.pubkey); - dispatch(incDmInteraction()); + if (publisher) { + const decrypted = await publisher.decryptDm(props.data); + setContent(decrypted || ""); + if (!isMe) { + setLastReadDm(props.data.pubkey); + } } } diff --git a/packages/app/src/Element/FollowButton.tsx b/packages/app/src/Element/FollowButton.tsx index 93eaab98..25259f09 100644 --- a/packages/app/src/Element/FollowButton.tsx +++ b/packages/app/src/Element/FollowButton.tsx @@ -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(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 ( diff --git a/packages/app/src/Element/FollowListBase.tsx b/packages/app/src/Element/FollowListBase.tsx index 5eaa3e48..c072bfcf 100644 --- a/packages/app/src/Element/FollowListBase.tsx +++ b/packages/app/src/Element/FollowListBase.tsx @@ -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 ( diff --git a/packages/app/src/Element/LogoutButton.tsx b/packages/app/src/Element/LogoutButton.tsx index 99e6b5c0..4a941434 100644 --- a/packages/app/src/Element/LogoutButton.tsx +++ b/packages/app/src/Element/LogoutButton.tsx @@ -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 ( { - dispatch( - logout(() => { - navigate("/"); - }) - ); + logout(publicKey); + navigate("/"); }}> diff --git a/packages/app/src/Element/MixCloudEmbed.tsx b/packages/app/src/Element/MixCloudEmbed.tsx index c24629a0..d3c5aa01 100644 --- a/packages/app/src/Element/MixCloudEmbed.tsx +++ b/packages/app/src/Element/MixCloudEmbed.tsx @@ -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(s => s.login.preferences.theme === "light"); - + const lightTheme = useLogin().preferences.theme === "light"; const lightParams = lightTheme ? "light=1" : "light=0"; - return ( <> diff --git a/packages/app/src/Element/Nip05.tsx b/packages/app/src/Element/Nip05.tsx index 975a3482..3ae6f093 100644 --- a/packages/app/src/Element/Nip05.tsx +++ b/packages/app/src/Element/Nip05.tsx @@ -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; diff --git a/packages/app/src/Element/Nip5Service.tsx b/packages/app/src/Element/Nip5Service.tsx index 8f10835d..4106d944 100644 --- a/packages/app/src/Element/Nip5Service.tsx +++ b/packages/app/src/Element/Nip5Service.tsx @@ -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, diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index 91276548..c8be3063 100644 --- a/packages/app/src/Element/Note.tsx +++ b/packages/app/src/Element/Note.tsx @@ -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(false); const [showMore, setShowMore] = useState(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(); 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); } } } diff --git a/packages/app/src/Element/NoteCreator.tsx b/packages/app/src/Element/NoteCreator.tsx index 581259ac..20658049 100644 --- a/packages/app/src/Element/NoteCreator.tsx +++ b/packages/app/src/Element/NoteCreator.tsx @@ -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> | 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)); diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx index 33fdd589..0bfa1007 100644 --- a/packages/app/src/Element/NoteFooter.tsx +++ b/packages/app/src/Element/NoteFooter.tsx @@ -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(s => s.login.publicKey); + const login = useLogin(); + const { pinned, bookmarked, publicKey, preferences: prefs, relays } = login; const { mute, block } = useModeration(); - const prefs = useSelector(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) { - {!pinned.includes(ev.id) && ( + {!pinned.item.includes(ev.id) && ( pin(ev.id)}> )} - {!bookmarked.includes(ev.id) && ( + {!bookmarked.item.includes(ev.id) && ( bookmark(ev.id)}> diff --git a/packages/app/src/Element/Poll.tsx b/packages/app/src/Element/Poll.tsx index df0d4083..0316c3f6 100644 --- a/packages/app/src/Element/Poll.tsx +++ b/packages/app/src/Element/Poll.tsx @@ -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; diff --git a/packages/app/src/Element/Relay.tsx b/packages/app/src/Element/Relay.tsx index ff69c018..989f22cd 100644 --- a/packages/app/src/Element/Relay.tsx +++ b/packages/app/src/Element/Relay.tsx @@ -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>(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() ); } diff --git a/packages/app/src/Element/RevealMedia.tsx b/packages/app/src/Element/RevealMedia.tsx index 5feeceb7..754b8b48 100644 --- a/packages/app/src/Element/RevealMedia.tsx +++ b/packages/app/src/Element/RevealMedia.tsx @@ -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; diff --git a/packages/app/src/Element/SendSats.tsx b/packages/app/src/Element/SendSats.tsx index 825eef5f..6228f7bb 100644 --- a/packages/app/src/Element/SendSats.tsx +++ b/packages/app/src/Element/SendSats.tsx @@ -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 = { 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); } } diff --git a/packages/app/src/Element/Zap.tsx b/packages/app/src/Element/Zap.tsx index fe162024..57c0044a 100644 --- a/packages/app/src/Element/Zap.tsx +++ b/packages/app/src/Element/Zap.tsx @@ -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 ? ( diff --git a/packages/app/src/ExternalStore.ts b/packages/app/src/ExternalStore.ts new file mode 100644 index 00000000..f9b77a88 --- /dev/null +++ b/packages/app/src/ExternalStore.ts @@ -0,0 +1,41 @@ +type HookFn = () => void; + +interface HookFilter { + fn: HookFn; +} + +/** + * Simple React hookable store with manual change notifications + */ +export default abstract class ExternalStore { + #hooks: Array = []; + #snapshot: Readonly = {} as Readonly; + #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; +} diff --git a/packages/app/src/Feed/BookmarkFeed.tsx b/packages/app/src/Feed/BookmarkFeed.tsx index b70bd42d..3412a75a 100644 --- a/packages/app/src/Feed/BookmarkFeed.tsx +++ b/packages/app/src/Feed/BookmarkFeed.tsx @@ -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); } diff --git a/packages/app/src/Feed/EventPublisher.ts b/packages/app/src/Feed/EventPublisher.ts index 92cf560c..475ae29d 100644 --- a/packages/app/src/Feed/EventPublisher.ts +++ b/packages/app/src/Feed/EventPublisher.ts @@ -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; - signEvent: (event: RawEvent) => Promise; - getRelays: () => Promise>; - nip04: { - encrypt: (pubkey: HexKey, content: string) => Promise; - decrypt: (pubkey: HexKey, content: string) => Promise; - }; - }; - } -} - -export type EventPublisher = ReturnType; +import useLogin from "Hooks/useLogin"; +import { EventPublisher } from "System/EventPublisher"; export default function useEventPublisher() { - const pubKey = useSelector(s => s.login.publicKey); - const privKey = useSelector(s => s.login.privateKey); - const follows = useSelector(s => s.login.follows); - const relays = useSelector((s: RootState) => s.login.relays); - const hasNip07 = "nostr" in window; - - async function signEvent(ev: RawEvent): Promise { - 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>, 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>) => { - 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>, 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) => { - 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 => { - if (pubKey) { - if (note.pubkey !== pubKey && !note.tags.some(a => a[1] === pubKey)) { - return ""; - } - 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 ""; - } - } - }, - 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>) => { - 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 (then: () => Promise): Promise => { - while (isNip07Busy) { - await delay(10); - } - isNip07Busy = true; - try { - return await then(); - } finally { - isNip07Busy = false; - } -}; diff --git a/packages/app/src/Feed/FollowsFeed.ts b/packages/app/src/Feed/FollowsFeed.ts index 813dfd8d..499af525 100644 --- a/packages/app/src/Feed/FollowsFeed.ts +++ b/packages/app/src/Feed/FollowsFeed.ts @@ -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, sub); return useMemo(() => { if (isMe) { - return follows; + return follows.item; } return getFollowing(contactFeed.data ?? [], pubkey); diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index 44277aba..02062c7f 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -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)(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)); - } -} diff --git a/packages/app/src/Feed/MuteList.ts b/packages/app/src/Feed/MuteList.ts index bbfa8cf1..4d551448 100644 --- a/packages/app/src/Feed/MuteList.ts +++ b/packages/app/src/Feed/MuteList.ts @@ -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, }; diff --git a/packages/app/src/Feed/PinnedFeed.tsx b/packages/app/src/Feed/PinnedFeed.tsx index c487a114..f8c6b5af 100644 --- a/packages/app/src/Feed/PinnedFeed.tsx +++ b/packages/app/src/Feed/PinnedFeed.tsx @@ -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); } diff --git a/packages/app/src/Feed/ThreadFeed.ts b/packages/app/src/Feed/ThreadFeed.ts index 66596257..c2675e99 100644 --- a/packages/app/src/Feed/ThreadFeed.ts +++ b/packages/app/src/Feed/ThreadFeed.ts @@ -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([link.id]); const [allEvents, setAllEvents] = useState([link.id]); - const pref = useSelector(s => s.login.preferences); + const pref = useLogin().preferences; const sub = useMemo(() => { const sub = new RequestBuilder(`thread:${link.id.substring(0, 8)}`); diff --git a/packages/app/src/Feed/TimelineFeed.ts b/packages/app/src/Feed/TimelineFeed.ts index dc80ddeb..c45526c2 100644 --- a/packages/app/src/Feed/TimelineFeed.ts +++ b/packages/app/src/Feed/TimelineFeed.ts @@ -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([]); const [trackingParentEvents, setTrackingParentEvents] = useState([]); - const pref = useSelector(s => s.login.preferences); + const pref = useLogin().preferences; const createBuilder = useCallback(() => { if (subject.type !== "global" && subject.items.length === 0) { diff --git a/packages/app/src/Hooks/useImgProxy.ts b/packages/app/src/Hooks/useImgProxy.ts index b2370811..901a1b96 100644 --- a/packages/app/src/Hooks/useImgProxy.ts +++ b/packages/app/src/Hooks/useImgProxy.ts @@ -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) { diff --git a/packages/app/src/Hooks/useLogin.tsx b/packages/app/src/Hooks/useLogin.tsx new file mode 100644 index 00000000..b4ffedff --- /dev/null +++ b/packages/app/src/Hooks/useLogin.tsx @@ -0,0 +1,9 @@ +import { LoginStore } from "Login"; +import { useSyncExternalStore } from "react"; + +export default function useLogin() { + return useSyncExternalStore( + s => LoginStore.hook(s), + () => LoginStore.snapshot() + ); +} diff --git a/packages/app/src/Hooks/useModeration.tsx b/packages/app/src/Hooks/useModeration.tsx index 92a10eee..ce20ad48 100644 --- a/packages/app/src/Hooks/useModeration.tsx +++ b/packages/app/src/Hooks/useModeration.tsx @@ -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, diff --git a/packages/app/src/Hooks/useNotelistSubscription.ts b/packages/app/src/Hooks/useNotelistSubscription.ts index 0ea08d4f..5e82d6a6 100644 --- a/packages/app/src/Hooks/useNotelistSubscription.ts +++ b/packages/app/src/Hooks/useNotelistSubscription.ts @@ -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(() => { diff --git a/packages/app/src/IntlProvider.tsx b/packages/app/src/IntlProvider.tsx index 2219df89..ed3f5493 100644 --- a/packages/app/src/IntlProvider.tsx +++ b/packages/app/src/IntlProvider.tsx @@ -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 ( diff --git a/packages/app/src/Login/Functions.ts b/packages/app/src/Login/Functions.ts new file mode 100644 index 00000000..498b7e51 --- /dev/null +++ b/packages/app/src/Login/Functions.ts @@ -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, createdAt: number) { + if (state.relays.timestamp > createdAt) { + return; + } + + // filter out non-websocket urls + const filtered = new Map(); + 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 = {}; + + 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, 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, 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, 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, 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, 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, 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); +} diff --git a/packages/app/src/Login/LoginSession.ts b/packages/app/src/Login/LoginSession.ts new file mode 100644 index 00000000..dd4776b6 --- /dev/null +++ b/packages/app/src/Login/LoginSession.ts @@ -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 { + 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>; + + /** + * A list of pubkeys this user follows + */ + follows: Newest>; + + /** + * A list of tags this user follows + */ + tags: Newest>; + + /** + * A list of event ids this user has pinned + */ + pinned: Newest>; + + /** + * A list of event ids this user has bookmarked + */ + bookmarked: Newest>; + + /** + * A list of pubkeys this user has muted + */ + muted: Newest>; + + /** + * A list of pubkeys this user has muted privately + */ + blocked: Newest>; + + /** + * Latest notification + */ + latestNotification: number; + + /** + * Timestamp of last read notification + */ + readNotifications: number; + + /** + * Users cusom preferences + */ + preferences: UserPreferences; + + /** + * Snort subscriptions licences + */ + subscriptions: Array; + + /** + * Current active subscription + */ + currentSubscription?: SubscriptionEvent; +} diff --git a/packages/app/src/Login/MultiAccountStore.ts b/packages/app/src/Login/MultiAccountStore.ts new file mode 100644 index 00000000..31850558 --- /dev/null +++ b/packages/app/src/Login/MultiAccountStore.ts @@ -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 { + #activeAccount?: HexKey; + #accounts: Map; + + constructor() { + super(); + const existing = window.localStorage.getItem(AccountStoreKey); + if (existing) { + this.#accounts = new Map((JSON.parse(existing) as Array).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) { + 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) { + 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(); + } +} diff --git a/packages/app/src/Login/Preferences.ts b/packages/app/src/Login/Preferences.ts new file mode 100644 index 00000000..005714d1 --- /dev/null +++ b/packages/app/src/Login/Preferences.ts @@ -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; diff --git a/packages/app/src/Login/index.ts b/packages/app/src/Login/index.ts new file mode 100644 index 00000000..d1ca2401 --- /dev/null +++ b/packages/app/src/Login/index.ts @@ -0,0 +1,6 @@ +import { MultiAccountStore } from "./MultiAccountStore"; +export const LoginStore = new MultiAccountStore(); + +export * from "./Preferences"; +export * from "./LoginSession"; +export * from "./Functions"; diff --git a/packages/app/src/Nip05/SnortServiceProvider.ts b/packages/app/src/Nip05/SnortServiceProvider.ts index fb603f3f..4fcc6d36 100644 --- a/packages/app/src/Nip05/SnortServiceProvider.ts +++ b/packages/app/src/Nip05/SnortServiceProvider.ts @@ -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 { - 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", diff --git a/packages/app/src/Notifications.ts b/packages/app/src/Notifications.ts index 46442051..5dc28164 100644 --- a/packages/app/src/Notifications.ts +++ b/packages/app/src/Notifications.ts @@ -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 { 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); + } + } +} diff --git a/packages/app/src/Pages/ChatPage.tsx b/packages/app/src/Pages/ChatPage.tsx index 29fc6fd9..3603c148 100644 --- a/packages/app/src/Pages/ChatPage.tsx +++ b/packages/app/src/Pages/ChatPage.tsx @@ -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(); const publisher = useEventPublisher(); const id = bech32ToHex(params.id ?? ""); - const pubKey = useSelector((s: RootState) => s.login.publicKey); + const pubKey = useLogin().publicKey; const [content, setContent] = useState(); const dmListRef = useRef(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(""); } diff --git a/packages/app/src/Pages/HashTagsPage.tsx b/packages/app/src/Pages/HashTagsPage.tsx index 01b5a358..8dc27e06 100644 --- a/packages/app/src/Pages/HashTagsPage.tsx +++ b/packages/app/src/Pages/HashTagsPage.tsx @@ -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 = () => { #{tag} {isFollowing ? ( - followTags(tags.filter(t => t !== tag))}> + followTags(login.tags.item.filter(t => t !== tag))}> ) : ( - followTags(tags.concat([tag]))}> + followTags(login.tags.item.concat([tag]))}> )} diff --git a/packages/app/src/Pages/Layout.css b/packages/app/src/Pages/Layout.css index def9d952..076bbd53 100644 --- a/packages/app/src/Pages/Layout.css +++ b/packages/app/src/Pages/Layout.css @@ -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; diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index 30086263..0036ed20 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -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 = {}; - - 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 ( {!shouldHideHeader && ( navigate("/")}> Snort - {subscription && ( + {currentSubscription && ( - {mapPlanName(subscription.type)} + {mapPlanName(currentSubscription.type)} )} @@ -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 ( navigate("/wallet")}> - + navigate("/search")}> @@ -264,7 +226,14 @@ const AccountHeader = () => { {hasNotifications && } - + { + if (profile) { + navigate(profileLink(profile.pubkey)); + } + }} + /> ); }; diff --git a/packages/app/src/Pages/Login.tsx b/packages/app/src/Pages/Login.tsx index f842c840..0586586e 100644 --- a/packages/app/src/Pages/Login.tsx +++ b/packages/app/src/Pages/Login.tsx @@ -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 = [ { 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 { } export default function LoginPage() { - const dispatch = useDispatch(); const navigate = useNavigate(); - const publicKey = useSelector(s => s.login.publicKey); + const login = useLogin(); const [key, setKey] = useState(""); const [error, setError] = useState(""); const [art, setArt] = useState(); @@ -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() { /> - makeRandomKey()}> + makeRandomKey()}> - + > ); diff --git a/packages/app/src/Pages/MessagesPage.tsx b/packages/app/src/Pages/MessagesPage.tsx index e31d83cc..27f86264 100644 --- a/packages/app/src/Pages/MessagesPage.tsx +++ b/packages/app/src/Pages/MessagesPage.tsx @@ -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(s => s.login.publicKey); - const dmInteraction = useSelector(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 ( @@ -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() { {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)} diff --git a/packages/app/src/Pages/NostrLinkHandler.tsx b/packages/app/src/Pages/NostrLinkHandler.tsx index 079ac13e..8cb2223f 100644 --- a/packages/app/src/Pages/NostrLinkHandler.tsx +++ b/packages/app/src/Pages/NostrLinkHandler.tsx @@ -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()}`); diff --git a/packages/app/src/Pages/Notifications.tsx b/packages/app/src/Pages/Notifications.tsx index 1af84d5c..6e9b9032 100644 --- a/packages/app/src/Pages/Notifications.tsx +++ b/packages/app/src/Pages/Notifications.tsx @@ -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(s => s.login.publicKey); + const login = useLogin(); useEffect(() => { - dispatch(markNotificationsRead()); + markNotificationsRead(login); }, []); return ( @@ -19,12 +17,12 @@ export default function NotificationsPage() { - {pubkey && ( + {login.publicKey && ( (); const user = useUserProfile(id); - const loginPubKey = useSelector((s: RootState) => s.login.publicKey); + const loginPubKey = useLogin().publicKey; const isMe = loginPubKey === id; const [showLnQr, setShowLnQr] = useState(false); const [showProfileQr, setShowProfileQr] = useState(false); diff --git a/packages/app/src/Pages/Root.tsx b/packages/app/src/Pages/Root.tsx index eef64963..9f038f5c 100644 --- a/packages/app/src/Pages/Root.tsx +++ b/packages/app/src/Pages/Root.tsx @@ -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 = { 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 ( { }; const GlobalTab = () => { - const { relays } = useSelector((s: RootState) => s.login); + const { relays } = useLogin(); const [relay, setRelay] = useState(); const [allRelays, setAllRelays] = useState(); 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 ; }; diff --git a/packages/app/src/Pages/new/DiscoverFollows.tsx b/packages/app/src/Pages/new/DiscoverFollows.tsx index 7fbf8e45..adef55c0 100644 --- a/packages/app/src/Pages/new/DiscoverFollows.tsx +++ b/packages/app/src/Pages/new/DiscoverFollows.tsx @@ -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("/"); } diff --git a/packages/app/src/Pages/new/GetVerified.tsx b/packages/app/src/Pages/new/GetVerified.tsx index 79265e38..d3d7436f 100644 --- a/packages/app/src/Pages/new/GetVerified.tsx +++ b/packages/app/src/Pages/new/GetVerified.tsx @@ -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"; diff --git a/packages/app/src/Pages/new/ImportFollows.tsx b/packages/app/src/Pages/new/ImportFollows.tsx index 1ac5e458..dccb7709 100644 --- a/packages/app/src/Pages/new/ImportFollows.tsx +++ b/packages/app/src/Pages/new/ImportFollows.tsx @@ -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(""); const [follows, setFollows] = useState([]); @@ -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() { diff --git a/packages/app/src/Pages/new/NewUserFlow.tsx b/packages/app/src/Pages/new/NewUserFlow.tsx index 891ea943..f34cc2d2 100644 --- a/packages/app/src/Pages/new/NewUserFlow.tsx +++ b/packages/app/src/Pages/new/NewUserFlow.tsx @@ -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() { - - - - diff --git a/packages/app/src/Pages/new/NewUsername.tsx b/packages/app/src/Pages/new/NewUsername.tsx index e604e5ac..f4699718 100644 --- a/packages/app/src/Pages/new/NewUsername.tsx +++ b/packages/app/src/Pages/new/NewUsername.tsx @@ -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"); diff --git a/packages/app/src/Pages/settings/Index.tsx b/packages/app/src/Pages/settings/Index.tsx index d04332d1..403ecf39 100644 --- a/packages/app/src/Pages/settings/Index.tsx +++ b/packages/app/src/Pages/settings/Index.tsx @@ -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 = () => { navigate("wallet")}> - + diff --git a/packages/app/src/Pages/settings/Preferences.tsx b/packages/app/src/Pages/settings/Preferences.tsx index 894c3801..e477d7d4 100644 --- a/packages/app/src/Pages/settings/Preferences.tsx +++ b/packages/app/src/Pages/settings/Preferences.tsx @@ -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(s => s.login.preferences); + const login = useLogin(); + const perf = login.preferences; return ( @@ -32,12 +32,10 @@ const PreferencesPage = () => { - 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 = () => { - dispatch( - setPreferences({ - ...perf, - theme: e.target.value, - } as UserPreferences) - ) + updatePreferences(login, { + ...perf, + theme: e.target.value, + } as UserPreferences) }> @@ -91,12 +87,10 @@ const PreferencesPage = () => { - dispatch( - setPreferences({ - ...perf, - defaultRootTab: e.target.value, - } as UserPreferences) - ) + updatePreferences(login, { + ...perf, + defaultRootTab: e.target.value, + } as UserPreferences) }> @@ -122,12 +116,10 @@ const PreferencesPage = () => { - dispatch( - setPreferences({ - ...perf, - autoLoadMedia: e.target.value, - } as UserPreferences) - ) + updatePreferences(login, { + ...perf, + autoLoadMedia: e.target.value, + } as UserPreferences) }> @@ -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") })} /> @@ -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 })} /> @@ -206,7 +198,7 @@ const PreferencesPage = () => { dispatch(setPreferences({ ...perf, autoZap: e.target.checked }))} + onChange={e => updatePreferences(login, { ...perf, autoZap: e.target.checked })} /> @@ -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, + }) } /> @@ -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, + }, + }) } /> @@ -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, + }, + }) } /> @@ -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, + }, + }) } /> @@ -331,7 +315,7 @@ const PreferencesPage = () => { dispatch(setPreferences({ ...perf, enableReactions: e.target.checked }))} + onChange={e => updatePreferences(login, { ...perf, enableReactions: e.target.checked })} /> @@ -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, + }) }> + @@ -382,7 +364,7 @@ const PreferencesPage = () => { dispatch(setPreferences({ ...perf, confirmReposts: e.target.checked }))} + onChange={e => updatePreferences(login, { ...perf, confirmReposts: e.target.checked })} /> @@ -399,7 +381,7 @@ const PreferencesPage = () => { dispatch(setPreferences({ ...perf, autoShowLatest: e.target.checked }))} + onChange={e => updatePreferences(login, { ...perf, autoShowLatest: e.target.checked })} /> @@ -415,12 +397,10 @@ const PreferencesPage = () => { - dispatch( - setPreferences({ - ...perf, - fileUploader: e.target.value, - } as UserPreferences) - ) + updatePreferences(login, { + ...perf, + fileUploader: e.target.value, + } as UserPreferences) }> void.cat @@ -444,7 +424,7 @@ const PreferencesPage = () => { dispatch(setPreferences({ ...perf, showDebugMenus: e.target.checked }))} + onChange={e => updatePreferences(login, { ...perf, showDebugMenus: e.target.checked })} /> diff --git a/packages/app/src/Pages/settings/Profile.tsx b/packages/app/src/Pages/settings/Profile.tsx index a48ed0b6..6b9b494a 100644 --- a/packages/app/src/Pages/settings/Profile.tsx +++ b/packages/app/src/Pages/settings/Profile.tsx @@ -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(s => s.login.publicKey); - const privKey = useSelector(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); + } } } diff --git a/packages/app/src/Pages/settings/RelayInfo.tsx b/packages/app/src/Pages/settings/RelayInfo.tsx index f3f87833..ac51b073 100644 --- a/packages/app/src/Pages/settings/RelayInfo.tsx +++ b/packages/app/src/Pages/settings/RelayInfo.tsx @@ -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 = () => { { - dispatch(removeRelay(unwrap(conn).Address)); + removeRelay(login, unwrap(conn).Address); navigate("/settings/relays"); }}> diff --git a/packages/app/src/Pages/settings/Relays.tsx b/packages/app/src/Pages/settings/Relays.tsx index 0da386f2..7cb7572e 100644 --- a/packages/app/src/Pages/settings/Relays.tsx +++ b/packages/app/src/Pages/settings/Relays.tsx @@ -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(); 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 = () => { - {Object.keys(relays || {}).map(a => ( + {Object.keys(relays.item || {}).map(a => ( ))} diff --git a/packages/app/src/Pages/settings/handle/LNAddress.tsx b/packages/app/src/Pages/settings/handle/LNAddress.tsx index 2f17f634..8f282eba 100644 --- a/packages/app/src/Pages/settings/handle/LNAddress.tsx +++ b/packages/app/src/Pages/settings/handle/LNAddress.tsx @@ -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); diff --git a/packages/app/src/Pages/settings/handle/ListHandles.tsx b/packages/app/src/Pages/settings/handle/ListHandles.tsx index 612f4895..e1c00fbc 100644 --- a/packages/app/src/Pages/settings/handle/ListHandles.tsx +++ b/packages/app/src/Pages/settings/handle/ListHandles.tsx @@ -10,13 +10,14 @@ export default function ListHandles() { const navigate = useNavigate(); const publisher = useEventPublisher(); const [handles, setHandles] = useState>([]); - 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); } diff --git a/packages/app/src/Pages/settings/handle/TransferHandle.tsx b/packages/app/src/Pages/settings/handle/TransferHandle.tsx index d18712fc..bbe39ff1 100644 --- a/packages/app/src/Pages/settings/handle/TransferHandle.tsx +++ b/packages/app/src/Pages/settings/handle/TransferHandle.tsx @@ -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>([]); 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) { diff --git a/packages/app/src/Pages/subscribe/index.tsx b/packages/app/src/Pages/subscribe/index.tsx index 316a7409..c010e3c3 100644 --- a/packages/app/src/Pages/subscribe/index.tsx +++ b/packages/app/src/Pages/subscribe/index.tsx @@ -58,9 +58,10 @@ export function SubscribePage() { {mapPlanName(a.id)} {formatShort(a.price)}, + plan: mapPlanName(a.id), + price: {formatShort(a.price)} sats/mo, }} /> : @@ -86,10 +87,7 @@ export function SubscribePage() { {a.disabled ? ( ) : ( - + )} diff --git a/packages/app/src/SnortApi.ts b/packages/app/src/SnortApi.ts index c168c7dd..88c6aa64 100644 --- a/packages/app/src/SnortApi.ts +++ b/packages/app/src/SnortApi.ts @@ -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"); } diff --git a/packages/app/src/State/Login.ts b/packages/app/src/State/Login.ts deleted file mode 100644 index 5430f94a..00000000 --- a/packages/app/src/State/Login.ts +++ /dev/null @@ -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; - - /** - * 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; - - /** - * 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; - 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) => { - 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) => { - 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) => { - window.localStorage.setItem(PublicKeyItem, action.payload); - state.loggedOut = false; - state.publicKey = action.payload; - }, - setRelays: (state, action: PayloadAction) => { - const relays = action.payload.relays; - const createdAt = action.payload.createdAt; - if (state.latestRelays > createdAt) { - return; - } - - // filter out non-websocket urls - const filtered = new Map(); - 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) => { - delete state.relays[action.payload]; - state.relays = { ...state.relays }; - window.localStorage.setItem(RelayListKey, JSON.stringify(state.relays)); - }, - setFollows: (state, action: PayloadAction) => { - 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) => { - state.latestNotification = action.payload; - }, - setPreferences: (state, action: PayloadAction) => { - state.preferences = action.payload; - window.localStorage.setItem(UserPreferencesKey, JSON.stringify(state.preferences)); - }, - addSubscription: (state, action: PayloadAction>) => { - 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 { - 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; diff --git a/packages/app/src/State/Store.ts b/packages/app/src/State/Store.ts index ba8989bd..24132725 100644 --- a/packages/app/src/State/Store.ts +++ b/packages/app/src/State/Store.ts @@ -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, }, }); diff --git a/packages/app/src/Subscription/index.ts b/packages/app/src/Subscription/index.ts index a3db06fa..8349beee 100644 --- a/packages/app/src/Subscription/index.ts +++ b/packages/app/src/Subscription/index.ts @@ -18,7 +18,7 @@ export const Plans = [ { id: SubscriptionType.Supporter, price: 5_000, - disabled: true, + disabled: false, unlocks: [LockedFeatures.MultiAccount, LockedFeatures.NostrAddress, LockedFeatures.Badge], }, { diff --git a/packages/app/src/System/EventBuilder.ts b/packages/app/src/System/EventBuilder.ts new file mode 100644 index 00000000..d70d15e2 --- /dev/null +++ b/packages/app/src/System/EventBuilder.ts @@ -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> = []; + + 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) { + 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; + } +} diff --git a/packages/app/src/System/EventExt.ts b/packages/app/src/System/EventExt.ts index b1b8d189..50c00589 100644 --- a/packages/app/src/System/EventExt.ts +++ b/packages/app/src/System/EventExt.ts @@ -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)); diff --git a/packages/app/src/System/EventPublisher.ts b/packages/app/src/System/EventPublisher.ts new file mode 100644 index 00000000..dd033064 --- /dev/null +++ b/packages/app/src/System/EventPublisher.ts @@ -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; + signEvent: (event: RawEvent) => Promise; + getRelays: () => Promise>; + nip04: { + encrypt: (pubkey: HexKey, content: string) => Promise; + decrypt: (pubkey: HexKey, content: string) => Promise; + }; + }; + } +} + +interface Nip7QueueItem { + next: () => Promise; + resolve(v: unknown): void; + reject(e: unknown): void; +} + +const Nip7QueueDelay = 200; +const Nip7Queue: Array = []; +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 (then: () => Promise): Promise => { + return new Promise((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, + 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 | Record) { + 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, relays: Record) { + 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); + } +} diff --git a/packages/app/src/System/index.ts b/packages/app/src/System/index.ts index 1e59ae9a..c5c254a1 100644 --- a/packages/app/src/System/index.ts +++ b/packages/app/src/System/index.ts @@ -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 { diff --git a/packages/app/src/Tasks/TaskList.tsx b/packages/app/src/Tasks/TaskList.tsx index 68c3c6ef..08397dd9 100644 --- a/packages/app/src/Tasks/TaskList.tsx +++ b/packages/app/src/Tasks/TaskList.tsx @@ -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 = [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(0); diff --git a/packages/app/src/Upload/index.ts b/packages/app/src/Upload/index.ts index 10ea1998..a3bf6da0 100644 --- a/packages/app/src/Upload/index.ts +++ b/packages/app/src/Upload/index.ts @@ -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": { diff --git a/packages/app/src/Util.ts b/packages/app/src/Util.ts index 6cf49248..2aee55e6 100644 --- a/packages/app/src/Util.ts +++ b/packages/app/src/Util.ts @@ -154,6 +154,14 @@ export function unixNowMs() { return new Date().getTime(); } +export function deepClone(obj: T) { + if ("structuredClone" in window) { + return structuredClone(obj); + } else { + return JSON.parse(JSON.stringify(obj)); + } +} + /** * Simple debounce */ diff --git a/packages/app/src/lang.json b/packages/app/src/lang.json index 9bb4b50f..702474c0 100644 --- a/packages/app/src/lang.json +++ b/packages/app/src/lang.json @@ -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" }, diff --git a/packages/app/src/nip6.ts b/packages/app/src/nip6.ts index 89639c4c..9f7f150c 100644 --- a/packages/app/src/nip6.ts +++ b/packages/app/src/nip6.ts @@ -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); diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index d62d7664..78cf69b3 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -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" -} +} \ No newline at end of file
{formatShort(a.price)}, + plan: mapPlanName(a.id), + price: {formatShort(a.price)} sats/mo, }} /> : @@ -86,10 +87,7 @@ export function SubscribePage() { {a.disabled ? ( ) : ( - + )}