add bookmark button

This commit is contained in:
hzrd149 2023-08-25 15:26:48 -05:00
parent f6f465611d
commit 0af6c2cfcd
16 changed files with 315 additions and 47 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add bookmark button to notes

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show note lists

View File

@ -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,
});

View File

@ -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<IconButtonProps, "icon">) {
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 (
<Menu closeOnSelect={false}>
<MenuButton as={IconButton} icon={inLists.length > 0 ? <BookmarkedIcon /> : <BookmarkIcon />} {...props} />
<MenuList minWidth="240px">
{lists.length > 0 && (
<MenuOptionGroup
type="checkbox"
value={inLists.map((list) => getEventCoordinate(list))}
onChange={handleChange}
>
{lists.map((list) => (
<MenuItemOption
key={getEventCoordinate(list)}
value={getEventCoordinate(list)}
isDisabled={account?.readonly && isLoading}
isTruncated
maxW="90vw"
>
{getListName(list)}
</MenuItemOption>
))}
</MenuOptionGroup>
)}
<MenuDivider />
<MenuItem icon={<PlusCircleIcon />} onClick={newListModal.onOpen}>
New list
</MenuItem>
{newListModal.isOpen && (
<NewListModal
onClose={newListModal.onClose}
isOpen
onCreated={newListModal.onClose}
initKind={NOTE_LIST_KIND}
/>
)}
</MenuList>
</Menu>
);
}

View File

@ -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) => {
/>
)}
<EventRelays event={event} />
<BookmarkButton event={event} aria-label="Bookmark note" size="sm" variant="link" />
<NoteMenu event={event} size="sm" variant="link" aria-label="More Options" />
</CardFooter>
</Card>

View File

@ -11,9 +11,11 @@ export type NoteRelaysProps = {
event: NostrEvent;
};
export const EventRelays = memo(({ event }: NoteRelaysProps & Omit<RelayIconStackProps, "relays" | "maxRelays">) => {
const maxRelays = useBreakpointValue({ base: 3, md: undefined });
const eventRelays = useSubject(getEventRelays(getEventUID(event)));
export const EventRelays = memo(
({ event, ...props }: NoteRelaysProps & Omit<RelayIconStackProps, "relays" | "maxRelays">) => {
const maxRelays = useBreakpointValue({ base: 3, md: undefined });
const eventRelays = useSubject(getEventRelays(getEventUID(event)));
return <RelayIconStack relays={eventRelays} direction="row-reverse" maxRelays={maxRelays} />;
});
return <RelayIconStack relays={eventRelays} direction="row-reverse" maxRelays={maxRelays} {...props} />;
},
);

View File

@ -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;

View File

@ -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 (
<Card>
<CardHeader display="flex" p="2" flex="1" gap="2" alignItems="center">
<Heading size="md">
<Link as={RouterLink} to={`/lists/${link}`}>
{getListName(event)}
</Link>
</Heading>
<CardHeader display="flex" p="2" flex="1" gap="2">
<Box>
<Heading size="md">
<Link as={RouterLink} to={`/lists/${link}`}>
{getListName(event)}
</Link>
</Heading>
<Text>Updated: {dayjs.unix(event.created_at).fromNow()}</Text>
</Box>
<Spacer />
<Text>Created by:</Text>
<UserAvatarLink pubkey={event.pubkey} size="xs" />
@ -38,15 +56,27 @@ export default function ListCard({ cord, event: maybeEvent }: { cord?: string; e
{people.length > 0 && (
<>
<Text>{people.length} people</Text>
<AvatarGroup overflow="hidden" mb="2">
<AvatarGroup overflow="hidden" mb="2" max={16} size="sm">
{people.map(({ pubkey, relay }) => (
<UserAvatarLink key={pubkey} pubkey={pubkey} relay={relay} />
))}
</AvatarGroup>
</>
)}
<EventRelays event={event} />
{notes.length > 0 && (
<>
<Text>{notes.length} notes</Text>
<Flex gap="2" wrap="wrap">
{notes.map(({ id, relay }) => (
<NoteLink key={id} noteId={id} />
))}
</Flex>
</>
)}
</CardBody>
<CardFooter p="2" display="flex">
<EventRelays event={event} ml="auto" />
</CardFooter>
</Card>
);
}

View File

@ -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<ModalProps, "children">;
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: "",
},
});

View File

@ -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 ? (
<Note event={event} />
) : (
<Text>
Loading <NoteLink noteId={id} />
</Text>
);
}

View File

