From 0af6c2cfcd663a91da5c4c73ce2738d616a271f6 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Fri, 25 Aug 2023 15:26:48 -0500 Subject: [PATCH] add bookmark button --- .changeset/few-tools-suffer.md | 5 + .changeset/silent-rivers-search.md | 5 + src/components/icons.tsx | 12 ++ .../note/buttons/bookmark-button.tsx | 113 ++++++++++++++++++ src/components/note/index.tsx | 2 + src/components/note/note-relays.tsx | 12 +- src/helpers/nostr/lists.ts | 50 ++++++-- src/views/lists/components/list-card.tsx | 52 ++++++-- src/views/lists/components/new-list-modal.tsx | 9 +- src/views/lists/components/note-card.tsx | 18 +++ src/views/lists/components/user-card.tsx | 2 +- src/views/lists/index.tsx | 36 ++++-- src/views/lists/list.tsx | 37 +++++- src/views/user/about.tsx | 2 +- src/views/user/index.tsx | 1 + src/views/user/lists.tsx | 6 +- 16 files changed, 315 insertions(+), 47 deletions(-) create mode 100644 .changeset/few-tools-suffer.md create mode 100644 .changeset/silent-rivers-search.md create mode 100644 src/components/note/buttons/bookmark-button.tsx create mode 100644 src/views/lists/components/note-card.tsx diff --git a/.changeset/few-tools-suffer.md b/.changeset/few-tools-suffer.md new file mode 100644 index 000000000..e514ab019 --- /dev/null +++ b/.changeset/few-tools-suffer.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add bookmark button to notes diff --git a/.changeset/silent-rivers-search.md b/.changeset/silent-rivers-search.md new file mode 100644 index 000000000..dca4c025d --- /dev/null +++ b/.changeset/silent-rivers-search.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Show note lists diff --git a/src/components/icons.tsx b/src/components/icons.tsx index b581f3419..56577c8b5 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -337,3 +337,15 @@ export const ErrorIcon = createIcon({ d: "M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM11 7H13V13H11V7Z", defaultProps, }); + +export const BookmarkIcon = createIcon({ + displayName: "BookmarkIcon", + d: "M5 2H19C19.5523 2 20 2.44772 20 3V22.1433C20 22.4194 19.7761 22.6434 19.5 22.6434C19.4061 22.6434 19.314 22.6168 19.2344 22.5669L12 18.0313L4.76559 22.5669C4.53163 22.7136 4.22306 22.6429 4.07637 22.4089C4.02647 22.3293 4 22.2373 4 22.1433V3C4 2.44772 4.44772 2 5 2ZM18 4H6V19.4324L12 15.6707L18 19.4324V4Z", + defaultProps, +}); + +export const BookmarkedIcon = createIcon({ + displayName: "BookmaredkIcon", + d: "M4 2H20C20.5523 2 21 2.44772 21 3V22.2763C21 22.5525 20.7761 22.7764 20.5 22.7764C20.4298 22.7764 20.3604 22.7615 20.2963 22.7329L12 19.0313L3.70373 22.7329C3.45155 22.8455 3.15591 22.7322 3.04339 22.4801C3.01478 22.4159 3 22.3465 3 22.2763V3C3 2.44772 3.44772 2 4 2ZM12 13.5L14.9389 15.0451L14.3776 11.7725L16.7553 9.45492L13.4695 8.97746L12 6L10.5305 8.97746L7.24472 9.45492L9.62236 11.7725L9.06107 15.0451L12 13.5Z", + defaultProps, +}); diff --git a/src/components/note/buttons/bookmark-button.tsx b/src/components/note/buttons/bookmark-button.tsx new file mode 100644 index 000000000..7cc65b99d --- /dev/null +++ b/src/components/note/buttons/bookmark-button.tsx @@ -0,0 +1,113 @@ +import { useCallback, useState } from "react"; +import { + IconButton, + IconButtonProps, + Menu, + MenuButton, + MenuDivider, + MenuItem, + MenuItemOption, + MenuList, + MenuOptionGroup, + useDisclosure, + useToast, +} from "@chakra-ui/react"; + +import { useCurrentAccount } from "../../../hooks/use-current-account"; +import { useSigningContext } from "../../../providers/signing-provider"; +import useUserLists from "../../../hooks/use-user-lists"; +import { + NOTE_LIST_KIND, + draftAddEvent, + draftRemoveEvent, + getEventsFromList, + getListName, +} from "../../../helpers/nostr/lists"; +import { NostrEvent } from "../../../types/nostr-event"; +import { getEventCoordinate } from "../../../helpers/nostr/events"; +import clientRelaysService from "../../../services/client-relays"; +import NostrPublishAction from "../../../classes/nostr-publish-action"; +import { BookmarkIcon, BookmarkedIcon, PlusCircleIcon } from "../../icons"; +import NewListModal from "../../../views/lists/components/new-list-modal"; + +export default function BookmarkButton({ event, ...props }: { event: NostrEvent } & Omit) { + const toast = useToast(); + const newListModal = useDisclosure(); + const account = useCurrentAccount(); + const { requestSignature } = useSigningContext(); + const [isLoading, setLoading] = useState(false); + + const lists = useUserLists(account?.pubkey).filter((list) => list.kind === NOTE_LIST_KIND); + + const inLists = lists.filter((list) => getEventsFromList(list).some((p) => p.id === event.id)); + + const handleChange = useCallback( + async (cords: string | string[]) => { + if (!Array.isArray(cords)) return; + + const writeRelays = clientRelaysService.getWriteUrls(); + + setLoading(true); + try { + const addToList = lists.find((list) => !inLists.includes(list) && cords.includes(getEventCoordinate(list))); + const removeFromList = lists.find( + (list) => inLists.includes(list) && !cords.includes(getEventCoordinate(list)), + ); + + if (addToList) { + const draft = draftAddEvent(addToList, event.id); + const signed = await requestSignature(draft); + const pub = new NostrPublishAction("Add to list", writeRelays, signed); + } else if (removeFromList) { + const draft = draftRemoveEvent(removeFromList, event.id); + const signed = await requestSignature(draft); + const pub = new NostrPublishAction("Remove from list", writeRelays, signed); + } + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + setLoading(false); + }, + [lists, event.id], + ); + + return ( + + 0 ? : } {...props} /> + + {lists.length > 0 && ( + getEventCoordinate(list))} + onChange={handleChange} + > + {lists.map((list) => ( + + {getListName(list)} + + ))} + + )} + + } onClick={newListModal.onOpen}> + New list + + + {newListModal.isOpen && ( + + )} + + + ); +} diff --git a/src/components/note/index.tsx b/src/components/note/index.tsx index ebce5da42..4180efc07 100644 --- a/src/components/note/index.tsx +++ b/src/components/note/index.tsx @@ -32,6 +32,7 @@ import NoteContentWithWarning from "./note-content-with-warning"; import { TrustProvider } from "../../providers/trust"; import { NoteLink } from "../note-link"; import { useRegisterIntersectionEntity } from "../../providers/intersection-observer"; +import BookmarkButton from "./buttons/bookmark-button"; export type NoteProps = { event: NostrEvent; @@ -86,6 +87,7 @@ export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => { /> )} + diff --git a/src/components/note/note-relays.tsx b/src/components/note/note-relays.tsx index c6010ddae..d1045725e 100644 --- a/src/components/note/note-relays.tsx +++ b/src/components/note/note-relays.tsx @@ -11,9 +11,11 @@ export type NoteRelaysProps = { event: NostrEvent; }; -export const EventRelays = memo(({ event }: NoteRelaysProps & Omit) => { - const maxRelays = useBreakpointValue({ base: 3, md: undefined }); - const eventRelays = useSubject(getEventRelays(getEventUID(event))); +export const EventRelays = memo( + ({ event, ...props }: NoteRelaysProps & Omit) => { + const maxRelays = useBreakpointValue({ base: 3, md: undefined }); + const eventRelays = useSubject(getEventRelays(getEventUID(event))); - return ; -}); + return ; + }, +); diff --git a/src/helpers/nostr/lists.ts b/src/helpers/nostr/lists.ts index 5a67a8d68..6e6c318a4 100644 --- a/src/helpers/nostr/lists.ts +++ b/src/helpers/nostr/lists.ts @@ -1,19 +1,23 @@ import dayjs from "dayjs"; -import { DraftNostrEvent, NostrEvent, isDTag, isPTag } from "../../types/nostr-event"; import { Kind } from "nostr-tools"; +import { DraftNostrEvent, NostrEvent, isDTag, isETag, isPTag } from "../../types/nostr-event"; export const PEOPLE_LIST_KIND = 30000; export const NOTE_LIST_KIND = 30001; +export const PIN_LIST_KIND = 10001; export const MUTE_LIST_KIND = 10000; export function getListName(event: NostrEvent) { if (event.kind === 3) return "Following"; - return event.tags.find(isDTag)?.[1]; + return event.tags.find((t) => t[0] === "title")?.[1] || event.tags.find(isDTag)?.[1]; } export function getPubkeysFromList(event: NostrEvent) { return event.tags.filter(isPTag).map((t) => ({ pubkey: t[1], relay: t[2] })); } +export function getEventsFromList(event: NostrEvent) { + return event.tags.filter(isETag).map((t) => ({ id: t[1], relay: t[2] })); +} export function isPubkeyInList(event?: NostrEvent, pubkey?: string) { if (!pubkey || !event) return false; @@ -37,25 +41,49 @@ export function createEmptyMuteList(): DraftNostrEvent { }; } -export function draftAddPerson(event: NostrEvent | DraftNostrEvent, pubkey: string, relay?: string) { - if (event.tags.some((t) => t[0] === "p" && t[1] === pubkey)) throw new Error("person already in list"); +export function draftAddPerson(list: NostrEvent | DraftNostrEvent, pubkey: string, relay?: string) { + if (list.tags.some((t) => t[0] === "p" && t[1] === pubkey)) throw new Error("person already in list"); const draft: DraftNostrEvent = { created_at: dayjs().unix(), - kind: event.kind, - content: event.content, - tags: [...event.tags, relay ? ["p", pubkey, relay] : ["p", pubkey]], + kind: list.kind, + content: list.content, + tags: [...list.tags, relay ? ["p", pubkey, relay] : ["p", pubkey]], }; return draft; } -export function draftRemovePerson(event: NostrEvent | DraftNostrEvent, pubkey: string) { +export function draftRemovePerson(list: NostrEvent | DraftNostrEvent, pubkey: string) { const draft: DraftNostrEvent = { created_at: dayjs().unix(), - kind: event.kind, - content: event.content, - tags: event.tags.filter((t) => t[0] !== "p" || t[1] !== pubkey), + kind: list.kind, + content: list.content, + tags: list.tags.filter((t) => !(t[0] === "p" && t[1] === pubkey)), + }; + + return draft; +} + +export function draftAddEvent(list: NostrEvent | DraftNostrEvent, event: string, relay?: string) { + if (list.tags.some((t) => t[0] === "e" && t[1] === event)) throw new Error("event already in list"); + + const draft: DraftNostrEvent = { + created_at: dayjs().unix(), + kind: list.kind, + content: list.content, + tags: [...list.tags, relay ? ["e", event, relay] : ["e", event]], + }; + + return draft; +} + +export function draftRemoveEvent(list: NostrEvent | DraftNostrEvent, event: string) { + const draft: DraftNostrEvent = { + created_at: dayjs().unix(), + kind: list.kind, + content: list.content, + tags: list.tags.filter((t) => !(t[0] === "e" && t[1] === event)), }; return draft; diff --git a/src/views/lists/components/list-card.tsx b/src/views/lists/components/list-card.tsx index 9d6edceb3..45dfee55d 100644 --- a/src/views/lists/components/list-card.tsx +++ b/src/views/lists/components/list-card.tsx @@ -1,33 +1,51 @@ import { Link as RouterLink } from "react-router-dom"; -import { AvatarGroup, Card, CardBody, CardHeader, Heading, Link, Spacer, Text } from "@chakra-ui/react"; +import { + AvatarGroup, + Box, + Card, + CardBody, + CardFooter, + CardHeader, + Flex, + Heading, + Link, + Spacer, + Text, +} from "@chakra-ui/react"; +import { Kind } from "nostr-tools"; +import dayjs from "dayjs"; import { UserAvatarLink } from "../../../components/user-avatar-link"; import { UserLink } from "../../../components/user-link"; import EventVerificationIcon from "../../../components/event-verification-icon"; -import { getListName, getPubkeysFromList } from "../../../helpers/nostr/lists"; +import { getEventsFromList, getListName, getPubkeysFromList } from "../../../helpers/nostr/lists"; import { getSharableEventNaddr } from "../../../helpers/nip19"; import { NostrEvent } from "../../../types/nostr-event"; import useReplaceableEvent from "../../../hooks/use-replaceable-event"; -import { Kind } from "nostr-tools"; import { createCoordinate } from "../../../services/replaceable-event-requester"; import { EventRelays } from "../../../components/note/note-relays"; +import { NoteLink } from "../../../components/note-link"; export default function ListCard({ cord, event: maybeEvent }: { cord?: string; event?: NostrEvent }) { const event = maybeEvent ?? (cord ? useReplaceableEvent(cord as string) : undefined); if (!event) return null; const people = getPubkeysFromList(event); + const notes = getEventsFromList(event); const link = event.kind === Kind.Contacts ? createCoordinate(Kind.Contacts, event.pubkey) : getSharableEventNaddr(event); return ( - - - - {getListName(event)} - - + + + + + {getListName(event)} + + + Updated: {dayjs.unix(event.created_at).fromNow()} + Created by: @@ -38,15 +56,27 @@ export default function ListCard({ cord, event: maybeEvent }: { cord?: string; e {people.length > 0 && ( <> {people.length} people - + {people.map(({ pubkey, relay }) => ( ))} )} - + {notes.length > 0 && ( + <> + {notes.length} notes + + {notes.map(({ id, relay }) => ( + + ))} + + + )} + + + ); } diff --git a/src/views/lists/components/new-list-modal.tsx b/src/views/lists/components/new-list-modal.tsx index 589126ebf..7a420ff24 100644 --- a/src/views/lists/components/new-list-modal.tsx +++ b/src/views/lists/components/new-list-modal.tsx @@ -23,14 +23,17 @@ import { useSigningContext } from "../../../providers/signing-provider"; import NostrPublishAction from "../../../classes/nostr-publish-action"; import clientRelaysService from "../../../services/client-relays"; -export type NewListModalProps = { onCreated?: (list: NostrEvent) => void } & Omit; +export type NewListModalProps = { onCreated?: (list: NostrEvent) => void; initKind?: number } & Omit< + ModalProps, + "children" +>; -export default function NewListModal({ onClose, onCreated, ...props }: NewListModalProps) { +export default function NewListModal({ onClose, onCreated, initKind, ...props }: NewListModalProps) { const toast = useToast(); const { requestSignature } = useSigningContext(); const { handleSubmit, register, formState } = useForm({ defaultValues: { - kind: PEOPLE_LIST_KIND, + kind: initKind || PEOPLE_LIST_KIND, name: "", }, }); diff --git a/src/views/lists/components/note-card.tsx b/src/views/lists/components/note-card.tsx new file mode 100644 index 000000000..511bc4dad --- /dev/null +++ b/src/views/lists/components/note-card.tsx @@ -0,0 +1,18 @@ +import { Text } from "@chakra-ui/react"; +import { Note } from "../../../components/note"; +import { NoteLink } from "../../../components/note-link"; +import { useReadRelayUrls } from "../../../hooks/use-client-relays"; +import useSingleEvent from "../../../hooks/use-single-event"; + +export default function NoteCard({ id, relay }: { id: string; relay?: string }) { + const readRelays = useReadRelayUrls(relay ? [relay] : []); + const { event } = useSingleEvent(id, readRelays); + + return event ? ( + + ) : ( + + Loading + + ); +} diff --git a/src/views/lists/components/user-card.tsx b/src/views/lists/components/user-card.tsx index 5d0dbaba2..3f43d4634 100644 --- a/src/views/lists/components/user-card.tsx +++ b/src/views/lists/components/user-card.tsx @@ -41,7 +41,7 @@ export default function UserCard({ pubkey, relay, list, ...props }: UserCardProp {account?.pubkey === list.pubkey ? ( - ) : ( diff --git a/src/views/lists/index.tsx b/src/views/lists/index.tsx index 3703b17f9..5e7e94ce9 100644 --- a/src/views/lists/index.tsx +++ b/src/views/lists/index.tsx @@ -1,4 +1,7 @@ -import { Button, Flex, Image, Link, Spacer, useDisclosure } from "@chakra-ui/react"; +import { Button, Divider, Flex, Heading, Image, Link, SimpleGrid, Spacer, useDisclosure } from "@chakra-ui/react"; +import { useNavigate } from "react-router-dom"; +import { Kind } from "nostr-tools"; + import { useCurrentAccount } from "../../hooks/use-current-account"; import { ExternalLinkIcon, PlusCircleIcon } from "../../components/icons"; import RequireCurrentAccount from "../../providers/require-current-account"; @@ -6,8 +9,8 @@ import ListCard from "./components/list-card"; import { getEventUID } from "../../helpers/nostr/events"; import useUserLists from "../../hooks/use-user-lists"; import NewListModal from "./components/new-list-modal"; -import { useNavigate } from "react-router-dom"; import { getSharableEventNaddr } from "../../helpers/nip19"; +import { MUTE_LIST_KIND, NOTE_LIST_KIND, PEOPLE_LIST_KIND, PIN_LIST_KIND } from "../../helpers/nostr/lists"; function ListsPage() { const account = useCurrentAccount()!; @@ -15,6 +18,9 @@ function ListsPage() { const newList = useDisclosure(); const navigate = useNavigate(); + const peopleLists = events.filter((event) => event.kind === PEOPLE_LIST_KIND); + const noteLists = events.filter((event) => event.kind === NOTE_LIST_KIND); + return ( @@ -33,11 +39,27 @@ function ListsPage() { - - - {events.map((event) => ( - - ))} + Special lists + + + + + + + People lists + + + {peopleLists.map((event) => ( + + ))} + + Bookmark lists + + + {noteLists.map((event) => ( + + ))} + {newList.isOpen && ( @@ -66,9 +70,32 @@ export default function ListView() { )} } aria-label="Show raw" onClick={debug.onOpen} /> - {people.map(({ pubkey, relay }) => ( - - ))} + + {people.length > 0 && ( + <> + People + + + {people.map(({ pubkey, relay }) => ( + + ))} + + + )} + + {notes.length > 0 && ( + <> + Notes + + + + {notes.map(({ id, relay }) => ( + + ))} + + + + )} {debug.isOpen && } diff --git a/src/views/user/about.tsx b/src/views/user/about.tsx index 3e087cb14..aa6c24c3e 100644 --- a/src/views/user/about.tsx +++ b/src/views/user/about.tsx @@ -79,7 +79,7 @@ export default function UserAboutTab() { gap="2" pt={metadata?.banner ? 0 : "2"} pb="8" - h="full" + minH="90vh" > { index={activeTab} onChange={(v) => navigate(tabs[v].path, { replace: true })} colorScheme="brand" + h="full" > {tabs.map(({ label }) => ( diff --git a/src/views/user/lists.tsx b/src/views/user/lists.tsx index 1e4015e20..33fd96eb6 100644 --- a/src/views/user/lists.tsx +++ b/src/views/user/lists.tsx @@ -1,5 +1,5 @@ import { useOutletContext } from "react-router-dom"; -import { Flex } from "@chakra-ui/react"; +import { SimpleGrid } from "@chakra-ui/react"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import useTimelineLoader from "../../hooks/use-timeline-loader"; @@ -20,12 +20,12 @@ export default function UserListsTab() { const events = useSubject(timeline.timeline); return ( - + {events.map((event) => ( ))} - + ); }