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

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

View File

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

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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