@ -41,7 +41,7 @@ export default function UserCard({ pubkey, relay, list, ...props }: UserCardProp
<UserDnsIdentityIcon pubkey={pubkey} />
</Flex>
{account?.pubkey === list.pubkey ? (
<Button variant="outline" colorScheme="orange" onClick={handleRemoveFromList}>
<Button variant="outline" colorScheme="orange" onClick={handleRemoveFromList} size="sm">
Remove
</Button>
) : (

View File

@ -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 (
<Flex direction="column" p="2" gap="2">
<Flex gap="2">
@ -33,11 +39,27 @@ function ListsPage() {
</Button>
</Flex>
<ListCard cord={`3:${account.pubkey}`} />
<ListCard cord={`10000:${account.pubkey}`} />
{events.map((event) => (
<ListCard key={getEventUID(event)} event={event} />
))}
<Heading size="md">Special lists</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
<ListCard cord={`${Kind.Contacts}:${account.pubkey}`} />
<ListCard cord={`${MUTE_LIST_KIND}:${account.pubkey}`} />
<ListCard cord={`${PIN_LIST_KIND}:${account.pubkey}`} />
</SimpleGrid>
<Heading size="md">People lists</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
{peopleLists.map((event) => (
<ListCard key={getEventUID(event)} event={event} />
))}
</SimpleGrid>
<Heading size="md">Bookmark lists</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
{noteLists.map((event) => (
<ListCard key={getEventUID(event)} event={event} />
))}
</SimpleGrid>
{newList.isOpen && (
<NewListModal

View File

@ -2,16 +2,19 @@ import { Link as RouterList, useNavigate, useParams } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { UserLink } from "../../components/user-link";
import { Button, Flex, Heading, IconButton, useDisclosure } from "@chakra-ui/react";
import { Button, Divider, Flex, Heading, IconButton, SimpleGrid, useDisclosure } from "@chakra-ui/react";
import { ArrowLeftSIcon, CodeIcon } from "../../components/icons";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
import { parseCoordinate } from "../../helpers/nostr/events";
import { getListName, getPubkeysFromList } from "../../helpers/nostr/lists";
import { getEventsFromList, getListName, getPubkeysFromList } from "../../helpers/nostr/lists";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import { EventRelays } from "../../components/note/note-relays";
import UserCard from "./components/user-card";
import NoteDebugModal from "../../components/debug-modals/note-debug-modal";
import { Note } from "../../components/note";
import NoteCard from "./components/note-card";
import { TrustProvider } from "../../providers/trust";
function useListCoordinate() {
const { addr } = useParams() as { addr: string };
@ -45,6 +48,7 @@ export default function ListView() {
const isAuthor = account?.pubkey === event.pubkey;
const people = getPubkeysFromList(event);
const notes = getEventsFromList(event);
return (
<Flex direction="column" px="2" pt="2" pb="8" overflowY="auto" overflowX="hidden" h="full" gap="2">
@ -66,9 +70,32 @@ export default function ListView() {
)}
<IconButton icon={<CodeIcon />} aria-label="Show raw" onClick={debug.onOpen} />
</Flex>
{people.map(({ pubkey, relay }) => (
<UserCard pubkey={pubkey} relay={relay} list={event} />
))}
{people.length > 0 && (
<>
<Heading size="md">People</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
{people.map(({ pubkey, relay }) => (
<UserCard pubkey={pubkey} relay={relay} list={event} />
))}
</SimpleGrid>
</>
)}
{notes.length > 0 && (
<>
<Heading size="md">Notes</Heading>
<Divider />
<TrustProvider trust>
<Flex gap="2" direction="column">
{notes.map(({ id, relay }) => (
<NoteCard id={id} relay={relay} />
))}
</Flex>
</TrustProvider>
</>
)}
{debug.isOpen && <NoteDebugModal event={event} isOpen onClose={debug.onClose} size="4xl" />}
</Flex>

View File

@ -79,7 +79,7 @@ export default function UserAboutTab() {
gap="2"
pt={metadata?.banner ? 0 : "2"}
pb="8"
h="full"
minH="90vh"
>
<Box
pt={!expanded.isOpen ? "20vh" : 0}

View File

@ -112,6 +112,7 @@ const UserView = () => {
index={activeTab}
onChange={(v) => navigate(tabs[v].path, { replace: true })}
colorScheme="brand"
h="full"
>
<TabList overflowX="auto" overflowY="hidden" flexShrink={0}>
{tabs.map(({ label }) => (

View File

@ -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 (
<Flex direction="column" p="2" gap="2">
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2" py="2" px={["2", "2", 0]}>
<ListCard cord={`3:${pubkey}`} />
<ListCard cord={`10000:${pubkey}`} />
{events.map((event) => (
<ListCard key={getEventUID(event)} event={event} />
))}
</Flex>
</SimpleGrid>
);
}