diff --git a/src/app.tsx b/src/app.tsx index dcd3ada64..0fb38964a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -35,6 +35,8 @@ import UserMediaTab from "./views/user/media"; import ToolsHomeView from "./views/tools"; import Nip19ToolsView from "./views/tools/nip19"; import UserAboutTab from "./views/user/about"; +import ListsView from "./views/lists"; +import ListView from "./views/lists/list"; // code split search view because QrScanner library is 400kB const SearchView = React.lazy(() => import("./views/search")); @@ -94,6 +96,13 @@ const router = createHashRouter([ { path: "nip19", element: <Nip19ToolsView /> }, ], }, + { + path: "lists", + children: [ + { path: "", element: <ListsView /> }, + { path: ":addr", element: <ListView /> }, + ], + }, { path: "l/:link", element: <NostrLinkView /> }, { path: "t/:hashtag", element: <HashTagView /> }, { diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 41378382f..c570bd411 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -259,3 +259,27 @@ export const AtIcon = createIcon({ d: "M20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C13.6418 20 15.1681 19.5054 16.4381 18.6571L17.5476 20.3214C15.9602 21.3818 14.0523 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12V13.5C22 15.433 20.433 17 18.5 17C17.2958 17 16.2336 16.3918 15.6038 15.4659C14.6942 16.4115 13.4158 17 12 17C9.23858 17 7 14.7614 7 12C7 9.23858 9.23858 7 12 7C13.1258 7 14.1647 7.37209 15.0005 8H17V13.5C17 14.3284 17.6716 15 18.5 15C19.3284 15 20 14.3284 20 13.5V12ZM12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9Z", defaultProps, }); + +export const FollowingIcon = createIcon({ + displayName: "FollowingIcon", + d: "M14 14.252V16.3414C13.3744 16.1203 12.7013 16 12 16C8.68629 16 6 18.6863 6 22H4C4 17.5817 7.58172 14 12 14C12.6906 14 13.3608 14.0875 14 14.252ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11ZM17.7929 19.9142L21.3284 16.3787L22.7426 17.7929L17.7929 22.7426L14.2574 19.2071L15.6716 17.7929L17.7929 19.9142Z", + defaultProps, +}); + +export const FollowIcon = createIcon({ + displayName: "FollowIcon", + d: "M14 14.252V16.3414C13.3744 16.1203 12.7013 16 12 16C8.68629 16 6 18.6863 6 22H4C4 17.5817 7.58172 14 12 14C12.6906 14 13.3608 14.0875 14 14.252ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11ZM18 17V14H20V17H23V19H20V22H18V19H15V17H18Z", + defaultProps, +}); + +export const UnfollowIcon = createIcon({ + displayName: "UnfollowIcon", + d: "M14 14.252V16.3414C13.3744 16.1203 12.7013 16 12 16C8.68629 16 6 18.6863 6 22H4C4 17.5817 7.58172 14 12 14C12.6906 14 13.3608 14.0875 14 14.252ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11ZM19 17.5858L21.1213 15.4645L22.5355 16.8787L20.4142 19L22.5355 21.1213L21.1213 22.5355L19 20.4142L16.8787 22.5355L15.4645 21.1213L17.5858 19L15.4645 16.8787L16.8787 15.4645L19 17.5858Z", + defaultProps, +}); + +export const ListIcon = createIcon({ + displayName: "ListIcon", + d: "M8 4H21V6H8V4ZM3 3.5H6V6.5H3V3.5ZM3 10.5H6V13.5H3V10.5ZM3 17.5H6V20.5H3V17.5ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z", + defaultProps, +}); diff --git a/src/components/page/desktop-side-nav.tsx b/src/components/page/desktop-side-nav.tsx index 15c2f31e2..126183701 100644 --- a/src/components/page/desktop-side-nav.tsx +++ b/src/components/page/desktop-side-nav.tsx @@ -12,7 +12,7 @@ import { ProfileIcon, RelayIcon, SearchIcon, - ToolsIcon, + ListIcon, } from "../icons"; import ProfileLink from "./profile-link"; import AccountSwitcher from "./account-switcher"; @@ -45,6 +45,9 @@ export default function DesktopSideNav() { <Button onClick={() => navigate("/profile")} leftIcon={<ProfileIcon />}> Profile </Button> + <Button onClick={() => navigate("/lists")} leftIcon={<ListIcon />}> + Lists + </Button> <Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />}> Relays </Button> diff --git a/src/components/user-follow-button.tsx b/src/components/user-follow-button.tsx index 41d186a93..1c93acdfc 100644 --- a/src/components/user-follow-button.tsx +++ b/src/components/user-follow-button.tsx @@ -1,11 +1,42 @@ -import { Button, ButtonProps } from "@chakra-ui/react"; +import { + Button, + ButtonProps, + Menu, + MenuButton, + MenuList, + MenuItem, + MenuItemOption, + MenuGroup, + MenuOptionGroup, + MenuDivider, +} from "@chakra-ui/react"; import { useCurrentAccount } from "../hooks/use-current-account"; import useSubject from "../hooks/use-subject"; import clientFollowingService from "../services/client-following"; import { useUserContacts } from "../hooks/use-user-contacts"; +import "../services/lists"; +import { ArrowDownSIcon, FollowIcon, PlusCircleIcon, TrashIcon, UnfollowIcon } from "./icons"; +import useUserLists from "../hooks/use-user-lists"; import { useReadRelayUrls } from "../hooks/use-client-relays"; import { useAdditionalRelayContext } from "../providers/additional-relay-context"; +function UsersLists() { + const account = useCurrentAccount()!; + + const readRelays = useReadRelayUrls(useAdditionalRelayContext()); + const lists = useUserLists(account.pubkey, readRelays); + + return ( + <> + {Array.from(Object.entries(lists)).map(([name, list]) => ( + <MenuItem isDisabled={account.readonly} isTruncated maxW="90vw"> + {name} + </MenuItem> + ))} + </> + ); +} + export const UserFollowButton = ({ pubkey, ...props @@ -20,25 +51,52 @@ export const UserFollowButton = ({ const isFollowing = following.some((t) => t[1] === pubkey); const isFollowingMe = account && userContacts?.contacts.includes(account.pubkey); - const toggleFollow = async () => { - if (isFollowing) { - clientFollowingService.removeContact(pubkey); - } else { - clientFollowingService.addContact(pubkey); - } - - await clientFollowingService.savePending(); - }; + const userContacts = useUserContacts(pubkey, clientRelaysService.getReadUrls()); + const followLabel = account && userContacts?.contacts.includes(account.pubkey) ? "Follow Back" : "Follow"; return ( - <Button - colorScheme={isFollowing ? "orange" : "brand"} - {...props} - isLoading={savingDraft} - onClick={toggleFollow} - isDisabled={account?.readonly ?? true} - > - {isFollowing ? "Unfollow" : isFollowingMe ? "Follow Back" : "Follow"} - </Button> + <Menu> + <MenuButton + as={Button} + colorScheme="brand" + {...props} + rightIcon={<ArrowDownSIcon />} + isDisabled={account?.readonly ?? true} + > + {isFollowing ? "Unfollow" : followLabel} + </MenuButton> + <MenuList> + {isFollowing ? ( + <MenuItem + onClick={() => clientFollowingService.removeContact(pubkey)} + icon={<UnfollowIcon />} + isDisabled={account?.readonly} + > + Unfollow + </MenuItem> + ) : ( + <MenuItem + onClick={() => clientFollowingService.addContact(pubkey)} + icon={<FollowIcon />} + isDisabled={account?.readonly} + > + {followLabel} + </MenuItem> + )} + {account && ( + <> + <MenuItem icon={<TrashIcon />} isDisabled={account.readonly}> + Remove from all + </MenuItem> + <MenuDivider /> + <UsersLists /> + <MenuDivider /> + <MenuItem icon={<PlusCircleIcon />} isDisabled={account.readonly}> + New list + </MenuItem> + </> + )} + </MenuList> + </Menu> ); }; diff --git a/src/hooks/use-user-lists.ts b/src/hooks/use-user-lists.ts new file mode 100644 index 000000000..979b0d7f2 --- /dev/null +++ b/src/hooks/use-user-lists.ts @@ -0,0 +1,12 @@ +import { useMemo } from "react"; +import listsService from "../services/lists"; +import useSubject from "./use-subject"; + +export default function useUserLists(pubkey: string, relays: string[], alwaysFetch?: boolean) { + const subject = useMemo(() => { + if (relays.length === 0) return; + return listsService.loadListsForPubkey(pubkey, relays, alwaysFetch); + }, [pubkey, relays.join("|"), alwaysFetch]); + + return useSubject(subject) || {}; +} diff --git a/src/services/lists.ts b/src/services/lists.ts new file mode 100644 index 000000000..6a524fb2a --- /dev/null +++ b/src/services/lists.ts @@ -0,0 +1,118 @@ +import moment from "moment"; +import { NostrRequest } from "../classes/nostr-request"; +import { PersistentSubject } from "../classes/subject"; +import { DraftNostrEvent, NostrEvent, isPTag } from "../types/nostr-event"; +import { nip19 } from "nostr-tools"; +import { getEventRelays } from "./event-relays"; +import relayScoreboardService from "./relay-scoreboard"; + +function getListName(event: NostrEvent) { + return event.tags.find((t) => t[0] === "d")?.[1]; +} + +export class List { + event: NostrEvent; + people = new PersistentSubject<{ pubkey: string; relay?: string }[]>([]); + + get author() { + return this.event.pubkey; + } + get name() { + return getListName(this.event)!; + } + + getAddress() { + // pick fastest for event + const relays = relayScoreboardService.getRankedRelays(getEventRelays(this.event.id).value).slice(0, 1); + + return nip19.naddrEncode({ + pubkey: this.event.pubkey, + identifier: this.name, + relays, + kind: this.event.kind, + }); + } + + constructor(event: NostrEvent) { + this.event = event; + this.updatePeople(); + } + + private updatePeople() { + const people = this.event.tags.filter(isPTag).map((p) => ({ pubkey: p[1], relay: p[2] })); + this.people.next(people); + } + handleEvent(event: NostrEvent) { + if (event.created_at > this.event.created_at) { + this.event = event; + this.updatePeople(); + } + } + + draftAddPerson(pubkey: string, relay?: string) { + 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(), + kind: this.event.kind, + content: this.event.content, + tags: [...this.event.tags, relay ? ["p", pubkey, relay] : ["p", pubkey]], + }; + + return draft; + } +} + +class ListsService { + private lists = new Map<string, List>(); + private pubkeyLists = new Map<string, PersistentSubject<Record<string, List>>>(); + + private fetchingPubkeys = new Set(); + fetchListsForPubkey(pubkey: string, relays: string[]) { + if (this.fetchingPubkeys.has(pubkey)) return this.pubkeyLists.get(pubkey)!; + this.fetchingPubkeys.add(pubkey); + + if (!this.pubkeyLists.has(pubkey)) { + this.pubkeyLists.set(pubkey, new PersistentSubject<Record<string, List>>({})); + } + let subject = this.pubkeyLists.get(pubkey)!; + + const request = new NostrRequest(relays); + request.onEvent.subscribe((event) => { + const listName = getListName(event); + + if (listName && event.kind === 30000) { + if (subject.value[listName]) { + subject.value[listName].handleEvent(event); + } else { + const list = new List(event); + this.lists.set(event.id, list); + subject.next({ ...subject.value, [listName]: list }); + } + } + }); + request.start({ kinds: [30000], authors: [pubkey] }); + + return subject; + } + + loadListsForPubkey(pubkey: string, relays: string[], alwaysFetch = false) { + if (!this.pubkeyLists.has(pubkey) || alwaysFetch) { + return this.fetchListsForPubkey(pubkey, relays); + } + return this.pubkeyLists.get(pubkey)!; + } + + getListsForPubkey(pubkey: string) { + return this.pubkeyLists.get(pubkey); + } +} + +const listsService = new ListsService(); + +if (import.meta.env.DEV) { + //@ts-ignore + window.listsService = listsService; +} + +export default listsService; diff --git a/src/views/lists/index.tsx b/src/views/lists/index.tsx new file mode 100644 index 000000000..a768c8683 --- /dev/null +++ b/src/views/lists/index.tsx @@ -0,0 +1,39 @@ +import { Box, Button, Divider, Flex } from "@chakra-ui/react"; +import { useCurrentAccount } from "../../hooks/use-current-account"; +import { useReadRelayUrls } from "../../hooks/use-client-relays"; +import useUserLists from "../../hooks/use-user-lists"; +import { Link as RouterLink } from "react-router-dom"; +import { PlusCircleIcon } from "../../components/icons"; + +function UsersLists() { + const account = useCurrentAccount()!; + + const readRelays = useReadRelayUrls(); + const lists = useUserLists(account.pubkey, readRelays); + + return ( + <> + {Array.from(Object.entries(lists)).map(([name, list]) => ( + <Button key={name} as={RouterLink} to={`./${list.getAddress()}`} isTruncated> + {name} + </Button> + ))} + </> + ); +} + +export default function ListsView() { + const account = useCurrentAccount(); + + return ( + <Flex direction="column" px="2" overflowY="auto" overflowX="hidden" h="full" gap="2"> + {account && ( + <> + <UsersLists /> + <Divider /> + <Button leftIcon={<PlusCircleIcon />}>New List</Button> + </> + )} + </Flex> + ); +} diff --git a/src/views/lists/list.tsx b/src/views/lists/list.tsx new file mode 100644 index 000000000..97735d80b --- /dev/null +++ b/src/views/lists/list.tsx @@ -0,0 +1,70 @@ +import { Link as RouterList, useParams } from "react-router-dom"; +import { nip19 } from "nostr-tools"; +import { useReadRelayUrls } from "../../hooks/use-client-relays"; +import useUserLists from "../../hooks/use-user-lists"; +import { UserLink } from "../../components/user-link"; +import useSubject from "../../hooks/use-subject"; +import { Button, Flex, Heading, Link } from "@chakra-ui/react"; +import { UserCard } from "../user/components/user-card"; +import { ArrowLeftSIcon, ExternalLinkIcon } from "../../components/icons"; +import { useCurrentAccount } from "../../hooks/use-current-account"; + +function useListPointer() { + const { addr } = useParams() as { addr: string }; + const pointer = nip19.decode(addr); + + switch (pointer.type) { + case "naddr": + if (pointer.data.kind !== 30000) throw new Error("Unknown event kind"); + return pointer.data; + default: + throw new Error(`Unknown type ${pointer.type}`); + } +} + +export default function ListView() { + const pointer = useListPointer(); + const account = useCurrentAccount(); + + const readRelays = useReadRelayUrls(pointer.relays); + const lists = useUserLists(pointer.pubkey, readRelays, true); + + const list = lists[pointer.identifier]; + const people = useSubject(list?.people) ?? []; + + if (!list) + return ( + <> + Looking for list "{pointer.identifier}" created by <UserLink pubkey={pointer.pubkey} /> + </> + ); + + const isAuthor = account?.pubkey === list.author; + + return ( + <Flex direction="column" px="2" pt="2" pb="8" overflowY="auto" overflowX="hidden" h="full" gap="2"> + <Flex gap="2" alignItems="center"> + <Button as={RouterList} to="/lists" leftIcon={<ArrowLeftSIcon />}> + Back + </Button> + + <Heading size="md" flex={1} isTruncated> + {list.name} + </Heading> + + {isAuthor && <Button colorScheme="red">Delete</Button>} + <Button + as={Link} + href={`https://listr.lol/a/${list.getAddress()}`} + target="_blank" + leftIcon={<ExternalLinkIcon />} + > + Edit + </Button> + </Flex> + {people.map(({ pubkey, relay }) => ( + <UserCard pubkey={pubkey} relay={relay} /> + ))} + </Flex> + ); +}