diff --git a/.changeset/tender-pumpkins-boil.md b/.changeset/tender-pumpkins-boil.md new file mode 100644 index 000000000..b97cd5b48 --- /dev/null +++ b/.changeset/tender-pumpkins-boil.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add delete button for lists diff --git a/src/components/note/note-menu.tsx b/src/components/note/note-menu.tsx index fc652e053..1957e8bc1 100644 --- a/src/components/note/note-menu.tsx +++ b/src/components/note/note-menu.tsx @@ -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) => { 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 ( <> @@ -84,7 +43,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit )} {account?.pubkey === event.pubkey && ( - } color="red.500" onClick={deleteModal.onOpen}> + } color="red.500" onClick={() => deleteEvent(event)}> Delete Note )} @@ -100,37 +59,6 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit )} - - {deleteModal.isOpen && ( - - - - - Delete Note? - - - - - setReason(e.target.value)} - placeholder="Reason (optional)" - mt="2" - /> - - - - - - - - - )} ); }; diff --git a/src/components/note/note-relays.tsx b/src/components/note/note-relays.tsx index 391f3749b..9a8b77cf1 100644 --- a/src/components/note/note-relays.tsx +++ b/src/components/note/note-relays.tsx @@ -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 & { 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); diff --git a/src/components/user-follow-button.tsx b/src/components/user-follow-button.tsx index 1c93acdfc..cb4f0b7c1 100644 --- a/src/components/user-follow-button.tsx +++ b/src/components/user-follow-button.tsx @@ -43,7 +43,6 @@ export const UserFollowButton = ({ }: { pubkey: string } & Omit) => { 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 ( diff --git a/src/hooks/use-event-relays.ts b/src/hooks/use-event-relays.ts new file mode 100644 index 000000000..cd771cab6 --- /dev/null +++ b/src/hooks/use-event-relays.ts @@ -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) ?? []; +} diff --git a/src/index.tsx b/src/index.tsx index 982526f79..da10484a8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( ); - -if (import.meta.env.DEV) { - window.moment = moment; -} diff --git a/src/providers/delete-event-provider.tsx b/src/providers/delete-event-provider.tsx new file mode 100644 index 000000000..21761fa1c --- /dev/null +++ b/src/providers/delete-event-provider.tsx @@ -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; +}; + +const DeleteEventContext = createContext({ + isLoading: false, + deleteEvent: () => Promise.reject(), +}); + +export function useDeleteEventContext() { + return useContext(DeleteEventContext); +} + +function EventPreview({ event }: { event: Event }) { + if (event.kind === Kind.Text) { + return ; + } + return {nip19.noteEncode(event.id)}; +} + +export default function DeleteEventProvider({ children }: PropsWithChildren) { + const toast = useToast(); + const account = useCurrentAccount(); + const [isLoading, setLoading] = useState(false); + const [event, setEvent] = useState(); + const [defer, setDefer] = useState>(); + const [reason, setReason] = useState(""); + + const eventRelays = useEventRelays(event?.id); + const writeRelays = useWriteRelayUrls(eventRelays); + + const deleteEvent = useCallback((event: Event) => { + setEvent(event); + const defer = createDefer(); + 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 ( + + {children} + {event && ( + + + + + Delete Note? + + + + + setReason(e.target.value)} + placeholder="Reason (optional)" + mt="2" + /> + + + + + Deleting from relays + + + + + {writeRelays.map((url) => ( + + + {url} + + ))} + + + + + + + + + + + + + + )} + + ); +} diff --git a/src/providers/index.tsx b/src/providers/index.tsx index de699762a..acef9c454 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -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 ( - {children} + + + {children} + + ); }; diff --git a/src/services/lists.ts b/src/services/lists.ts index 6a524fb2a..7bd19ce52 100644 --- a/src/services/lists.ts +++ b/src/services/lists.ts @@ -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]], diff --git a/src/views/hashtag/index.tsx b/src/views/hashtag/index.tsx index 13e5ae6ff..4eaf56e65 100644 --- a/src/views/hashtag/index.tsx +++ b/src/views/hashtag/index.tsx @@ -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)); diff --git a/src/views/home/global-tab.tsx b/src/views/home/global-tab.tsx index ee5c2bdcb..4ee2980d9 100644 --- a/src/views/home/global-tab.tsx +++ b/src/views/home/global-tab.tsx @@ -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)); diff --git a/src/views/lists/index.tsx b/src/views/lists/index.tsx index a768c8683..bfa792c30 100644 --- a/src/views/lists/index.tsx +++ b/src/views/lists/index.tsx @@ -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 ( <> diff --git a/src/views/lists/list.tsx b/src/views/lists/list.tsx index 0d294fbc4..e32da8668 100644 --- a/src/views/lists/list.tsx +++ b/src/views/lists/list.tsx @@ -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} - {isAuthor && } + {isAuthor && ( + + )} diff --git a/src/views/tools/index.tsx b/src/views/tools/index.tsx index c5609fde5..8ce5f6b24 100644 --- a/src/views/tools/index.tsx +++ b/src/views/tools/index.tsx @@ -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 + diff --git a/src/views/user/reports.tsx b/src/views/user/reports.tsx index 344de7ada..60f0ae18b 100644 --- a/src/views/user/reports.tsx +++ b/src/views/user/reports.tsx @@ -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 ( diff --git a/yarn.lock b/yarn.lock index 05a1329f0..131bd39da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"