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 ( 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 ? ( - ) : ( - )} 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() { />

- +
); 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, - theme: e.target.value, - } as UserPreferences) - ) + updatePreferences(login, { + ...perf, + theme: e.target.value, + } as UserPreferences) }>
@@ -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, + }) }>