1
0
forked from Kieran/snort

feat: multi-account system

This commit is contained in:
Kieran 2023-04-14 12:33:19 +01:00
parent 723589fec7
commit fe788853c9
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
58 changed files with 966 additions and 1080 deletions

View File

@ -74,6 +74,15 @@ export const RecommendedFollows = [
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha
];
/**
* Snort imgproxy details
*/
export const DefaultImgProxy = {
url: "https://imgproxy.snort.social",
key: "a82fcf26aa0ccb55dfc6b4bd6a1c90744d3be0f38429f21a8828b43449ce7cebe6bdc2b09a827311bef37b18ce35cb1e6b1c60387a254541afa9e5b4264ae942",
salt: "a897770d9abf163de055e9617891214e75a9016d748f8ef865e6ffbcb9ed932295659549773a22a019a5f06d0b440c320be411e3fddfe784e199e4f03d74bd9b",
};
/**
* NIP06-defined derivation path for private keys
*/

View File

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

View File

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

View File

@ -1,10 +1,10 @@
import "./FollowButton.css";
import { useSelector } from "react-redux";
import { FormattedMessage } from "react-intl";
import useEventPublisher from "Feed/EventPublisher";
import { HexKey } from "@snort/nostr";
import { RootState } from "State/Store";
import useEventPublisher from "Feed/EventPublisher";
import { parseId } from "Util";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
@ -15,7 +15,7 @@ export interface FollowButtonProps {
export default function FollowButton(props: FollowButtonProps) {
const pubkey = parseId(props.pubkey);
const publiser = useEventPublisher();
const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);
const isFollowing = useLogin().follows.item.includes(pubkey);
const baseClassname = `${props.className} follow-button`;
async function follow(pubkey: HexKey) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,13 +17,14 @@ import SendSats from "Element/SendSats";
import { ParsedZap, ZapsSummary } from "Element/Zap";
import { useUserProfile } from "Hooks/useUserProfile";
import { RootState } from "State/Store";
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
import { setReplyTo, setShow, reset } from "State/NoteCreator";
import useModeration from "Hooks/useModeration";
import { SnortPubKey, TranslateHost } from "Const";
import { LNURL } from "LNURL";
import { DonateLNURL } from "Pages/DonatePage";
import { useWallet } from "Wallet";
import useLogin from "Hooks/useLogin";
import { setBookmarked, setPinned } from "Login";
import messages from "./messages";
@ -94,10 +95,9 @@ export default function NoteFooter(props: NoteFooterProps) {
const { ev, showReactions, setShowReactions, positive, negative, reposts, zaps } = props;
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const login = useLogin();
const { pinned, bookmarked, publicKey, preferences: prefs } = login;
const { mute, block } = useModeration();
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const author = useUserProfile(ev.pubkey);
const publisher = useEventPublisher();
const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show);
@ -108,13 +108,13 @@ export default function NoteFooter(props: NoteFooterProps) {
const walletState = useWallet();
const wallet = walletState.wallet;
const isMine = ev.pubkey === login;
const isMine = ev.pubkey === publicKey;
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = ZapCache.has(ev.id) || zaps.some(a => a.sender === login);
const didZap = ZapCache.has(ev.id) || zaps.some(a => a.sender === publicKey);
const longPress = useLongPress(
e => {
e.stopPropagation();
@ -126,11 +126,11 @@ export default function NoteFooter(props: NoteFooterProps) {
);
function hasReacted(emoji: string) {
return positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login);
return positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey);
}
function hasReposted() {
return reposts.some(a => a.pubkey === login);
return reposts.some(a => a.pubkey === publicKey);
}
async function react(content: string) {
@ -320,17 +320,21 @@ export default function NoteFooter(props: NoteFooterProps) {
}
async function pin(id: HexKey) {
const es = [...pinned, id];
const es = [...pinned.item, id];
const ev = await publisher.pinned(es);
publisher.broadcast(ev);
dispatch(setPinned({ keys: es, createdAt: new Date().getTime() }));
if (ev) {
publisher.broadcast(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
async function bookmark(id: HexKey) {
const es = [...bookmarked, id];
const es = [...bookmarked.item, id];
const ev = await publisher.bookmarked(es);
publisher.broadcast(ev);
dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() }));
if (ev) {
publisher.broadcast(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
async function copyEvent() {
@ -355,13 +359,13 @@ export default function NoteFooter(props: NoteFooterProps) {
<Icon name="share" />
<FormattedMessage {...messages.Share} />
</MenuItem>
{!pinned.includes(ev.id) && (
{!pinned.item.includes(ev.id) && (
<MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!bookmarked.includes(ev.id) && (
{!bookmarked.item.includes(ev.id) && (
<MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />

View File

@ -1,12 +1,10 @@
import { TaggedRawEvent } from "@snort/nostr";
import { useState } from "react";
import { useSelector } from "react-redux";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { ParsedZap } from "Element/Zap";
import Text from "Element/Text";
import useEventPublisher from "Feed/EventPublisher";
import { RootState } from "State/Store";
import { useWallet } from "Wallet";
import { useUserProfile } from "Hooks/useUserProfile";
import { LNURL } from "LNURL";
@ -14,6 +12,7 @@ import { unwrap } from "Util";
import { formatShort } from "Number";
import Spinner from "Icons/Spinner";
import SendSats from "Element/SendSats";
import useLogin from "Hooks/useLogin";
interface PollProps {
ev: TaggedRawEvent;
@ -24,8 +23,7 @@ export default function Poll(props: PollProps) {
const { formatMessage } = useIntl();
const publisher = useEventPublisher();
const { wallet } = useWallet();
const prefs = useSelector((s: RootState) => s.login.preferences);
const myPubKey = useSelector((s: RootState) => s.login.publicKey);
const { preferences: prefs, publicKey: myPubKey } = useLogin();
const pollerProfile = useUserProfile(props.ev.pubkey);
const [error, setError] = useState("");
const [invoice, setInvoice] = useState("");

View File

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

View File

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

View File

@ -1,11 +1,9 @@
import "./SendSats.css";
import React, { useEffect, useMemo, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { HexKey, RawEvent } from "@snort/nostr";
import { formatShort } from "Number";
import { RootState } from "State/Store";
import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher";
import ProfileImage from "Element/ProfileImage";
@ -18,6 +16,7 @@ import { useWallet } from "Wallet";
import { EventExt } from "System/EventExt";
import messages from "./messages";
import useLogin from "Hooks/useLogin";
enum ZapType {
PublicZap = 1,
@ -41,7 +40,7 @@ export interface SendSatsProps {
export default function SendSats(props: SendSatsProps) {
const onClose = props.onClose || (() => undefined);
const { note, author, target } = props;
const defaultZapAmount = useSelector((s: RootState) => s.login.preferences.defaultZapAmount);
const defaultZapAmount = useLogin().preferences.defaultZapAmount;
const amounts = [defaultZapAmount, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000];
const emojis: Record<number, string> = {
1_000: "👍",

View File

@ -13,6 +13,7 @@ import { findTag } from "Util";
import { UserCache } from "Cache/UserCache";
import messages from "./messages";
import useLogin from "Hooks/useLogin";
function getInvoice(zap: TaggedRawEvent): InvoiceDetails | undefined {
const bolt11 = findTag(zap, "bolt11");
@ -103,7 +104,7 @@ export interface ParsedZap {
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean }) => {
const { amount, content, sender, valid, receiver } = zap;
const pubKey = useSelector((s: RootState) => s.login.publicKey);
const pubKey = useLogin().publicKey;
return valid && sender ? (
<div className="zap note card">

View File

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

View File

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

View File

@ -1,13 +1,12 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import * as secp from "@noble/secp256k1";
import { EventKind, RelaySettings, TaggedRawEvent, HexKey, RawEvent, u256, UserMetadata, Lists } from "@snort/nostr";
import { RootState } from "State/Store";
import { bech32ToHex, delay, unwrap } from "Util";
import { DefaultRelays, HashtagRegex } from "Const";
import { System } from "System";
import { EventExt } from "System/EventExt";
import useLogin from "Hooks/useLogin";
declare global {
interface Window {
@ -26,10 +25,7 @@ declare global {
export type EventPublisher = ReturnType<typeof useEventPublisher>;
export default function useEventPublisher() {
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
const relays = useSelector((s: RootState) => s.login.relays);
const { publicKey: pubKey, privateKey: privKey, follows, relays } = useLogin();
const hasNip07 = "nostr" in window;
async function signEvent(ev: RawEvent): Promise<RawEvent> {
@ -270,7 +266,7 @@ export default function useEventPublisher() {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
ev.content = JSON.stringify(relays);
for (const pk of follows) {
for (const pk of follows.item) {
ev.tags.push(["p", pk]);
}
@ -297,7 +293,7 @@ export default function useEventPublisher() {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
ev.content = JSON.stringify(newRelays ?? relays);
const temp = new Set(follows);
const temp = new Set(follows.item);
if (Array.isArray(pkAdd)) {
pkAdd.forEach(a => temp.add(a));
} else {
@ -317,7 +313,7 @@ export default function useEventPublisher() {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.ContactList);
ev.content = JSON.stringify(relays);
for (const pk of follows) {
for (const pk of follows.item) {
if (pk === pkRemove || pk.length !== 64) {
continue;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,143 @@
import { HexKey, RelaySettings } from "@snort/nostr";
import * as secp from "@noble/secp256k1";
import { DefaultRelays, SnortPubKey } from "Const";
import { EventPublisher } from "Feed/EventPublisher";
import { LoginStore, UserPreferences, LoginSession } from "Login";
import { generateBip39Entropy, entropyToDerivedKey } from "nip6";
import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unixNowMs } from "Util";
import { getCurrentSubscription, SubscriptionEvent } from "Subscription";
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(publisher: EventPublisher) {
const ent = generateBip39Entropy();
const entHex = secp.utils.bytesToHex(ent);
const newKeyHex = entropyToDerivedKey(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 => [a, { read: true, write: true }]);
newRelays = {
...Object.fromEntries(relayObjects),
...Object.fromEntries(DefaultRelays.entries()),
};
}
} catch (e) {
console.warn(e);
}
const ev = await publisher.addFollow([bech32ToHex(SnortPubKey), newKeyHex], newRelays);
publisher.broadcast(ev);
LoginStore.loginWithPrivateKey(newKeyHex, entHex);
}
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,181 @@
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) {
const pubKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(key));
if (this.#accounts.has(pubKey)) {
throw new Error("Already logged in with this pubkey");
}
this.#accounts.set(pubKey, {
...LoggedOut,
privateKey: key,
publicKey: pubKey,
generatedEntropy: entropy,
preferences: deepClone(DefaultPreferences),
} as LoginSession);
this.#activeAccount = pubKey;
this.#save();
}
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import { TimelineSubject } from "Feed/TimelineFeed";
import { debounce, getRelayName, sha256, unixNow, unwrap } from "Util";
import messages from "./messages";
import useLogin from "Hooks/useLogin";
interface RelayOption {
url: string;
@ -22,7 +23,7 @@ export default function RootPage() {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const location = useLocation();
const { publicKey: pubKey, tags, preferences } = useSelector((s: RootState) => s.login);
const { publicKey: pubKey, tags, preferences } = useLogin();
const RootTab: Record<string, Tab> = {
Posts: {
@ -65,7 +66,7 @@ export default function RootPage() {
}
}, [location]);
const tagTabs = tags.map((t, idx) => {
const tagTabs = tags.item.map((t, idx) => {
return { text: `#${t}`, value: idx + 3, data: `/tag/${t}` };
});
const tabs = [RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global, ...tagTabs];
@ -81,8 +82,8 @@ export default function RootPage() {
}
const FollowsHint = () => {
const { publicKey: pubKey, follows } = useSelector((s: RootState) => s.login);
if (follows?.length === 0 && pubKey) {
const { publicKey: pubKey, follows } = useLogin();
if (follows.item?.length === 0 && pubKey) {
return (
<FormattedMessage
{...messages.NoFollows}
@ -100,7 +101,7 @@ const FollowsHint = () => {
};
const GlobalTab = () => {
const { relays } = useSelector((s: RootState) => s.login);
const { relays } = useLogin();
const [relay, setRelay] = useState<RelayOption>();
const [allRelays, setAllRelays] = useState<RelayOption[]>();
const [now] = useState(unixNow());
@ -177,8 +178,8 @@ const GlobalTab = () => {
};
const PostsTab = () => {
const follows = useSelector((s: RootState) => s.login.follows);
const subject: TimelineSubject = { type: "pubkey", items: follows, discriminator: "follows" };
const { follows } = useLogin();
const subject: TimelineSubject = { type: "pubkey", items: follows.item, discriminator: "follows" };
return (
<>
@ -189,8 +190,8 @@ const PostsTab = () => {
};
const ConversationsTab = () => {
const { follows } = useSelector((s: RootState) => s.login);
const subject: TimelineSubject = { type: "pubkey", items: follows, discriminator: "follows" };
const { follows } = useLogin();
const subject: TimelineSubject = { type: "pubkey", items: follows.item, discriminator: "follows" };
return <Timeline subject={subject} postsOnly={false} method={"TIME_RANGE"} window={undefined} relay={undefined} />;
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +1,20 @@
import "./Index.css";
import { FormattedMessage } from "react-intl";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import Icon from "Icons/Icon";
import { logout } from "State/Login";
import { logout } from "Login";
import useLogin from "Hooks/useLogin";
import { unwrap } from "Util";
import messages from "./messages";
const SettingsIndex = () => {
const dispatch = useDispatch();
const login = useLogin();
const navigate = useNavigate();
function handleLogout() {
dispatch(
logout(() => {
navigate("/");
})
);
logout(unwrap(login.publicKey));
navigate("/");
}
return (

View File

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

View File

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

View File

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

View File

@ -1,24 +1,23 @@
import { useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
import { randomSample } from "Util";
import { randomSample, unixNowMs } from "Util";
import Relay from "Element/Relay";
import useEventPublisher from "Feed/EventPublisher";
import { RootState } from "State/Store";
import { setRelays } from "State/Login";
import { System } from "System";
import messages from "./messages";
import useLogin from "Hooks/useLogin";
import { setRelays } from "Login";
const RelaySettingsPage = () => {
const dispatch = useDispatch();
const publisher = useEventPublisher();
const relays = useSelector((s: RootState) => s.login.relays);
const login = useLogin();
const relays = login.relays;
const [newRelay, setNewRelay] = useState<string>();
const otherConnections = useMemo(() => {
return [...System.Sockets.keys()].filter(a => relays[a] === undefined);
return [...System.Sockets.keys()].filter(a => relays.item[a] === undefined);
}, [relays]);
async function saveRelays() {
@ -69,13 +68,10 @@ const RelaySettingsPage = () => {
if ((newRelay?.length ?? 0) > 0) {
const parsed = new URL(newRelay ?? "");
const payload = {
relays: {
...relays,
[parsed.toString()]: { read: false, write: false },
},
createdAt: Math.floor(new Date().getTime() / 1000),
...relays.item,
[parsed.toString()]: { read: true, write: true },
};
dispatch(setRelays(payload));
setRelays(login, payload, unixNowMs());
}
}
@ -85,7 +81,7 @@ const RelaySettingsPage = () => {
<FormattedMessage {...messages.Relays} />
</h3>
<div className="flex f-col mb10">
{Object.keys(relays || {}).map(a => (
{Object.keys(relays.item || {}).map(a => (
<Relay addr={a} key={a} />
))}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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