add task manager modal

This commit is contained in:
hzrd149 2024-04-28 12:13:27 -05:00
parent af1b65f77d
commit 8faf3e42fd
18 changed files with 466 additions and 130 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add task manager modal

View File

@ -89,6 +89,7 @@ import BookmarksView from "./views/bookmarks";
import LoginNostrAddressView from "./views/signin/address";
import LoginNostrAddressCreate from "./views/signin/address/create";
import DatabaseView from "./views/relays/cache/database";
import TaskManagerProvider from "./views/task-manager/provider";
const TracksView = lazy(() => import("./views/tracks"));
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const UserVideosTab = lazy(() => import("./views/user/videos"));
@ -451,11 +452,13 @@ const router = createHashRouter([
export const App = () => (
<ErrorBoundary>
<DrawerSubViewProvider parentRouter={router}>
<Global styles={overrideReactTextareaAutocompleteStyles} />
<Suspense fallback={<Spinner />}>
<RouterProvider router={router} />
</Suspense>
</DrawerSubViewProvider>
<TaskManagerProvider parentRouter={router}>
<DrawerSubViewProvider parentRouter={router}>
<Global styles={overrideReactTextareaAutocompleteStyles} />
<Suspense fallback={<Spinner />}>
<RouterProvider router={router} />
</Suspense>
</DrawerSubViewProvider>
</TaskManagerProvider>
</ErrorBoundary>
);

View File

@ -5,10 +5,11 @@ import relayPoolService from "../services/relay-pool";
import createDefer from "./deferred";
import { PersistentSubject } from "./subject";
import ControlledObservable from "./controlled-observable";
import dayjs from "dayjs";
export type PublishResult = { relay: AbstractRelay; success: boolean; message: string };
export default class NostrPublishAction {
export default class PublishAction {
id = nanoid();
label: string;
relays: string[];

View File

@ -5,13 +5,14 @@ import { css } from "@emotion/react";
import useCurrentAccount from "../../hooks/use-current-account";
import AccountSwitcher from "./account-switcher";
import PublishLog from "../publish-log";
import NavItems from "./nav-items";
import { PostModalContext } from "../../providers/route/post-modal-provider";
import { WritingIcon } from "../icons";
import useSubject from "../../hooks/use-subject";
import { offlineMode } from "../../services/offline-mode";
import WifiOff from "../icons/wifi-off";
import { useTaskManagerContext } from "../../views/task-manager/provider";
import TaskManagerButton from "./task-manager-button";
const hideScrollbar = css`
-ms-overflow-style: none;
@ -88,7 +89,7 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
</Button>
)}
</Flex>
<PublishLog overflowY="auto" minH="15rem" my="4" />
<TaskManagerButton mt="auto" flexShrink={0} py="2" />
</Flex>
);
}

View File

@ -15,6 +15,7 @@ import { Link as RouterLink } from "react-router-dom";
import AccountSwitcher from "./account-switcher";
import useCurrentAccount from "../../hooks/use-current-account";
import NavItems from "./nav-items";
import TaskManagerButton from "./task-manager-button";
export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "children">) {
const account = useCurrentAccount();
@ -23,16 +24,7 @@ export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "childr
<Drawer placement="left" {...props}>
<DrawerOverlay />
<DrawerContent>
<DrawerBody
display="flex"
flexDirection="column"
px="4"
pt="4"
pb="8"
overflowY="auto"
overflowX="hidden"
gap="2"
>
<DrawerBody display="flex" flexDirection="column" px="4" pt="4" overflowY="auto" overflowX="hidden" gap="2">
{account ? (
<AccountSwitcher />
) : (
@ -48,6 +40,7 @@ export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "childr
Sign in
</Button>
)}
<TaskManagerButton mt="auto" flexShrink={0} py="2" />
</DrawerBody>
</DrawerContent>
</Drawer>

View File

@ -0,0 +1,18 @@
import { useContext } from "react";
import { Button, ButtonProps } from "@chakra-ui/react";
import { PublishContext } from "../../providers/global/publish-provider";
import { useTaskManagerContext } from "../../views/task-manager/provider";
import PublishActionStatusTag from "../../views/task-manager/publish-log/action-status-tag";
export default function TaskManagerButton({ ...props }: Omit<ButtonProps, "children">) {
const { log } = useContext(PublishContext);
const { openTaskManager } = useTaskManagerContext();
return (
<Button variant="link" justifyContent="space-between" onClick={() => openTaskManager("/network")} {...props}>
Task Manager
{log.length > 0 && <PublishActionStatusTag action={log[log.length - 1]} />}
</Button>
);
}

View File

@ -29,8 +29,8 @@ import { useForm } from "react-hook-form";
import { kinds } from "nostr-tools";
import { ChevronDownIcon, ChevronUpIcon, UploadImageIcon } from "../icons";
import NostrPublishAction from "../../classes/nostr-publish-action";
import { PublishDetails } from "../publish-details";
import PublishAction from "../../classes/nostr-publish-action";
import { PublishDetails } from "../../views/task-manager/publish-log/publish-details";
import { TrustProvider } from "../../providers/local/trust";
import {
correctContentMentions,
@ -85,7 +85,7 @@ export default function PostModal({
const account = useCurrentAccount()!;
const { noteDifficulty } = useAppSettings();
const [miningTarget, setMiningTarget] = useState(0);
const [publishAction, setPublishAction] = useState<NostrPublishAction>();
const [publishAction, setPublishAction] = useState<PublishAction>();
const emojis = useContextEmojis();
const moreOptions = useDisclosure();

View File

@ -1,96 +0,0 @@
import { useContext } from "react";
import {
Flex,
FlexProps,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Spinner,
Tag,
TagLabel,
TagProps,
Text,
useDisclosure,
} from "@chakra-ui/react";
import NostrPublishAction from "../classes/nostr-publish-action";
import useSubject from "../hooks/use-subject";
import { CheckIcon, ErrorIcon } from "./icons";
import { PublishDetails } from "./publish-details";
import { PublishContext } from "../providers/global/publish-provider";
export function PublishActionStatusTag({ pub, ...props }: { pub: NostrPublishAction } & Omit<TagProps, "children">) {
const results = useSubject(pub.results);
const successful = results.filter(({ success }) => success);
const failedWithMessage = results.filter(({ success, message }) => !success && !!message);
let statusIcon = <Spinner size="xs" />;
let statusColor: TagProps["colorScheme"] = "blue";
if (results.length !== pub.relays.length) {
statusColor = "blue";
statusIcon = <Spinner size="xs" />;
} else if (successful.length === 0) {
statusColor = "red";
statusIcon = <ErrorIcon />;
} else if (failedWithMessage.length > 0) {
statusColor = "orange";
statusIcon = <CheckIcon />;
} else {
statusColor = "green";
statusIcon = <CheckIcon />;
}
return (
<Tag colorScheme={statusColor} {...props}>
<TagLabel mr="1">
{successful.length}/{pub.relays.length}
</TagLabel>
{statusIcon}
</Tag>
);
}
function PublishAction({ pub }: { pub: NostrPublishAction }) {
const details = useDisclosure();
return (
<>
<Flex gap="2" alignItems="center" cursor="pointer" onClick={details.onOpen}>
<Text isTruncated>{pub.label}</Text>
<PublishActionStatusTag ml="auto" pub={pub} flexShrink={0} />
</Flex>
{details.isOpen && (
<Modal isOpen onClose={details.onClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader pt="4" px="4" pb="0">
{pub.label}
</ModalHeader>
<ModalCloseButton />
<ModalBody p="2">
<PublishDetails pub={pub} />
</ModalBody>
</ModalContent>
</Modal>
)}
</>
);
}
export default function PublishLog({ ...props }: Omit<FlexProps, "children">) {
const { log } = useContext(PublishContext);
const reverseLog = Array.from(log).reverse();
return (
<Flex overflow="hidden" direction="column" gap="1" {...props}>
{reverseLog.length > 0 && <Text>Activity log:</Text>}
{reverseLog.map((pub) => (
<PublishAction key={pub.id} pub={pub} />
))}
</Flex>
);
}

View File

@ -4,7 +4,7 @@ import { EventTemplate, NostrEvent, kinds } from "nostr-tools";
import { useSigningContext } from "./signing-provider";
import { DraftNostrEvent } from "../../types/nostr-event";
import NostrPublishAction from "../../classes/nostr-publish-action";
import PublishAction from "../../classes/nostr-publish-action";
import clientRelaysService from "../../services/client-relays";
import RelaySet from "../../classes/relay-set";
import { getAllRelayHints, isReplaceable } from "../../helpers/nostr/event";
@ -17,28 +17,28 @@ import deleteEventService from "../../services/delete-events";
import userMailboxesService from "../../services/user-mailboxes";
type PublishContextType = {
log: NostrPublishAction[];
log: PublishAction[];
publishEvent(
label: string,
event: EventTemplate | NostrEvent,
additionalRelays: Iterable<string> | undefined,
quite: false,
onlyAdditionalRelays: false,
): Promise<NostrPublishAction>;
): Promise<PublishAction>;
publishEvent(
label: string,
event: EventTemplate | NostrEvent,
additionalRelays: Iterable<string> | undefined,
quite: false,
onlyAdditionalRelays?: boolean,
): Promise<NostrPublishAction>;
): Promise<PublishAction>;
publishEvent(
label: string,
event: EventTemplate | NostrEvent,
additionalRelays?: Iterable<string> | undefined,
quite?: boolean,
onlyAdditionalRelays?: boolean,
): Promise<NostrPublishAction | undefined>;
): Promise<PublishAction | undefined>;
};
export const PublishContext = createContext<PublishContextType>({
log: [],
@ -53,7 +53,7 @@ export function usePublishEvent() {
export default function PublishProvider({ children }: PropsWithChildren) {
const toast = useToast();
const [log, setLog] = useState<NostrPublishAction[]>([]);
const [log, setLog] = useState<PublishAction[]>([]);
const { requestSignature } = useSigningContext();
const publishEvent = useCallback(
@ -84,7 +84,7 @@ export default function PublishProvider({ children }: PropsWithChildren) {
signed = await requestSignature(draft);
} else signed = event as NostrEvent;
const pub = new NostrPublishAction(label, relays, signed);
const pub = new PublishAction(label, relays, signed);
setLog((arr) => arr.concat(pub));
pub.onResult.subscribe(({ relay, success }) => {

View File

@ -0,0 +1,5 @@
import DatabaseView from "../../relays/cache/database";
export default function TaskManagerDatabase() {
return <DatabaseView />;
}

View File

@ -0,0 +1,44 @@
import { Tab, TabIndicator, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
const tabs = ["network", "publish-log", "database"];
export default function TaskManagerLayout() {
const location = useLocation();
const navigate = useNavigate();
const index = tabs.indexOf(location.pathname.split("/")[1] || "network");
return (
<Tabs
display="flex"
flexDirection="column"
flexGrow="1"
isLazy
colorScheme="primary"
position="relative"
variant="unstyled"
index={index}
onChange={(i) => navigate("/" + tabs[i], { replace: true })}
>
<TabList overflowX="auto" overflowY="hidden" flexShrink={0} mr="10">
<Tab>Network</Tab>
<Tab>Publish Log</Tab>
<Tab>Database</Tab>
</TabList>
<TabIndicator height="2px" bg="primary.500" borderRadius="1px" />
<TabPanels>
<TabPanel p={0} minH="50vh">
<Outlet />
</TabPanel>
<TabPanel p={0} minH="50vh">
<Outlet />
</TabPanel>
<TabPanel minH="50vh">
<Outlet />
</TabPanel>
</TabPanels>
</Tabs>
);
}

View File

@ -0,0 +1,75 @@
import {
Heading,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalOverlay,
ModalProps,
Spinner,
Tab,
TabIndicator,
TabList,
TabPanel,
TabPanels,
Tabs,
} from "@chakra-ui/react";
import { RouterProvider, createMemoryRouter } from "react-router-dom";
import { PersistentSubject } from "../../classes/subject";
import useSubject from "../../hooks/use-subject";
import DatabaseView from "../relays/cache/database";
import TaskManagerNetwork from "./network";
import { Suspense } from "react";
type Router = ReturnType<typeof createMemoryRouter>;
export default function TaskManagerModal({
router,
isOpen,
onClose,
}: { router: Router } & Omit<ModalProps, "children">) {
return (
<Modal isOpen={isOpen} onClose={onClose} size="6xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent>
<ModalBody display="flex" flexDirection="column" gap="2" p="0">
<Suspense
fallback={
<Heading size="md" mx="auto" my="4">
<Spinner /> Loading page
</Heading>
}
>
<RouterProvider router={router} />
</Suspense>
{/* <Tabs
display="flex"
flexDirection="column"
flexGrow="1"
isLazy
colorScheme="primary"
position="relative"
variant="unstyled"
>
<TabList overflowX="auto" overflowY="hidden" flexShrink={0} mr="10">
<Tab>Network</Tab>
<Tab>Database</Tab>
</TabList>
<TabIndicator height="2px" bg="primary.500" borderRadius="1px" />
<TabPanels minH="50vh">
<TabPanel p={0}>
<TaskManagerNetwork />
</TabPanel>
<TabPanel>
<DatabaseView />
</TabPanel>
</TabPanels>
</Tabs> */}
</ModalBody>
<ModalCloseButton />
</ModalContent>
</Modal>
);
}

View File

@ -0,0 +1,38 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Spacer,
Text,
} from "@chakra-ui/react";
import relayPoolService from "../../../services/relay-pool";
import { RelayFavicon } from "../../../components/relay-favicon";
import { RelayStatus } from "../../../components/relay-status";
export default function TaskManagerNetwork() {
return (
<Accordion>
{Array.from(relayPoolService.relays.values()).map((relay) => (
<AccordionItem key={relay.url}>
<h2>
<AccordionButton>
<RelayFavicon relay={relay.url} size="sm" mr="2" />
<Text isTruncated>{relay.url}</Text>
<Spacer />
<RelayStatus url={relay.url} />
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat.
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
);
}

View File

@ -0,0 +1,5 @@
import VerticalPageLayout from "../../../components/vertical-page-layout";
export default function InspectRelayView() {
return <VerticalPageLayout></VerticalPageLayout>;
}

View File

@ -0,0 +1,152 @@
import { PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Router, Location, To, createMemoryRouter, RouteObject } from "react-router-dom";
import { useRouterMarker } from "../../providers/drawer-sub-view-provider";
import { logger } from "../../helpers/debug";
import { RouteProviders } from "../../providers/route";
import InspectRelayView from "./network/inspect-relay";
import TaskManagerModal from "./modal";
import TaskManagerLayout from "./layout";
import TaskManagerNetwork from "./network";
import TaskManagerDatabase from "./database";
import PublishLogView from "./publish-log";
type Router = ReturnType<typeof createMemoryRouter>;
const log = logger.extend("TaskManagerProvider");
const TaskManagerContext = createContext<{ openTaskManager: (route: To) => void; closeTaskManager: () => void }>({
openTaskManager() {},
closeTaskManager() {},
});
export function useTaskManagerContext() {
return useContext(TaskManagerContext);
}
const routes: RouteObject[] = [
{
path: "",
element: <TaskManagerLayout />,
children: [
{
path: "network",
element: <TaskManagerNetwork />,
children: [
{
path: ":url",
element: (
<RouteProviders>
<InspectRelayView />
</RouteProviders>
),
},
],
},
{ path: "publish-log", element: <PublishLogView /> },
{
path: "database",
element: <TaskManagerDatabase />,
},
],
},
];
export default function TaskManagerProvider({ children, parentRouter }: PropsWithChildren & { parentRouter: Router }) {
const [router, setRouter] = useState<Router | null>(null);
const openInParent = useCallback((to: To) => parentRouter.navigate(to), [parentRouter]);
const direction = useRef<"up" | "down">();
const marker = useRouterMarker(parentRouter);
useEffect(() => {
return parentRouter.subscribe((event) => {
const location = event.location as Location<{ taskManagerRoute?: To | null } | null>;
const subRoute = location.state?.taskManagerRoute;
if (subRoute) {
if (router) {
if (router.state.location.pathname !== subRoute && direction.current !== "up") {
log("Updating router from parent state");
direction.current = "down";
router.navigate(subRoute);
direction.current = undefined;
}
} else {
log("Create Router");
const newRouter = createMemoryRouter(routes, { initialEntries: [subRoute] });
newRouter.subscribe((e) => {
if (
e.errors &&
e.errors["__shim-error-route__"].status === 404 &&
e.errors["__shim-error-route__"].internal
) {
openInParent(e.location);
} else if (direction.current !== "down") {
log("Updating parent state from Router");
direction.current = "up";
parentRouter.navigate(parentRouter.state.location, {
preventScrollReset: true,
state: { ...parentRouter.state.location.state, taskManagerRoute: e.location.pathname },
});
}
direction.current = undefined;
});
// use the parent routers createHref method so that users can open links in new tabs
newRouter.createHref = parentRouter.createHref;
setRouter(newRouter);
}
} else if (router) {
log("Destroy Router");
setRouter(null);
}
});
}, [parentRouter, router, setRouter]);
const openTaskManager = useCallback(
(to: To) => {
marker.set();
parentRouter.navigate(parentRouter.state.location, {
preventScrollReset: true,
state: { ...parentRouter.state.location.state, taskManagerRoute: to },
});
},
[parentRouter],
);
const closeTaskManager = useCallback(() => {
const i = marker.index.current;
if (i !== null && i > 0) {
log(`Navigating back ${i} entries to the point the task manager was opened`);
parentRouter.navigate(-i);
} else {
log(`Failed to navigate back, clearing state`);
parentRouter.navigate(parentRouter.state.location, {
preventScrollReset: true,
state: { ...parentRouter.state.location.state, taskManagerRoute: undefined },
});
}
// reset marker
marker.reset();
}, [parentRouter]);
const context = useMemo(
() => ({
openTaskManager,
closeTaskManager,
}),
[openTaskManager, closeTaskManager],
);
return (
<TaskManagerContext.Provider value={context}>
{children}
{router && <TaskManagerModal router={router} isOpen onClose={closeTaskManager} />}
</TaskManagerContext.Provider>
);
}

View File

@ -0,0 +1,40 @@
import { Spinner, Tag, TagLabel, TagProps } from "@chakra-ui/react";
import PublishAction from "../../../classes/nostr-publish-action";
import useSubject from "../../../hooks/use-subject";
import { CheckIcon, ErrorIcon } from "../../../components/icons";
export default function PublishActionStatusTag({
action,
...props
}: { action: PublishAction } & Omit<TagProps, "children">) {
const results = useSubject(action.results);
const successful = results.filter(({ success }) => success);
const failedWithMessage = results.filter(({ success, message }) => !success && !!message);
let statusIcon = <Spinner size="xs" />;
let statusColor: TagProps["colorScheme"] = "blue";
if (results.length !== action.relays.length) {
statusColor = "blue";
statusIcon = <Spinner size="xs" />;
} else if (successful.length === 0) {
statusColor = "red";
statusIcon = <ErrorIcon />;
} else if (failedWithMessage.length > 0) {
statusColor = "orange";
statusIcon = <CheckIcon />;
} else {
statusColor = "green";
statusIcon = <CheckIcon />;
}
return (
<Tag colorScheme={statusColor} {...props}>
<TagLabel mr="1">
{successful.length}/{action.relays.length}
</TagLabel>
{statusIcon}
</Tag>
);
}

View File

@ -0,0 +1,52 @@
import { useContext } from "react";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Heading,
Spacer,
Text,
} from "@chakra-ui/react";
import PublishAction from "../../../classes/nostr-publish-action";
import PublishActionStatusTag from "./action-status-tag";
import { PublishContext } from "../../../providers/global/publish-provider";
import { PublishDetails } from "./publish-details";
function PublishLogAction({ action }: { action: PublishAction }) {
return (
<AccordionItem>
<h2>
<AccordionButton>
<Text isTruncated>{action.label}</Text>
<Spacer />
<PublishActionStatusTag ml="auto" action={action} flexShrink={0} />
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<PublishDetails pub={action} />
</AccordionPanel>
</AccordionItem>
);
}
export default function PublishLogView() {
const { log } = useContext(PublishContext);
const reverseLog = Array.from(log).reverse();
return (
<Accordion>
{reverseLog.length === 0 && (
<Heading mx="auto" mt="10" size="md" textAlign="center">
No events published yet
</Heading>
)}
{reverseLog.map((action) => (
<PublishLogAction key={action.id} action={action} />
))}
</Accordion>
);
}

View File

@ -12,13 +12,13 @@ import {
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import NostrPublishAction, { PublishResult } from "../classes/nostr-publish-action";
import useSubject from "../hooks/use-subject";
import { RelayPaidTag } from "../views/relays/components/relay-card";
import { EmbedEvent } from "./embed-event";
import PublishAction, { PublishResult } from "../../../classes/nostr-publish-action";
import useSubject from "../../../hooks/use-subject";
import { RelayPaidTag } from "../../relays/components/relay-card";
import { EmbedEvent } from "../../../components/embed-event";
export type PostResultsProps = {
pub: NostrPublishAction;
pub: PublishAction;
};
function PublishResultRow({ result }: { result: PublishResult }) {