diff --git a/.changeset/tasty-scissors-taste.md b/.changeset/tasty-scissors-taste.md
new file mode 100644
index 000000000..e4bb2dabc
--- /dev/null
+++ b/.changeset/tasty-scissors-taste.md
@@ -0,0 +1,5 @@
+---
+"nostrudel": minor
+---
+
+Add task manager modal
diff --git a/src/app.tsx b/src/app.tsx
index aeb2e3cac..c2a784139 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -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 = () => (
-
-
- }>
-
-
-
+
+
+
+ }>
+
+
+
+
);
diff --git a/src/classes/nostr-publish-action.ts b/src/classes/nostr-publish-action.ts
index cf1cc085a..3b9d62345 100644
--- a/src/classes/nostr-publish-action.ts
+++ b/src/classes/nostr-publish-action.ts
@@ -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[];
diff --git a/src/components/layout/desktop-side-nav.tsx b/src/components/layout/desktop-side-nav.tsx
index 2e326d78a..658e713e2 100644
--- a/src/components/layout/desktop-side-nav.tsx
+++ b/src/components/layout/desktop-side-nav.tsx
@@ -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) {
)}
-
+
);
}
diff --git a/src/components/layout/mobile-side-drawer.tsx b/src/components/layout/mobile-side-drawer.tsx
index 6bd7d40d8..27a409db6 100644
--- a/src/components/layout/mobile-side-drawer.tsx
+++ b/src/components/layout/mobile-side-drawer.tsx
@@ -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) {
const account = useCurrentAccount();
@@ -23,16 +24,7 @@ export default function MobileSideDrawer({ ...props }: Omit
-
+
{account ? (
) : (
@@ -48,6 +40,7 @@ export default function MobileSideDrawer({ ...props }: Omit
)}
+
diff --git a/src/components/layout/task-manager-button.tsx b/src/components/layout/task-manager-button.tsx
new file mode 100644
index 000000000..8cdf5bb87
--- /dev/null
+++ b/src/components/layout/task-manager-button.tsx
@@ -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) {
+ const { log } = useContext(PublishContext);
+ const { openTaskManager } = useTaskManagerContext();
+
+ return (
+
+ );
+}
diff --git a/src/components/post-modal/index.tsx b/src/components/post-modal/index.tsx
index ba81b8bb7..5c85b13eb 100644
--- a/src/components/post-modal/index.tsx
+++ b/src/components/post-modal/index.tsx
@@ -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();
+ const [publishAction, setPublishAction] = useState();
const emojis = useContextEmojis();
const moreOptions = useDisclosure();
diff --git a/src/components/publish-log.tsx b/src/components/publish-log.tsx
deleted file mode 100644
index 1fbec4fcd..000000000
--- a/src/components/publish-log.tsx
+++ /dev/null
@@ -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) {
- const results = useSubject(pub.results);
-
- const successful = results.filter(({ success }) => success);
- const failedWithMessage = results.filter(({ success, message }) => !success && !!message);
-
- let statusIcon = ;
- let statusColor: TagProps["colorScheme"] = "blue";
- if (results.length !== pub.relays.length) {
- statusColor = "blue";
- statusIcon = ;
- } else if (successful.length === 0) {
- statusColor = "red";
- statusIcon = ;
- } else if (failedWithMessage.length > 0) {
- statusColor = "orange";
- statusIcon = ;
- } else {
- statusColor = "green";
- statusIcon = ;
- }
-
- return (
-
-
- {successful.length}/{pub.relays.length}
-
- {statusIcon}
-
- );
-}
-
-function PublishAction({ pub }: { pub: NostrPublishAction }) {
- const details = useDisclosure();
-
- return (
- <>
-
- {pub.label}
-
-
- {details.isOpen && (
-
-
-
-
- {pub.label}
-
-
-
-
-
-
-
- )}
- >
- );
-}
-
-export default function PublishLog({ ...props }: Omit) {
- const { log } = useContext(PublishContext);
- const reverseLog = Array.from(log).reverse();
-
- return (
-
- {reverseLog.length > 0 && Activity log:}
- {reverseLog.map((pub) => (
-
- ))}
-
- );
-}
diff --git a/src/providers/global/publish-provider.tsx b/src/providers/global/publish-provider.tsx
index 0afe8122f..d3af9bd84 100644
--- a/src/providers/global/publish-provider.tsx
+++ b/src/providers/global/publish-provider.tsx
@@ -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 | undefined,
quite: false,
onlyAdditionalRelays: false,
- ): Promise;
+ ): Promise;
publishEvent(
label: string,
event: EventTemplate | NostrEvent,
additionalRelays: Iterable | undefined,
quite: false,
onlyAdditionalRelays?: boolean,
- ): Promise;
+ ): Promise;
publishEvent(
label: string,
event: EventTemplate | NostrEvent,
additionalRelays?: Iterable | undefined,
quite?: boolean,
onlyAdditionalRelays?: boolean,
- ): Promise;
+ ): Promise;
};
export const PublishContext = createContext({
log: [],
@@ -53,7 +53,7 @@ export function usePublishEvent() {
export default function PublishProvider({ children }: PropsWithChildren) {
const toast = useToast();
- const [log, setLog] = useState([]);
+ const [log, setLog] = useState([]);
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 }) => {
diff --git a/src/views/task-manager/database/index.tsx b/src/views/task-manager/database/index.tsx
new file mode 100644
index 000000000..6fbde1c32
--- /dev/null
+++ b/src/views/task-manager/database/index.tsx
@@ -0,0 +1,5 @@
+import DatabaseView from "../../relays/cache/database";
+
+export default function TaskManagerDatabase() {
+ return ;
+}
diff --git a/src/views/task-manager/layout.tsx b/src/views/task-manager/layout.tsx
new file mode 100644
index 000000000..230a126c9
--- /dev/null
+++ b/src/views/task-manager/layout.tsx
@@ -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 (
+ navigate("/" + tabs[i], { replace: true })}
+ >
+
+ Network
+ Publish Log
+ Database
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/views/task-manager/modal.tsx b/src/views/task-manager/modal.tsx
new file mode 100644
index 000000000..a73ab3075
--- /dev/null
+++ b/src/views/task-manager/modal.tsx
@@ -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;
+
+export default function TaskManagerModal({
+ router,
+ isOpen,
+ onClose,
+}: { router: Router } & Omit) {
+ return (
+
+
+
+
+
+ Loading page
+
+ }
+ >
+
+
+ {/*
+
+ Network
+ Database
+
+
+
+
+
+
+
+
+
+
+
+ */}
+
+
+
+
+ );
+}
diff --git a/src/views/task-manager/network/index.tsx b/src/views/task-manager/network/index.tsx
new file mode 100644
index 000000000..97d54220b
--- /dev/null
+++ b/src/views/task-manager/network/index.tsx
@@ -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 (
+
+ {Array.from(relayPoolService.relays.values()).map((relay) => (
+
+
+
+
+ {relay.url}
+
+
+
+
+
+
+ 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.
+
+
+ ))}
+
+ );
+}
diff --git a/src/views/task-manager/network/inspect-relay.tsx b/src/views/task-manager/network/inspect-relay.tsx
new file mode 100644
index 000000000..2b52d65d9
--- /dev/null
+++ b/src/views/task-manager/network/inspect-relay.tsx
@@ -0,0 +1,5 @@
+import VerticalPageLayout from "../../../components/vertical-page-layout";
+
+export default function InspectRelayView() {
+ return ;
+}
diff --git a/src/views/task-manager/provider.tsx b/src/views/task-manager/provider.tsx
new file mode 100644
index 000000000..bc1360244
--- /dev/null
+++ b/src/views/task-manager/provider.tsx
@@ -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;
+
+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: ,
+ children: [
+ {
+ path: "network",
+ element: ,
+ children: [
+ {
+ path: ":url",
+ element: (
+
+
+
+ ),
+ },
+ ],
+ },
+ { path: "publish-log", element: },
+ {
+ path: "database",
+ element: ,
+ },
+ ],
+ },
+];
+
+export default function TaskManagerProvider({ children, parentRouter }: PropsWithChildren & { parentRouter: Router }) {
+ const [router, setRouter] = useState(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 (
+
+ {children}
+ {router && }
+
+ );
+}
diff --git a/src/views/task-manager/publish-log/action-status-tag.tsx b/src/views/task-manager/publish-log/action-status-tag.tsx
new file mode 100644
index 000000000..5b13900b6
--- /dev/null
+++ b/src/views/task-manager/publish-log/action-status-tag.tsx
@@ -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) {
+ const results = useSubject(action.results);
+
+ const successful = results.filter(({ success }) => success);
+ const failedWithMessage = results.filter(({ success, message }) => !success && !!message);
+
+ let statusIcon = ;
+ let statusColor: TagProps["colorScheme"] = "blue";
+ if (results.length !== action.relays.length) {
+ statusColor = "blue";
+ statusIcon = ;
+ } else if (successful.length === 0) {
+ statusColor = "red";
+ statusIcon = ;
+ } else if (failedWithMessage.length > 0) {
+ statusColor = "orange";
+ statusIcon = ;
+ } else {
+ statusColor = "green";
+ statusIcon = ;
+ }
+
+ return (
+
+
+ {successful.length}/{action.relays.length}
+
+ {statusIcon}
+
+ );
+}
diff --git a/src/views/task-manager/publish-log/index.tsx b/src/views/task-manager/publish-log/index.tsx
new file mode 100644
index 000000000..27cf6479a
--- /dev/null
+++ b/src/views/task-manager/publish-log/index.tsx
@@ -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 (
+
+
+
+ {action.label}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function PublishLogView() {
+ const { log } = useContext(PublishContext);
+ const reverseLog = Array.from(log).reverse();
+
+ return (
+
+ {reverseLog.length === 0 && (
+
+ No events published yet
+
+ )}
+ {reverseLog.map((action) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/publish-details.tsx b/src/views/task-manager/publish-log/publish-details.tsx
similarity index 86%
rename from src/components/publish-details.tsx
rename to src/views/task-manager/publish-log/publish-details.tsx
index 2623a420d..826faded4 100644
--- a/src/components/publish-details.tsx
+++ b/src/views/task-manager/publish-log/publish-details.tsx
@@ -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 }) {