add simple list view

This commit is contained in:
hzrd149 2023-06-13 08:08:58 -05:00
parent 02b93843b2
commit 63474a7413
16 changed files with 232 additions and 108 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add delete button for lists

View File

@ -1,17 +1,4 @@
import {
Button,
Input,
MenuItem,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { Bech32Prefix, getSharableNoteId, normalizeToBech32 } from "../../helpers/nip19";
@ -22,47 +9,19 @@ import { ClipboardIcon, CodeIcon, ExternalLinkIcon, LikeIcon, RepostIcon, TrashI
import NoteReactionsModal from "./note-zaps-modal";
import NoteDebugModal from "../debug-modals/note-debug-modal";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useCallback, useState } from "react";
import QuoteNote from "./quote-note";
import { buildDeleteEvent } from "../../helpers/nostr-event";
import signingService from "../../services/signing";
import { nostrPostAction } from "../../classes/nostr-post-action";
import clientRelaysService from "../../services/client-relays";
import { buildAppSelectUrl } from "../../helpers/nostr-apps";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) => {
const account = useCurrentAccount();
const toast = useToast();
const infoModal = useDisclosure();
const reactionsModal = useDisclosure();
const deleteModal = useDisclosure();
const [reason, setReason] = useState("");
const [deleting, setDeleting] = useState(false);
const { deleteEvent } = useDeleteEventContext();
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const noteId = normalizeToBech32(event.id, Bech32Prefix.Note);
const deleteNote = useCallback(async () => {
try {
if (!account) throw new Error("not logged in");
setDeleting(true);
const deleteEvent = buildDeleteEvent([event.id], reason);
const signed = await signingService.requestSignature(deleteEvent, account);
const results = nostrPostAction(clientRelaysService.getWriteUrls(), signed);
await results.onComplete;
deleteModal.onClose();
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
} finally {
setDeleting(false);
}
}, [event]);
return (
<>
<MenuIconButton {...props}>
@ -84,7 +43,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
</MenuItem>
)}
{account?.pubkey === event.pubkey && (
<MenuItem icon={<TrashIcon />} color="red.500" onClick={deleteModal.onOpen}>
<MenuItem icon={<TrashIcon />} color="red.500" onClick={() => deleteEvent(event)}>
Delete Note
</MenuItem>
)}
@ -100,37 +59,6 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
{reactionsModal.isOpen && (
<NoteReactionsModal noteId={event.id} isOpen={reactionsModal.isOpen} onClose={reactionsModal.onClose} />
)}
{deleteModal.isOpen && (
<Modal isOpen={deleteModal.isOpen} onClose={deleteModal.onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" py="2">
Delete Note?
</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" py="0">
<QuoteNote noteId={event.id} />
<Input
name="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason (optional)"
mt="2"
/>
</ModalBody>
<ModalFooter px="4" py="4">
<Button variant="ghost" size="sm" mr={2} onClick={deleteModal.onClose}>
Cancel
</Button>
<Button colorScheme="red" variant="solid" onClick={deleteNote} size="sm" isLoading={deleting}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</>
);
};

View File

@ -13,20 +13,20 @@ import {
PopoverFooter,
} from "@chakra-ui/react";
import { nostrPostAction } from "../../classes/nostr-post-action";
import { getEventRelays, handleEventFromRelay } from "../../services/event-relays";
import { handleEventFromRelay } from "../../services/event-relays";
import { NostrEvent } from "../../types/nostr-event";
import { RelayIcon, SearchIcon } from "../icons";
import { RelayIcon } from "../icons";
import { RelayFavicon } from "../relay-favicon";
import { useReadRelayUrls, useWriteRelayUrls } from "../../hooks/use-client-relays";
import { useWriteRelayUrls } from "../../hooks/use-client-relays";
import relayPoolService from "../../services/relay-pool";
import useSubject from "../../hooks/use-subject";
import useEventRelays from "../../hooks/use-event-relays";
export type NoteRelaysProps = Omit<IconButtonProps, "icon" | "aria-label"> & {
event: NostrEvent;
};
export const NoteRelays = memo(({ event, ...props }: NoteRelaysProps) => {
const eventRelays = useSubject(getEventRelays(event.id));
const eventRelays = useEventRelays();
const writeRelays = useWriteRelayUrls();
const [broadcasting, setBroadcasting] = useState(false);

View File

@ -43,7 +43,6 @@ export const UserFollowButton = ({
}: { pubkey: string } & Omit<ButtonProps, "onClick" | "isLoading" | "isDisabled">) => {
const account = useCurrentAccount();
const following = useSubject(clientFollowingService.following) ?? [];
const savingDraft = useSubject(clientFollowingService.savingDraft);
const readRelays = useReadRelayUrls(useAdditionalRelayContext());
const userContacts = useUserContacts(pubkey, readRelays);
@ -51,8 +50,7 @@ export const UserFollowButton = ({
const isFollowing = following.some((t) => t[1] === pubkey);
const isFollowingMe = account && userContacts?.contacts.includes(account.pubkey);
const userContacts = useUserContacts(pubkey, clientRelaysService.getReadUrls());
const followLabel = account && userContacts?.contacts.includes(account.pubkey) ? "Follow Back" : "Follow";
const followLabel = account && isFollowingMe ? "Follow Back" : "Follow";
return (
<Menu>

View File

@ -0,0 +1,8 @@
import { useMemo } from "react";
import { getEventRelays } from "../services/event-relays";
import useSubject from "./use-subject";
export default function useEventRelays(eventId?: string) {
const sub = useMemo(() => (eventId ? getEventRelays(eventId) : undefined), [eventId]);
return useSubject(sub) ?? [];
}

View File

@ -1,4 +1,3 @@
import moment from "moment";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import { Providers } from "./providers";
@ -26,7 +25,3 @@ root.render(
<App />
</Providers>
);
if (import.meta.env.DEV) {
window.moment = moment;
}

View File

@ -0,0 +1,176 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Button,
Code,
Flex,
Input,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
useToast,
} from "@chakra-ui/react";
import { Event, Kind, nip19 } from "nostr-tools";
import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
import { buildDeleteEvent } from "../helpers/nostr-event";
import { useCurrentAccount } from "../hooks/use-current-account";
import signingService from "../services/signing";
import { nostrPostAction } from "../classes/nostr-post-action";
import QuoteNote from "../components/note/quote-note";
import createDefer, { Deferred } from "../classes/deferred";
import useEventRelays from "../hooks/use-event-relays";
import { useWriteRelayUrls } from "../hooks/use-client-relays";
import { RelayFavicon } from "../components/relay-favicon";
import { ExternalLinkIcon } from "../components/icons";
type DeleteEventContextType = {
isLoading: boolean;
deleteEvent: (event: Event) => Promise<void>;
};
const DeleteEventContext = createContext<DeleteEventContextType>({
isLoading: false,
deleteEvent: () => Promise.reject(),
});
export function useDeleteEventContext() {
return useContext(DeleteEventContext);
}
function EventPreview({ event }: { event: Event }) {
if (event.kind === Kind.Text) {
return <QuoteNote noteId={event.id} />;
}
return <Code>{nip19.noteEncode(event.id)}</Code>;
}
export default function DeleteEventProvider({ children }: PropsWithChildren) {
const toast = useToast();
const account = useCurrentAccount();
const [isLoading, setLoading] = useState(false);
const [event, setEvent] = useState<Event>();
const [defer, setDefer] = useState<Deferred<void>>();
const [reason, setReason] = useState("");
const eventRelays = useEventRelays(event?.id);
const writeRelays = useWriteRelayUrls(eventRelays);
const deleteEvent = useCallback((event: Event) => {
setEvent(event);
const defer = createDefer<void>();
setDefer(defer);
return defer;
}, []);
const onClose = useCallback(() => setEvent(undefined), []);
const confirm = useCallback(async () => {
try {
if (!event) throw new Error("no event");
if (!account) throw new Error("not logged in");
setLoading(true);
const deleteEvent = buildDeleteEvent([event.id], reason);
const signed = await signingService.requestSignature(deleteEvent, account);
const results = nostrPostAction(writeRelays, signed);
await results.onComplete;
defer?.resolve();
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
defer?.reject();
} finally {
setLoading(false);
setReason("");
setEvent(undefined);
setDefer(undefined);
}
}, [defer, event, account]);
const context = useMemo(
() => ({
isLoading,
deleteEvent,
}),
[isLoading, deleteEvent]
);
return (
<DeleteEventContext.Provider value={context}>
{children}
{event && (
<Modal isOpen={true} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" py="2">
Delete Note?
</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" py="0">
<EventPreview event={event} />
<Input
name="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason (optional)"
mt="2"
/>
<Accordion allowToggle my="2">
<AccordionItem>
<AccordionButton>
Deleting from relays
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
<Flex wrap="wrap" gap="2" py="2">
{writeRelays.map((url) => (
<Box alignItems="center" key={url} px="2" borderRadius="lg" display="flex" borderWidth="1px">
<RelayFavicon relay={url} size="2xs" mr="2" />
<Text isTruncated>{url}</Text>
</Box>
))}
</Flex>
</AccordionPanel>
</AccordionItem>
</Accordion>
</ModalBody>
<ModalFooter px="4" pb="4" pt="0">
<Button
as={Link}
leftIcon={<ExternalLinkIcon />}
isExternal
href="https://nostr-delete.vercel.app/"
variant="link"
mr="auto"
size="sm"
>
Nostr Event Deletion
</Button>
<Button variant="ghost" size="sm" mr={2} onClick={onClose}>
Cancel
</Button>
<Button colorScheme="red" variant="solid" onClick={confirm} size="sm" isLoading={isLoading}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</DeleteEventContext.Provider>
);
}

View File

@ -3,6 +3,8 @@ import { ChakraProvider, localStorageManager } from "@chakra-ui/react";
import { SigningProvider } from "./signing-provider";
import createTheme from "../theme";
import useAppSettings from "../hooks/use-app-settings";
import DeleteEventProvider from "./delete-event-provider";
import { ErrorBoundary } from "../components/error-boundary";
export const Providers = ({ children }: { children: React.ReactNode }) => {
const { primaryColor } = useAppSettings();
@ -10,7 +12,11 @@ export const Providers = ({ children }: { children: React.ReactNode }) => {
return (
<ChakraProvider theme={theme} colorModeManager={localStorageManager}>
<SigningProvider>{children}</SigningProvider>
<SigningProvider>
<ErrorBoundary>
<DeleteEventProvider>{children}</DeleteEventProvider>
</ErrorBoundary>
</SigningProvider>
</ChakraProvider>
);
};

View File

@ -1,4 +1,4 @@
import moment from "moment";
import dayjs from "dayjs";
import { NostrRequest } from "../classes/nostr-request";
import { PersistentSubject } from "../classes/subject";
import { DraftNostrEvent, NostrEvent, isPTag } from "../types/nostr-event";
@ -53,7 +53,7 @@ export class List {
if (this.event.tags.some((t) => t[0] === "p" && t[1] === pubkey)) throw new Error("person already in list");
const draft: DraftNostrEvent = {
created_at: moment().unix(),
created_at: dayjs().unix(),
kind: this.event.kind,
content: this.event.content,
tags: [...this.event.tags, relay ? ["p", pubkey, relay] : ["p", pubkey]],

View File

@ -10,7 +10,6 @@ import {
useDisclosure,
} from "@chakra-ui/react";
import { useParams, useSearchParams } from "react-router-dom";
import moment from "moment";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
@ -38,7 +37,7 @@ export default function HashTagView() {
`${hashtag}-hashtag`,
selectedRelay ? [selectedRelay] : defaultRelays,
{ kinds: [1], "#t": [hashtag] },
{ pageSize: moment.duration(5, "minutes").asSeconds() }
{ pageSize: 60 * 10 }
);
const timeline = showReplies ? events : events.filter((e) => !isReply(e));

View File

@ -1,5 +1,4 @@
import { Button, Flex, FormControl, FormLabel, Select, Spinner, Switch, useDisclosure } from "@chakra-ui/react";
import moment from "moment";
import { useSearchParams } from "react-router-dom";
import { Note } from "../../components/note";
import { unique } from "../../helpers/array";
@ -26,7 +25,7 @@ export default function GlobalTab() {
`global`,
selectedRelay ? [selectedRelay] : [],
{ kinds: [1] },
{ pageSize: moment.duration(5, "minutes").asSeconds() }
{ pageSize: 60 * 10 }
);
const timeline = showReplies ? events : events.filter((e) => !isReply(e));

View File

@ -9,7 +9,7 @@ function UsersLists() {
const account = useCurrentAccount()!;
const readRelays = useReadRelayUrls();
const lists = useUserLists(account.pubkey, readRelays);
const lists = useUserLists(account.pubkey, readRelays, true);
return (
<>

View File

@ -1,4 +1,4 @@
import { Link as RouterList, useParams } from "react-router-dom";
import { Link as RouterList, useNavigate, useParams } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useUserLists from "../../hooks/use-user-lists";
@ -9,6 +9,7 @@ import { UserCard } from "../user/components/user-card";
import { ArrowLeftSIcon, ExternalLinkIcon } from "../../components/icons";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { buildAppSelectUrl } from "../../helpers/nostr-apps";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
function useListPointer() {
const { addr } = useParams() as { addr: string };
@ -26,6 +27,8 @@ function useListPointer() {
export default function ListView() {
const pointer = useListPointer();
const account = useCurrentAccount();
const navigate = useNavigate();
const { deleteEvent } = useDeleteEventContext();
const readRelays = useReadRelayUrls(pointer.relays);
const lists = useUserLists(pointer.pubkey, readRelays, true);
@ -53,7 +56,11 @@ export default function ListView() {
{list.name}
</Heading>
{isAuthor && <Button colorScheme="red">Delete</Button>}
{isAuthor && (
<Button colorScheme="red" onClick={() => deleteEvent(list.event).then(() => navigate("/lists"))}>
Delete
</Button>
)}
<Button as={Link} href={buildAppSelectUrl(list.getAddress())} target="_blank" leftIcon={<ExternalLinkIcon />}>
Open in app
</Button>

View File

@ -1,6 +1,6 @@
import { Avatar, Button, Flex, Heading, Image, Link } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { ToolsIcon } from "../../components/icons";
import { ExternalLinkIcon, ToolsIcon } from "../../components/icons";
export default function ToolsHomeView() {
return (
@ -18,6 +18,15 @@ export default function ToolsHomeView() {
>
nostr army knife
</Button>
<Button
as={Link}
href="https://nostr-delete.vercel.app/"
isExternal
target="_blank"
leftIcon={<ExternalLinkIcon />}
>
Nostr Event Deletion
</Button>
<Button as={RouterLink} to="./nip19">
Nip-19 encode/decode
</Button>

View File

@ -1,5 +1,4 @@
import { Button, Flex, Spinner, Text } from "@chakra-ui/react";
import moment from "moment";
import { useOutletContext } from "react-router-dom";
import { NoteLink } from "../../components/note-link";
import { UserLink } from "../../components/user-link";
@ -46,7 +45,7 @@ export default function UserReportsTab() {
`${truncatedId(pubkey)}-reports`,
contextRelays,
{ authors: [pubkey], kinds: [1984] },
{ pageSize: moment.duration(1, "week").asSeconds() }
{ pageSize: 60 * 60 * 24 * 7 }
);
return (

View File

@ -4934,11 +4934,6 @@ mixme@^0.5.1:
resolved "https://registry.yarnpkg.com/mixme/-/mixme-0.5.9.tgz#a5a58e17354632179ff3ce5b0fc130899c8ba81c"
integrity sha512-VC5fg6ySUscaWUpI4gxCBTQMH2RdUpNrk+MsbpCYtIvf9SBJdiUey4qE7BXviJsJR4nDQxCZ+3yaYNW3guz/Pw==
moment@^2.29.4:
version "2.29.4"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"