add basic accounts

This commit is contained in:
hzrd149 2023-02-14 09:41:25 -06:00
parent 8b660b4697
commit 621a1a2aea
26 changed files with 174 additions and 149 deletions

View File

@ -24,22 +24,25 @@ import NotificationsView from "./views/notifications";
import { RelaysView } from "./views/relays";
import useSubject from "./hooks/use-subject";
import { LoginNip05View } from "./views/login/nip05";
import { Spinner } from "@chakra-ui/react";
const RequireSetup = ({ children }: { children: JSX.Element }) => {
const RequireAccount = ({ children }: { children: JSX.Element }) => {
let location = useLocation();
const setup = useSubject(accountService.setup);
const loading = useSubject(accountService.loading);
const account = useSubject(accountService.current);
if (!setup) return <Navigate to="/login" state={{ from: location.pathname }} replace />;
if (loading) return <Spinner />;
if (!account) return <Navigate to="/login" state={{ from: location.pathname }} replace />;
return children;
};
const RootPage = () => (
<RequireSetup>
<RequireAccount>
<Page>
<Outlet />
</Page>
</RequireSetup>
</RequireAccount>
);
const router = createBrowserRouter([

View File

@ -1,6 +1,6 @@
import relayPoolService from "../services/relay-pool";
import { NostrEvent } from "../types/nostr-event";
import Deferred from "./deferred";
import createDefer from "./deferred";
import { IncomingCommandResult, Relay } from "./relay";
import { ListenerFn, Subject } from "./subject";
@ -8,7 +8,7 @@ export type PostResult = { url: string; message?: string; status: boolean };
export function nostrPostAction(relays: string[], event: NostrEvent, timeout: number = 5000) {
const subject = new Subject<PostResult>();
const onComplete = new Deferred<void>();
const onComplete = createDefer<void>();
const remaining = new Map<Relay, ListenerFn<IncomingCommandResult>>();
for (const url of relays) {

View File

@ -3,7 +3,7 @@ import { NostrQuery } from "../types/nostr-query";
import relayPoolService from "../services/relay-pool";
import { IncomingEOSE, IncomingEvent, Relay } from "./relay";
import Subject from "./subject";
import Deferred from "./deferred";
import createDefer from "./deferred";
let lastId = 0;
@ -19,7 +19,7 @@ export class NostrRequest {
relayCleanup = new Map<Relay, Function>();
state = NostrRequest.IDLE;
onEvent = new Subject<NostrEvent>();
onComplete = new Deferred<void>();
onComplete = createDefer<void>();
seenEvents = new Set<string>();
constructor(relayUrls: string[], timeout?: number) {

View File

@ -8,7 +8,6 @@ import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
import { NoteContents } from "./note-contents";
import { NoteMenu } from "./note-menu";
import accountService from "../../services/account";
import { useUserContacts } from "../../hooks/use-user-contacts";
import { UserTipButton } from "../user-tip-button";
import { NoteRelays } from "./note-relays";
@ -18,9 +17,8 @@ import { ReplyIcon } from "../icons";
import { PostModalContext } from "../../providers/post-modal-provider";
import { buildReply } from "../../helpers/nostr-event";
import { UserDnsIdentityIcon } from "../user-dns-identity";
import { useReadonlyMode } from "../../hooks/use-readonly-mode";
import { convertTimestampToDate } from "../../helpers/date";
import useSubject from "../../hooks/use-subject";
import { useCurrentAccount } from "../../hooks/use-current-account";
export type NoteProps = {
event: NostrEvent;
@ -28,11 +26,10 @@ export type NoteProps = {
};
export const Note = React.memo(({ event, maxHeight }: NoteProps) => {
const isMobile = useIsMobile();
const readonly = useReadonlyMode();
const account = useCurrentAccount();
const { openModal } = useContext(PostModalContext);
const pubkey = useSubject(accountService.pubkey) ?? "";
const contacts = useUserContacts(pubkey);
const contacts = useUserContacts(account.pubkey);
const following = contacts?.contacts || [];
const reply = () => openModal(buildReply(event));
@ -63,7 +60,7 @@ export const Note = React.memo(({ event, maxHeight }: NoteProps) => {
aria-label="Reply"
onClick={reply}
size="xs"
isDisabled={readonly}
isDisabled={account.readonly}
/>
<Box flexGrow={1} />
<UserTipButton pubkey={event.pubkey} size="xs" />

View File

@ -13,16 +13,15 @@ import { PostModalProvider } from "../providers/post-modal-provider";
import { useReadonlyMode } from "../hooks/use-readonly-mode";
import { ProfileButton } from "./profile-button";
import { UserAvatarLink } from "./user-avatar-link";
import useSubject from "../hooks/use-subject";
import { useCurrentAccount } from "../hooks/use-current-account";
const MobileProfileHeader = () => {
const pubkey = useSubject(accountService.pubkey) ?? "";
const readonly = useReadonlyMode();
const account = useCurrentAccount();
return (
<Flex justifyContent="space-between" padding="2" alignItems="center">
<UserAvatarLink pubkey={pubkey} size="sm" />
{readonly && (
<UserAvatarLink pubkey={account.pubkey} size="sm" />
{account.readonly && (
<Button
colorScheme="red"
textAlign="center"

View File

@ -1,14 +1,13 @@
import { Box, LinkBox, Text } from "@chakra-ui/react";
import { Link } from "react-router-dom";
import accountService from "../services/account";
import { UserAvatar } from "./user-avatar";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { normalizeToBech32 } from "../helpers/nip-19";
import { truncatedId } from "../helpers/nostr-event";
import useSubject from "../hooks/use-subject";
import { useCurrentAccount } from "../hooks/use-current-account";
export const ProfileButton = () => {
const pubkey = useSubject(accountService.pubkey) ?? "";
const { pubkey } = useCurrentAccount();
const metadata = useUserMetadata(pubkey);
return (

View File

@ -1,5 +1,5 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import { useReadonlyMode } from "../hooks/use-readonly-mode";
import { useCurrentAccount } from "../hooks/use-current-account";
import useSubject from "../hooks/use-subject";
import clientFollowingService from "../services/client-following";
@ -7,7 +7,7 @@ export const UserFollowButton = ({
pubkey,
...props
}: { pubkey: string } & Omit<ButtonProps, "onClick" | "isLoading" | "isDisabled">) => {
const readonly = useReadonlyMode();
const account = useCurrentAccount();
const following = useSubject(clientFollowingService.following) ?? [];
const savingDraft = useSubject(clientFollowingService.savingDraft);
@ -24,7 +24,7 @@ export const UserFollowButton = ({
};
return (
<Button colorScheme="brand" {...props} isLoading={savingDraft} onClick={toggleFollow} isDisabled={readonly}>
<Button colorScheme="brand" {...props} isLoading={savingDraft} onClick={toggleFollow} isDisabled={account.readonly}>
{isFollowing ? "Unfollow" : "Follow"}
</Button>
);

View File

@ -0,0 +1,8 @@
import accountService from "../services/account";
import useSubject from "./use-subject";
export function useCurrentAccount() {
const account = useSubject(accountService.current);
if (!account) throw Error("no account");
return account;
}

View File

@ -1,6 +1,7 @@
import accountService from "../services/account";
import useSubject from "./use-subject";
import { useCurrentAccount } from "./use-current-account";
/** @deprecated */
export function useReadonlyMode() {
return useSubject(accountService.readonly);
const account = useCurrentAccount();
return account.readonly;
}

View File

@ -1,83 +1,59 @@
import { PersistentSubject, Subject } from "../classes/subject";
import settings from "./settings";
import { PersistentSubject } from "../classes/subject";
import db from "./db";
export type PresetRelays = Record<string, { read: boolean; write: boolean }>;
export type SavedIdentity = {
export type Account = {
pubkey: string;
secKey?: string;
useExtension: boolean;
readonly: boolean;
relays?: string[];
};
class AccountService {
loading = new PersistentSubject(false);
setup = new PersistentSubject(false);
pubkey = new Subject<string>();
readonly = new PersistentSubject(false);
// directory of relays provided by nip07 extension
relays = new Subject<PresetRelays>({});
private useExtension: boolean = false;
private secKey: string | undefined = undefined;
loading = new PersistentSubject(true);
accounts = new PersistentSubject<Account[]>([]);
current = new PersistentSubject<Account | null>(null);
constructor() {
settings.identity.subscribe((savedIdentity) => {
this.loading.next(false);
if (savedIdentity) {
this.setup.next(true);
this.pubkey.next(savedIdentity.pubkey);
this.readonly.next(false);
this.secKey = savedIdentity.secKey;
this.useExtension = savedIdentity.useExtension;
} else {
this.setup.next(false);
this.pubkey.next("");
this.readonly.next(false);
this.secKey = undefined;
this.useExtension = false;
db.getAll("accounts").then((accounts) => {
this.accounts.next(accounts);
const lastAccount = localStorage.getItem("lastAccount");
if (lastAccount && this.hasAccount(lastAccount)) {
this.switchAccount(lastAccount);
}
this.loading.next(false);
});
}
async loginWithExtension() {
if (window.nostr) {
try {
this.loading.next(true);
const pubkey = await window.nostr.getPublicKey();
const relays = await window.nostr.getRelays();
hasAccount(pubkey: string) {
return this.accounts.value.some((acc) => acc.pubkey === pubkey);
}
addAccount(pubkey: string, relays?: string[], readonly = false) {
const account: Account = { pubkey, relays, readonly };
this.accounts.next(this.accounts.value.concat(account));
if (Array.isArray(relays)) {
this.relays.next(relays.reduce<PresetRelays>((d, r) => ({ ...d, [r]: { read: true, write: true } }), {}));
} else {
this.relays.next(relays);
}
db.put("accounts", account);
}
removeAccount(pubkey: string) {
this.accounts.next(this.accounts.value.filter((acc) => acc.pubkey !== pubkey));
settings.identity.next({
pubkey,
useExtension: true,
});
} catch (e) {
this.loading.next(false);
}
}
db.delete("accounts", pubkey);
}
// loginWithSecKey(secKey: string) {
// const pubkey =
// settings.identity.next({
// pubkey,
// useExtension: true,
// });
// }
loginWithPubkey(pubkey: string) {
this.readonly.next(true);
this.pubkey.next(pubkey);
this.setup.next(true);
this.loading.next(false);
switchAccount(pubkey: string) {
const account = this.accounts.value.find((acc) => acc.pubkey === pubkey);
if (account) {
this.current.next(account);
localStorage.setItem("lastAccount", pubkey);
}
}
switchToTemporary(account: Account) {
this.current.next(account);
}
logout() {
settings.identity.next(null);
this.current.next(null);
localStorage.removeItem("lastAccount");
}
}

View File

@ -29,19 +29,20 @@ function handleNewContacts(contacts: UserContacts | undefined) {
let sub: Subject<UserContacts> | undefined;
function updateSub() {
const pubkey = accountService.current.value?.pubkey;
if (sub) {
sub.unsubscribe(handleNewContacts);
sub = undefined;
}
if (accountService.pubkey.value) {
sub = userContactsService.requestContacts(accountService.pubkey.value, clientRelaysService.getReadUrls(), true);
if (pubkey) {
sub = userContactsService.requestContacts(pubkey, clientRelaysService.getReadUrls(), true);
sub.subscribe(handleNewContacts);
}
}
accountService.pubkey.subscribe(() => {
accountService.current.subscribe(() => {
// clear the following list until a new one can be fetched
following.next([]);

View File

@ -23,29 +23,28 @@ class ClientRelayService {
constructor() {
let lastSubject: Subject<UserRelays> | undefined;
accountService.pubkey.subscribe((pubkey) => {
// clear the relay list until a new one can be fetched
// this.relays.next([]);
accountService.current.subscribe((account) => {
this.relays.next([]);
if (!account) return;
if (account.relays) {
this.bootstrapRelays.clear();
for (const relay of account.relays) {
this.bootstrapRelays.add(relay);
}
}
if (lastSubject) {
lastSubject.unsubscribe(this.handleRelayChanged, this);
lastSubject = undefined;
}
lastSubject = userRelaysService.requestRelays(pubkey, Array.from(this.bootstrapRelays), true);
lastSubject = userRelaysService.requestRelays(account.pubkey, Array.from(this.bootstrapRelays), true);
lastSubject.subscribe(this.handleRelayChanged, this);
});
// add preset relays fromm nip07 extension to bootstrap list
accountService.relays.subscribe((presetRelays) => {
for (const [url, opts] of Object.entries(presetRelays)) {
if (opts.read) {
clientRelaysService.bootstrapRelays.add(url);
}
}
});
this.relays.subscribe((relays) => this.writeRelays.next(relays.filter((r) => r.mode & RelayMode.WRITE)));
this.relays.subscribe((relays) => this.readRelays.next(relays.filter((r) => r.mode & RelayMode.READ)));
}

View File

@ -42,6 +42,7 @@ const MIGRATIONS: MigrationFunction[] = [
db.createObjectStore("settings");
db.createObjectStore("relayInfo");
db.createObjectStore("accounts", { keyPath: "pubkey" });
},
];

View File

@ -1,5 +1,6 @@
import { DBSchema } from "idb";
import { NostrEvent } from "../../types/nostr-event";
import { Account } from "../account";
import { RelayInformationDocument } from "../relay-info";
export interface CustomSchema extends DBSchema {
@ -38,4 +39,8 @@ export interface CustomSchema extends DBSchema {
key: string;
value: any;
};
accounts: {
key: string;
value: Account;
};
}

View File

@ -1,12 +1,12 @@
import { PersistentSubject } from "../classes/subject";
import db from "./db";
import { SavedIdentity } from "./account";
import { Account } from "./account";
const settings = {
identity: new PersistentSubject<SavedIdentity | null>(null),
blurImages: new PersistentSubject(true),
autoShowMedia: new PersistentSubject(true),
proxyUserMedia: new PersistentSubject(false),
accounts: new PersistentSubject<Account[]>([]),
};
async function loadSettings() {
@ -15,6 +15,7 @@ async function loadSettings() {
// load
for (const [key, subject] of Object.entries(settings)) {
const value = await db.get("settings", key);
// @ts-ignore
if (value !== undefined) subject.next(value);
// save

View File

@ -3,13 +3,11 @@ import { Button, Flex, Spinner } from "@chakra-ui/react";
import moment from "moment";
import { Note } from "../../components/note";
import { useUserContacts } from "../../hooks/use-user-contacts";
import accountService from "../../services/account";
import userContactsService from "../../services/user-contacts";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isNote } from "../../helpers/nostr-event";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSubject from "../../hooks/use-subject";
import { useCurrentAccount } from "../../hooks/use-current-account";
function useExtendedContacts(pubkey: string) {
const readRelays = useReadRelayUrls();
@ -41,10 +39,10 @@ function useExtendedContacts(pubkey: string) {
export const DiscoverTab = () => {
useAppTitle("discover");
const pubkey = useSubject(accountService.pubkey) ?? "";
const account = useCurrentAccount();
const relays = useReadRelayUrls();
const contactsOfContacts = useExtendedContacts(pubkey);
const contactsOfContacts = useExtendedContacts(account.pubkey);
const { events, loading, loadMore } = useTimelineLoader(
`discover`,
relays,

View File

@ -5,20 +5,17 @@ import { Note } from "../../components/note";
import { isNote } from "../../helpers/nostr-event";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useUserContacts } from "../../hooks/use-user-contacts";
import accountService from "../../services/account";
import { AddIcon } from "@chakra-ui/icons";
import { useContext } from "react";
import { PostModalContext } from "../../providers/post-modal-provider";
import { useReadonlyMode } from "../../hooks/use-readonly-mode";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSubject from "../../hooks/use-subject";
import { useCurrentAccount } from "../../hooks/use-current-account";
export const FollowingTab = () => {
const readonly = useReadonlyMode();
const pubkey = useSubject(accountService.pubkey) ?? "";
const account = useCurrentAccount();
const relays = useReadRelayUrls();
const { openModal } = useContext(PostModalContext);
const contacts = useUserContacts(pubkey);
const contacts = useUserContacts(account.pubkey);
const [search, setSearch] = useSearchParams();
const showReplies = search.has("replies");
const onToggle = () => {
@ -37,7 +34,7 @@ export const FollowingTab = () => {
return (
<Flex direction="column" gap="2">
<Button variant="outline" leftIcon={<AddIcon />} onClick={() => openModal()} isDisabled={readonly}>
<Button variant="outline" leftIcon={<AddIcon />} onClick={() => openModal()} isDisabled={account.readonly}>
New Post
</Button>
<FormControl display="flex" alignItems="center">

View File

@ -4,10 +4,10 @@ import useSubject from "../../hooks/use-subject";
import accountService from "../../services/account";
export const LoginView = () => {
const setup = useSubject(accountService.setup);
const current = useSubject(accountService.current);
const location = useLocation();
if (setup) return <Navigate to={location.state?.from ?? "/"} replace />;
if (current) return <Navigate to={location.state?.from ?? "/"} replace />;
return (
<Flex direction="column" alignItems="center" justifyContent="center" gap="4" height="80%" px="4">

View File

@ -64,16 +64,21 @@ export const LoginNip05View = () => {
return toast({ status: "error", title: "No relay selected" });
}
accountService.loginWithPubkey(pubkey);
// add the account if it dose not exist
if (!accountService.hasAccount(pubkey)) {
const bootstrapRelays = new Set<string>();
if (relayUrl) {
clientRelaysService.bootstrapRelays.add(relayUrl);
}
if (relays) {
for (const url of relays) {
clientRelaysService.bootstrapRelays.add(url);
if (relayUrl) bootstrapRelays.add(relayUrl);
if (relays) {
for (const url of relays) {
bootstrapRelays.add(url);
}
}
accountService.addAccount(pubkey, Array.from(bootstrapRelays), true);
}
accountService.switchAccount(pubkey);
};
const renderInputIcon = () => {

View File

@ -20,7 +20,10 @@ export const LoginNpubView = () => {
return toast({ status: "error", title: "Invalid npub" });
}
accountService.loginWithPubkey(pubkey);
if (!accountService.hasAccount(pubkey)) {
accountService.addAccount(pubkey, [relayUrl], true);
}
accountService.switchAccount(pubkey);
clientRelaysService.bootstrapRelays.add(relayUrl);
};

View File

@ -1,11 +1,39 @@
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Button, Spinner } from "@chakra-ui/react";
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Button, Flex, Spinner } from "@chakra-ui/react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import useSubject from "../../hooks/use-subject";
import accountService from "../../services/account";
export const LoginStartView = () => {
const navigate = useNavigate();
const loading = useSubject(accountService.loading);
const [loading, setLoading] = useState(false);
const accounts = useSubject(accountService.accounts);
const loginWithExtension = async () => {
if (window.nostr) {
try {
setLoading(true);
const pubkey = await window.nostr.getPublicKey();
if (!accountService.hasAccount(pubkey)) {
let relays: string[] = [];
const extRelays = await window.nostr.getRelays();
if (Array.isArray(extRelays)) {
relays = extRelays;
} else {
relays = Object.keys(extRelays).filter((url) => extRelays[url].read);
}
accountService.addAccount(pubkey, relays, false);
}
accountService.switchAccount(pubkey);
} catch (e) {}
setLoading(false);
}
};
if (loading) return <Spinner />;
return (
@ -17,13 +45,20 @@ export const LoginStartView = () => {
<AlertDescription>There are bugs and things will break.</AlertDescription>
</Box>
</Alert>
<Button onClick={() => accountService.loginWithExtension()} colorScheme="brand">
<Button onClick={loginWithExtension} colorScheme="brand">
Use browser extension
</Button>
<Button onClick={() => navigate("./nip05")}>Login with Nip-05 Id</Button>
<Button variant="link" onClick={() => navigate("./npub")}>
Login with npub
</Button>
<Flex gap="2" direction="column">
{accounts.map((account) => (
<Button key={account.pubkey} onClick={() => accountService.switchAccount(account.pubkey)}>
{account.pubkey}
</Button>
))}
</Flex>
</>
);
};

View File

@ -6,9 +6,8 @@ import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { convertTimestampToDate } from "../../helpers/date";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSubject from "../../hooks/use-subject";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import accountService from "../../services/account";
import { NostrEvent } from "../../types/nostr-event";
const Kind1Notification = ({ event }: { event: NostrEvent }) => {
@ -39,12 +38,12 @@ const NotificationItem = memo(({ event }: { event: NostrEvent }) => {
const NotificationsView = () => {
const readRelays = useReadRelayUrls();
const pubkey = useSubject(accountService.pubkey) ?? "";
const account = useCurrentAccount();
const { events, loading, loadMore } = useTimelineLoader(
"notifications",
readRelays,
{
"#p": [pubkey],
"#p": [account.pubkey],
kinds: [1],
since: moment().subtract(1, "day").unix(),
},
@ -53,7 +52,7 @@ const NotificationsView = () => {
const timeline = events
// ignore events made my the user
.filter((e) => e.pubkey !== pubkey);
.filter((e) => e.pubkey !== account.pubkey);
return (
<Flex direction="column" overflowX="hidden" overflowY="auto" gap="2">

View File

@ -1,6 +1,7 @@
import { Avatar, Button, Flex, FormControl, FormLabel, Input, SkeletonText, Textarea } from "@chakra-ui/react";
import { useMemo } from "react";
import { useForm } from "react-hook-form";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useSubject from "../../hooks/use-subject";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import accountService from "../../services/account";
@ -60,8 +61,8 @@ const MetadataForm = ({ defaultValues, onSubmit }: MetadataFormProps) => {
};
export const ProfileEditView = () => {
const pubkey = useSubject(accountService.pubkey) ?? "";
const metadata = useUserMetadata(pubkey);
const account = useCurrentAccount();
const metadata = useUserMetadata(account.pubkey);
const defaultValues = useMemo<FormData>(
() => ({

View File

@ -1,9 +1,5 @@
import useSubject from "../../hooks/use-subject";
import accountService from "../../services/account";
import { ProfileEditView } from "./edit";
export const ProfileView = () => {
const pubkey = useSubject(accountService.pubkey) ?? "";
return <ProfileEditView />;
};

View File

@ -13,8 +13,7 @@ export const UserProfileMenu = ({ pubkey, ...props }: { pubkey: string } & Omit<
const loginAsUser = () => {
if (confirm(`Do you want to logout and login as ${getUserDisplayName(metadata, pubkey)}?`)) {
accountService.logout();
accountService.loginWithPubkey(pubkey);
accountService.switchToTemporary({ pubkey, readonly: true });
}
};

View File

@ -27,6 +27,7 @@ import { CopyIconButton } from "../../components/copy-icon-button";
import accountService from "../../services/account";
import { UserFollowButton } from "../../components/user-follow-button";
import { useAppTitle } from "../../hooks/use-app-title";
import { useCurrentAccount } from "../../hooks/use-current-account";
const tabs = [
{ label: "Notes", path: "notes" },
@ -39,6 +40,7 @@ const tabs = [
const UserView = () => {
const isMobile = useIsMobile();
const navigate = useNavigate();
const account = useCurrentAccount();
const { pubkey } = useLoaderData() as { pubkey: string };
const matches = useMatches();
@ -48,7 +50,7 @@ const UserView = () => {
const metadata = useUserMetadata(pubkey, [], true);
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
const isSelf = pubkey === accountService.pubkey.value;
const isSelf = pubkey === account.pubkey;
useAppTitle(getUserDisplayName(metadata, npub ?? pubkey));